diff --git a/demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs b/demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs index 1c8211da..bcfa0d1f 100644 --- a/demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs +++ b/demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs @@ -17,7 +17,8 @@ public partial class GalleryMenuViewModel : ReactiveViewModel new("Clipboard", "OSC 52 copy, toasts, and paste validation", "/clipboard"), new("Layouts", "Vertical, horizontal, grid, panels, borders", "/layouts"), new("Animations", "Spinners, streaming text, progress indicators", "/animations"), - new("File Picker", "File/folder selection, directory navigation, fuzzy filtering", "/filepicker") + new("File Picker", "File/folder selection, directory navigation, fuzzy filtering", "/filepicker"), + new("Toast Notifications", "Colors, icons, positions, and presets", "/toasts") }; public void NavigateToGallery(string route) diff --git a/demos/Termina.Demo.Gallery/Pages/ToastGalleryPage.cs b/demos/Termina.Demo.Gallery/Pages/ToastGalleryPage.cs new file mode 100644 index 00000000..d2b2687d --- /dev/null +++ b/demos/Termina.Demo.Gallery/Pages/ToastGalleryPage.cs @@ -0,0 +1,99 @@ +// Copyright (c) Petabridge, LLC. All rights reserved. +// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information. + +using R3; +using Termina.Components.Streaming; +using Termina.Extensions; +using Termina.Layout; +using Termina.Notifications; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Termina.Demo.Gallery.Pages; + +public sealed class ToastGalleryPage : ReactivePage +{ + private readonly IToastService _toastService; + private SelectionListNode _presetList = null!; + + public ToastGalleryPage(IToastService toastService) + { + _toastService = toastService; + } + + public override void OnNavigatedTo() + { + base.OnNavigatedTo(); + + KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu")); + + _presetList.SelectionConfirmed + .Subscribe(items => + { + var item = items.FirstOrDefault(); + if (item != null) + ShowPreset(item); + }) + .DisposeWith(Subscriptions); + + Focus.PushFocus(_presetList); + } + + private void ShowPreset(ToastPreset preset) + { + _toastService.Show(preset.Message, new ToastOptions( + Duration: TimeSpan.FromSeconds(3), + Position: preset.Position, + Color: preset.Color, + Icon: preset.Icon)); + + ViewModel.StatusMessage.Value = $"Shown: {preset.Name} toast at {preset.Position}"; + } + + public override ILayoutNode BuildLayout() + { + _presetList = new SelectionListNode( + ViewModel.Presets, + item => + { + var colorLabel = item.Color.HasValue ? item.Color.Value.ToString() : "default"; + var iconLabel = item.Icon ?? "default"; + return new SelectionItemContent() + .AddLine( + new StaticTextSegment(item.Name, item.Color ?? Color.White, decoration: TextDecoration.Bold), + new StaticTextSegment($" {item.Message}", Color.Gray)) + .AddLine(new StaticTextSegment( + $" color={colorLabel} icon={iconLabel} position={item.Position}", + Color.DarkGray)); + }) + .WithMode(SelectionMode.Single) + .WithShowNumbers(true) + .WithHighlightColors(Color.Black, Color.Cyan) + .WithVisibleRows(8); + + return Layouts.Vertical() + .WithChild( + new PanelNode() + .WithTitle("Toast Notifications Gallery") + .WithBorder(BorderStyle.Double) + .WithBorderColor(Color.BrightYellow) + .WithContent( + Layouts.Vertical() + .WithChild( + new TextNode("\n Toast notifications with custom colors, icons, and positions.\n Select a preset and press Enter to trigger it.\n") + .WithForeground(Color.Gray)) + .WithChild(_presetList)) + .Fill()) + .WithChild( + new TextNode("[Enter] Show Toast [Esc] Menu") + .WithForeground(Color.BrightBlack) + .NoWrap() + .Height(1)) + .WithChild( + ViewModel.StatusMessage + .Select(msg => new TextNode(msg).WithForeground(Color.White)) + .AsLayout() + .Height(1)); + } +} diff --git a/demos/Termina.Demo.Gallery/Pages/ToastGalleryViewModel.cs b/demos/Termina.Demo.Gallery/Pages/ToastGalleryViewModel.cs new file mode 100644 index 00000000..9ae21e3e --- /dev/null +++ b/demos/Termina.Demo.Gallery/Pages/ToastGalleryViewModel.cs @@ -0,0 +1,32 @@ +// Copyright (c) Petabridge, LLC. All rights reserved. +// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information. + +using R3; +using Termina.Notifications; +using Termina.Reactive; +using Termina.Terminal; + +namespace Termina.Demo.Gallery.Pages; + +public class ToastGalleryViewModel : ReactiveViewModel +{ + public ReactiveProperty StatusMessage { get; } = new("Press a key to trigger a toast"); + + public IReadOnlyList Presets { get; } = new List + { + new("Success", "Operation completed", Color.BrightGreen, "✓", ToastPosition.TopRight), + new("Error", "Something went wrong", Color.BrightRed, "✗", ToastPosition.TopRight), + new("Warning", "Check your input", Color.BrightYellow, "⚠", ToastPosition.TopCenter), + new("Info", "3 items updated", Color.BrightCyan, "ℹ", ToastPosition.BottomRight), + new("Custom", "Deployed to production", Color.Magenta, "🚀", ToastPosition.BottomCenter), + new("Default", "Plain toast (no color/icon override)", null, null, ToastPosition.BottomRight) + }; + + public override void Dispose() + { + StatusMessage.Dispose(); + base.Dispose(); + } +} + +public record ToastPreset(string Name, string Message, Color? Color, string? Icon, ToastPosition Position); diff --git a/demos/Termina.Demo.Gallery/Program.cs b/demos/Termina.Demo.Gallery/Program.cs index 6d790c5e..c565bc82 100644 --- a/demos/Termina.Demo.Gallery/Program.cs +++ b/demos/Termina.Demo.Gallery/Program.cs @@ -41,6 +41,7 @@ termina.RegisterRoute("/layouts", NavigationBehavior.PreserveState); termina.RegisterRoute("/animations", NavigationBehavior.PreserveState); termina.RegisterRoute("/filepicker", NavigationBehavior.PreserveState); + termina.RegisterRoute("/toasts", NavigationBehavior.PreserveState); }); var host = builder.Build(); diff --git a/docs/advanced/clipboard-and-feedback.md b/docs/advanced/clipboard-and-feedback.md index 9e70e971..3297228c 100644 --- a/docs/advanced/clipboard-and-feedback.md +++ b/docs/advanced/clipboard-and-feedback.md @@ -44,6 +44,26 @@ toastService.Show( This keeps feedback decoupled from the component that triggered it. +### Custom Colors and Icons + +Toast border color and icon are configurable via `ToastOptions`. When omitted, the defaults are a green border (`Color.BrightGreen`) and a checkmark icon (`✓`). + +```csharp +// Success toast with green border and checkmark +toastService.Show("Saved", new ToastOptions(Color: Color.BrightGreen, Icon: "✓")); + +// Error toast with red border and cross +toastService.Show("Failed to save", new ToastOptions(Color: Color.BrightRed, Icon: "✗")); + +// Warning toast with yellow border +toastService.Show("Check your input", new ToastOptions( + Color: Color.BrightYellow, + Icon: "⚠", + Position: ToastPosition.TopCenter)); +``` + +Both `Color` and `Icon` are optional. You can set one without the other — for example, change the border color while keeping the default checkmark icon, or supply a custom icon while keeping the default green border. + ## Inline Feedback If you do not want a global toast, components like `CopyableTextNode` can show local feedback instead. diff --git a/src/Termina/Notifications/ToastMessage.cs b/src/Termina/Notifications/ToastMessage.cs index 77be135f..30c69574 100644 --- a/src/Termina/Notifications/ToastMessage.cs +++ b/src/Termina/Notifications/ToastMessage.cs @@ -1,6 +1,12 @@ +using Termina.Terminal; + namespace Termina.Notifications; /// /// A transient toast message. /// -public sealed record ToastMessage(string Message, ToastPosition Position = ToastPosition.BottomRight); +public sealed record ToastMessage( + string Message, + ToastPosition Position = ToastPosition.BottomRight, + Color? Color = null, + string? Icon = null); diff --git a/src/Termina/Notifications/ToastOptions.cs b/src/Termina/Notifications/ToastOptions.cs index 9cb38be3..8c52b63b 100644 --- a/src/Termina/Notifications/ToastOptions.cs +++ b/src/Termina/Notifications/ToastOptions.cs @@ -1,3 +1,5 @@ +using Termina.Terminal; + namespace Termina.Notifications; /// @@ -5,4 +7,6 @@ namespace Termina.Notifications; /// public sealed record ToastOptions( TimeSpan? Duration = null, - ToastPosition Position = ToastPosition.BottomRight); + ToastPosition Position = ToastPosition.BottomRight, + Color? Color = null, + string? Icon = null); diff --git a/src/Termina/Notifications/ToastOverlayNode.cs b/src/Termina/Notifications/ToastOverlayNode.cs index 634e7c76..be292fe9 100644 --- a/src/Termina/Notifications/ToastOverlayNode.cs +++ b/src/Termina/Notifications/ToastOverlayNode.cs @@ -35,7 +35,8 @@ public override void Render(IRenderContext context, Rect bounds) if (!bounds.HasArea || _currentToast is null) return; - var message = $" {char.ConvertFromUtf32(0x2713)} {_currentToast.Message} "; + var icon = _currentToast.Icon ?? char.ConvertFromUtf32(0x2713); + var message = $" {icon} {_currentToast.Message} "; var width = Math.Min(bounds.Width, message.Length + 2); var height = 3; if (width <= 0 || bounds.Height < height) @@ -45,7 +46,8 @@ public override void Render(IRenderContext context, Rect bounds) var panelBounds = new Rect(x, y, width, height); var panelContext = context.CreateSubContext(panelBounds); - panelContext.SetForeground(Color.BrightGreen); + var borderColor = _currentToast.Color ?? Color.BrightGreen; + panelContext.SetForeground(borderColor); panelContext.WriteAt(0, 0, '╭'); panelContext.WriteAt(1, 0, new string('─', Math.Max(0, width - 2))); panelContext.WriteAt(width - 1, 0, '╮'); @@ -55,7 +57,7 @@ public override void Render(IRenderContext context, Rect bounds) panelContext.SetForeground(Color.White); panelContext.WriteAt(1, 1, message.Length > width - 2 ? message[..(width - 2)] : message.PadRight(width - 2)); panelContext.ResetColors(); - panelContext.SetForeground(Color.BrightGreen); + panelContext.SetForeground(borderColor); panelContext.WriteAt(width - 1, 1, '│'); panelContext.WriteAt(0, 2, '╰'); diff --git a/src/Termina/Notifications/ToastService.cs b/src/Termina/Notifications/ToastService.cs index 8b40462a..f16f5607 100644 --- a/src/Termina/Notifications/ToastService.cs +++ b/src/Termina/Notifications/ToastService.cs @@ -22,7 +22,7 @@ public void Show(string message, ToastOptions? options = null) return; var resolvedOptions = options ?? new ToastOptions(); - _currentToast.Value = new ToastMessage(message, resolvedOptions.Position); + _currentToast.Value = new ToastMessage(message, resolvedOptions.Position, resolvedOptions.Color, resolvedOptions.Icon); _dismissSubscription?.Dispose(); _dismissSubscription = Observable