Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions demos/Termina.Demo.Gallery/Pages/ToastGalleryPage.cs
Original file line number Diff line number Diff line change
@@ -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<ToastGalleryViewModel>
{
private readonly IToastService _toastService;
private SelectionListNode<ToastPreset> _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<ToastPreset>(
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<string, ILayoutNode>(msg => new TextNode(msg).WithForeground(Color.White))
.AsLayout()
.Height(1));
}
}
32 changes: 32 additions & 0 deletions demos/Termina.Demo.Gallery/Pages/ToastGalleryViewModel.cs
Original file line number Diff line number Diff line change
@@ -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<string> StatusMessage { get; } = new("Press a key to trigger a toast");

public IReadOnlyList<ToastPreset> Presets { get; } = new List<ToastPreset>
{
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);
1 change: 1 addition & 0 deletions demos/Termina.Demo.Gallery/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
termina.RegisterRoute<LayoutGalleryPage, LayoutGalleryViewModel>("/layouts", NavigationBehavior.PreserveState);
termina.RegisterRoute<AnimationsGalleryPage, AnimationsGalleryViewModel>("/animations", NavigationBehavior.PreserveState);
termina.RegisterRoute<FilePickerGalleryPage, FilePickerGalleryViewModel>("/filepicker", NavigationBehavior.PreserveState);
termina.RegisterRoute<ToastGalleryPage, ToastGalleryViewModel>("/toasts", NavigationBehavior.PreserveState);
});

var host = builder.Build();
Expand Down
20 changes: 20 additions & 0 deletions docs/advanced/clipboard-and-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/Termina/Notifications/ToastMessage.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
using Termina.Terminal;

namespace Termina.Notifications;

/// <summary>
/// A transient toast message.
/// </summary>
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);
6 changes: 5 additions & 1 deletion src/Termina/Notifications/ToastOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using Termina.Terminal;

namespace Termina.Notifications;

/// <summary>
/// Optional display settings for a toast notification.
/// </summary>
public sealed record ToastOptions(
TimeSpan? Duration = null,
ToastPosition Position = ToastPosition.BottomRight);
ToastPosition Position = ToastPosition.BottomRight,
Color? Color = null,
string? Icon = null);
8 changes: 5 additions & 3 deletions src/Termina/Notifications/ToastOverlayNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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, '╮');
Expand All @@ -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, '╰');
Expand Down
2 changes: 1 addition & 1 deletion src/Termina/Notifications/ToastService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down