diff --git a/windows/Ghostty.Core/Panes/IPaneHost.cs b/windows/Ghostty.Core/Panes/IPaneHost.cs index 0e31fccc4a8..94908c45d81 100644 --- a/windows/Ghostty.Core/Panes/IPaneHost.cs +++ b/windows/Ghostty.Core/Panes/IPaneHost.cs @@ -34,6 +34,12 @@ internal interface IPaneHost /// tab-level indicator. event EventHandler? ProgressChanged; + /// Raised when the active leaf rings the bell (libghostty + /// ring-bell action). Only the active leaf is forwarded, matching + /// ; background panes ring audibly but + /// do not drive the window-level attention badge. + event EventHandler? BellRang; + /// /// Split the active leaf with the given orientation. The new leaf /// becomes the active leaf. is recorded diff --git a/windows/Ghostty.Core/Tabs/TabManager.cs b/windows/Ghostty.Core/Tabs/TabManager.cs index d07566337fd..821fb40eaa9 100644 --- a/windows/Ghostty.Core/Tabs/TabManager.cs +++ b/windows/Ghostty.Core/Tabs/TabManager.cs @@ -54,6 +54,10 @@ internal sealed class TabManager public event EventHandler? LastTabClosed; public event EventHandler? WindowTitleChanged; + /// Raised when any owned tab's active leaf rings the bell. + /// Window-level: the taskbar attention coordinator subscribes here. + public event EventHandler? BellRang; + /// /// Raised AFTER the tab's manager subscriptions have been unwired /// but BEFORE the tab is removed from . Fired @@ -230,6 +234,10 @@ private TabModel CreateTab(ProfileSnapshot? snapshot) // without needing a shared dictionary. EventHandler progressHandler = (_, state) => tab.Progress = state; host.ProgressChanged += progressHandler; + // Forward the active-leaf bell up to the window level. Captured + // as a local so CloseTab can unsubscribe alongside progress. + EventHandler bellHandler = (_, _) => BellRang?.Invoke(this, EventArgs.Empty); + host.BellRang += bellHandler; // Bridge "the last leaf in this tab closed" (e.g. the only shell // in this tab exited via `exit`, or libghostty's close-surface // callback fired for the sole pane) into a tab-level close. The @@ -241,6 +249,7 @@ private TabModel CreateTab(ProfileSnapshot? snapshot) tab.OnClose = () => { host.ProgressChanged -= progressHandler; + host.BellRang -= bellHandler; host.LastLeafClosed -= lastLeafHandler; }; tab.PropertyChanged += OnTabPropertyChanged; @@ -352,6 +361,8 @@ private void WireAdoptedTab(TabModel tab) tab.PaneHost.LeafFocused += OnLeafFocused; EventHandler progressHandler = (_, state) => tab.Progress = state; tab.PaneHost.ProgressChanged += progressHandler; + EventHandler bellHandler = (_, _) => BellRang?.Invoke(this, EventArgs.Empty); + tab.PaneHost.BellRang += bellHandler; // Re-attach the last-leaf-closed bridge in the adopter's event // graph. See CreateTab for why the bridge exists; without it, // a tab detached to a new window would no longer close that @@ -364,6 +375,7 @@ private void WireAdoptedTab(TabModel tab) tab.OnClose = () => { tab.PaneHost.ProgressChanged -= progressHandler; + tab.PaneHost.BellRang -= bellHandler; tab.PaneHost.LastLeafClosed -= lastLeafHandler; }; tab.PropertyChanged += OnTabPropertyChanged; diff --git a/windows/Ghostty.Core/Taskbar/ITaskbarOverlaySink.cs b/windows/Ghostty.Core/Taskbar/ITaskbarOverlaySink.cs new file mode 100644 index 00000000000..2f3c335b62b --- /dev/null +++ b/windows/Ghostty.Core/Taskbar/ITaskbarOverlaySink.cs @@ -0,0 +1,16 @@ +namespace Ghostty.Core.Taskbar; + +/// +/// Narrow surface the writes +/// to. Implemented in the WinUI project by a facade forwarding to +/// ITaskbarList3::SetOverlayIcon. Tests use a recording fake. +/// +/// Pure Ghostty.Core — no WinUI types so the coordinator is unit- +/// testable without dragging WinAppSDK in. +/// +internal interface ITaskbarOverlaySink +{ + /// Show ( == true) or clear the + /// attention overlay badge. Expected to be idempotent. + void SetAttention(bool active); +} diff --git a/windows/Ghostty.Core/Taskbar/TaskbarAttentionCoordinator.cs b/windows/Ghostty.Core/Taskbar/TaskbarAttentionCoordinator.cs new file mode 100644 index 00000000000..6c7d4aac412 --- /dev/null +++ b/windows/Ghostty.Core/Taskbar/TaskbarAttentionCoordinator.cs @@ -0,0 +1,51 @@ +using Ghostty.Core.Tabs; + +namespace Ghostty.Core.Taskbar; + +/// +/// Drives the Windows taskbar overlay "attention" badge. The badge is +/// the Windows equivalent of Ghostty's bell-features = attention +/// (on by default): request the user's attention when an unfocused +/// window rings the bell, until the window is refocused. +/// +/// Pure-logic state machine. is wired to +/// ; is driven +/// by the window's activation event. The WinUI-side facade writes to +/// ITaskbarList3::SetOverlayIcon. The _attentionActive guard +/// keeps it to one sink write per attention episode regardless of how many +/// bells fire. +/// +internal sealed class TaskbarAttentionCoordinator +{ + private readonly ITaskbarOverlaySink _sink; + private bool _focused; // false until the first activation + private bool _attentionActive; + + public TaskbarAttentionCoordinator(TabManager manager, ITaskbarOverlaySink sink) + { + _sink = sink; + manager.BellRang += (_, _) => OnBell(); + } + + /// A bell rang on the active leaf of some owned tab. + public void OnBell() + { + if (_focused) return; + if (_attentionActive) return; + _attentionActive = true; + _sink.SetAttention(true); + } + + /// Window focus changed. Gaining focus clears any pending + /// attention badge; losing focus only records the state (the badge + /// appears later, if and when a bell actually rings). + public void SetFocused(bool focused) + { + _focused = focused; + if (focused && _attentionActive) + { + _attentionActive = false; + _sink.SetAttention(false); + } + } +} diff --git a/windows/Ghostty.Tests/Tabs/FakePaneHost.cs b/windows/Ghostty.Tests/Tabs/FakePaneHost.cs index 13e80251fc1..844c6c82f76 100644 --- a/windows/Ghostty.Tests/Tabs/FakePaneHost.cs +++ b/windows/Ghostty.Tests/Tabs/FakePaneHost.cs @@ -41,6 +41,9 @@ public void Split(PaneOrientation orientation, ProfileSnapshot? snapshot) public void RaiseProgressChanged(TabProgressState state) => ProgressChanged?.Invoke(this, state); + public event EventHandler? BellRang; + public void RaiseBellRang() => BellRang?.Invoke(this, EventArgs.Empty); + public void CloseActive() { CloseActiveCalls++; diff --git a/windows/Ghostty.Tests/Tabs/TabManagerBellTests.cs b/windows/Ghostty.Tests/Tabs/TabManagerBellTests.cs new file mode 100644 index 00000000000..3419cb940d3 --- /dev/null +++ b/windows/Ghostty.Tests/Tabs/TabManagerBellTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Ghostty.Core.Tabs; +using Xunit; + +namespace Ghostty.Tests.Tabs; + +public class TabManagerBellTests +{ + private static (TabManager mgr, List hosts) NewManager() + { + var hosts = new List(); + var mgr = new TabManager(_ => + { + var h = new FakePaneHost(); + hosts.Add(h); + return h; + }); + return (mgr, hosts); + } + + [Fact] + public void PaneHost_bell_is_forwarded_to_manager() + { + var (mgr, hosts) = NewManager(); + int count = 0; + mgr.BellRang += (_, _) => count++; + + hosts[0].RaiseBellRang(); + + Assert.Equal(1, count); + } + + [Fact] + public void Closed_tab_bell_is_not_forwarded() + { + var (mgr, hosts) = NewManager(); + mgr.NewTab(); // hosts[1] + int count = 0; + mgr.BellRang += (_, _) => count++; + + mgr.CloseTab(mgr.Tabs[1]); // unwires hosts[1] + hosts[1].RaiseBellRang(); + + Assert.Equal(0, count); + } +} diff --git a/windows/Ghostty.Tests/Taskbar/FakeTaskbarOverlaySink.cs b/windows/Ghostty.Tests/Taskbar/FakeTaskbarOverlaySink.cs new file mode 100644 index 00000000000..f3689ffc6fb --- /dev/null +++ b/windows/Ghostty.Tests/Taskbar/FakeTaskbarOverlaySink.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Ghostty.Core.Taskbar; + +namespace Ghostty.Tests.Taskbar; + +/// Recording fake for . +/// Stores every argument in order. +internal sealed class FakeTaskbarOverlaySink : ITaskbarOverlaySink +{ + public List Writes { get; } = new(); + public void SetAttention(bool active) => Writes.Add(active); +} diff --git a/windows/Ghostty.Tests/Taskbar/TaskbarAttentionCoordinatorTests.cs b/windows/Ghostty.Tests/Taskbar/TaskbarAttentionCoordinatorTests.cs new file mode 100644 index 00000000000..a08f833955c --- /dev/null +++ b/windows/Ghostty.Tests/Taskbar/TaskbarAttentionCoordinatorTests.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Ghostty.Core.Tabs; +using Ghostty.Core.Taskbar; +using Ghostty.Tests.Tabs; +using Xunit; + +namespace Ghostty.Tests.Taskbar; + +public class TaskbarAttentionCoordinatorTests +{ + private static (TabManager mgr, List hosts, FakeTaskbarOverlaySink sink, TaskbarAttentionCoordinator coord) New() + { + var hosts = new List(); + var mgr = new TabManager(_ => + { + var h = new FakePaneHost(); + hosts.Add(h); + return h; + }); + var sink = new FakeTaskbarOverlaySink(); + var coord = new TaskbarAttentionCoordinator(mgr, sink); + return (mgr, hosts, sink, coord); + } + + [Fact] + public void No_writes_on_construction() + { + var (_, _, sink, _) = New(); + Assert.Empty(sink.Writes); + } + + [Fact] + public void Bell_while_unfocused_shows_badge() + { + var (_, hosts, sink, coord) = New(); + coord.SetFocused(false); + + hosts[0].RaiseBellRang(); + + Assert.Equal(new[] { true }, sink.Writes); + } + + [Fact] + public void Bell_while_focused_does_nothing() + { + var (_, hosts, sink, coord) = New(); + coord.SetFocused(true); + + hosts[0].RaiseBellRang(); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void Focus_clears_active_badge() + { + var (_, hosts, sink, coord) = New(); + coord.SetFocused(false); + hosts[0].RaiseBellRang(); + + coord.SetFocused(true); + + Assert.Equal(new[] { true, false }, sink.Writes); + } + + [Fact] + public void Repeated_bells_coalesce_to_one_show() + { + var (_, hosts, sink, coord) = New(); + coord.SetFocused(false); + + hosts[0].RaiseBellRang(); + hosts[0].RaiseBellRang(); + hosts[0].RaiseBellRang(); + + Assert.Equal(new[] { true }, sink.Writes); + } + + [Fact] + public void Focus_with_no_pending_attention_does_not_clear() + { + var (_, _, sink, coord) = New(); + coord.SetFocused(false); + coord.SetFocused(true); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void Re_arms_after_clear() + { + var (_, hosts, sink, coord) = New(); + coord.SetFocused(false); + hosts[0].RaiseBellRang(); // true + coord.SetFocused(true); // false + coord.SetFocused(false); + hosts[0].RaiseBellRang(); // true + + Assert.Equal(new[] { true, false, true }, sink.Writes); + } +} diff --git a/windows/Ghostty/Controls/TerminalControl.xaml.cs b/windows/Ghostty/Controls/TerminalControl.xaml.cs index fed6550f124..3f824bf8d82 100644 --- a/windows/Ghostty/Controls/TerminalControl.xaml.cs +++ b/windows/Ghostty/Controls/TerminalControl.xaml.cs @@ -183,6 +183,7 @@ internal void RaiseProgressChanged(Ghostty.Core.Tabs.TabProgressState state) } internal void RaisePromptReady() => PromptReady?.Invoke(this, EventArgs.Empty); internal void RaiseFirstRender() => FirstRender?.Invoke(this, EventArgs.Empty); + internal void RaiseBellRang() => BellRang?.Invoke(this, EventArgs.Empty); // Called on the libghostty thread. Stashes the latest state and // enqueues a single UI-thread flush. Coalescing: if libghostty @@ -307,6 +308,11 @@ private void OnScrollBarPointerWheelChanged(object sender, PointerRoutedEventArg public event EventHandler? CloseRequested; internal event EventHandler? ProgressChanged; + /// Raised when libghostty rings the bell for this surface + /// (ring-bell action). PaneHost forwards the active leaf's bell up + /// to the window-level taskbar attention badge. + internal event EventHandler? BellRang; + /// Raised when the shell prompt becomes interactive (OSC 133;B). /// The first such event per surface marks the shell as responsive. public event EventHandler? PromptReady; @@ -545,6 +551,7 @@ internal void DisposeSurface() ProgressChanged = null; PromptReady = null; FirstRender = null; + BellRang = null; } private static IntPtr AllocEmptyUtf8() diff --git a/windows/Ghostty/Hosting/GhosttyHost.cs b/windows/Ghostty/Hosting/GhosttyHost.cs index 42dbc90c27e..7921b5fcbb8 100644 --- a/windows/Ghostty/Hosting/GhosttyHost.cs +++ b/windows/Ghostty/Hosting/GhosttyHost.cs @@ -625,7 +625,16 @@ private byte OnAction(GhosttyApp _, IntPtr targetPtr, IntPtr actionPtr) case GhosttyActionTag.RingBell: { + // Audible system bell rings immediately on the + // libghostty thread; the attention badge is a UI + // concern, so hop to the UI thread to resolve the + // owning surface and raise BellRang. PInvoke.MessageBeep(MESSAGEBOX_STYLE.MB_OK); + _dispatcher.TryEnqueue(() => + { + if (TryResolveControl(surfaceHandle, out var c) && c is not null) + c.RaiseBellRang(); + }); return 1; } diff --git a/windows/Ghostty/NativeMethods.txt b/windows/Ghostty/NativeMethods.txt index 7e3b493ebc0..cae2ea8d02d 100644 --- a/windows/Ghostty/NativeMethods.txt +++ b/windows/Ghostty/NativeMethods.txt @@ -47,6 +47,18 @@ SYSTEM_METRICS_INDEX // gdi32 - CreateSolidBrush and SetClassLongPtr are not in CsWin32 // 0.3.269 metadata for this platform target; hand-written in MainWindow. +// gdi32 / shell - taskbar overlay attention icon (AttentionOverlayIcon) +CreateDIBSection +CreateBitmap +CreateIconIndirect +DeleteObject +DestroyIcon +GetDC +ReleaseDC +ICONINFO +BITMAPV5HEADER +DIB_USAGE + // dwmapi DwmSetWindowAttribute DWMWINDOWATTRIBUTE diff --git a/windows/Ghostty/Panes/PaneHost.cs b/windows/Ghostty/Panes/PaneHost.cs index c13f6a648fc..38e7106beac 100644 --- a/windows/Ghostty/Panes/PaneHost.cs +++ b/windows/Ghostty/Panes/PaneHost.cs @@ -160,6 +160,25 @@ private void BindActiveLeafProgress() private void OnActiveLeafProgressChanged(object? sender, Ghostty.Core.Tabs.TabProgressState state) => ProgressChanged?.Invoke(this, state); + /// Raised when the active leaf's terminal rings the bell. + /// Rewired across leaf-focus changes, mirroring + /// . + public event EventHandler? BellRang; + + private TerminalControl? _bellBoundTerminal; + + private void BindActiveLeafBell() + { + var next = _activeLeaf.Terminal(); + if (ReferenceEquals(next, _bellBoundTerminal)) return; + _bellBoundTerminal?.BellRang -= OnActiveLeafBellRang; + _bellBoundTerminal = next; + next.BellRang += OnActiveLeafBellRang; + } + + private void OnActiveLeafBellRang(object? sender, EventArgs e) + => BellRang?.Invoke(this, EventArgs.Empty); + /// /// Raised when the last leaf in the tree closes. The owning /// TabManager subscribes and routes to @@ -315,6 +334,7 @@ public PaneHost(GhosttyHost host, Func termin Loaded += (_, _) => { BindActiveLeafProgress(); + BindActiveLeafBell(); LeafFocused?.Invoke(this, _activeLeaf); // Evict expired undo entries ~once a second; dispose any shell // that is no longer reachable from the live tree or history. @@ -327,8 +347,8 @@ public PaneHost(GhosttyHost host, Func termin TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); }; - // Rebind progress whenever the active leaf changes later. - LeafFocused += (_, _) => BindActiveLeafProgress(); + // Rebind progress and bell whenever the active leaf changes later. + LeafFocused += (_, _) => { BindActiveLeafProgress(); BindActiveLeafBell(); }; } // Public operations ------------------------------------------------- @@ -509,6 +529,7 @@ public void DisposeAllLeaves() // must always be dropped. Matches TerminalControl.DisposeSurface. LeafFocused = null; ProgressChanged = null; + BellRang = null; LastLeafClosed = null; } diff --git a/windows/Ghostty/Shell/TaskbarHost.cs b/windows/Ghostty/Shell/TaskbarHost.cs index b62e587050e..6687b59f92e 100644 --- a/windows/Ghostty/Shell/TaskbarHost.cs +++ b/windows/Ghostty/Shell/TaskbarHost.cs @@ -6,6 +6,8 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; namespace Ghostty.Shell; @@ -25,6 +27,9 @@ internal sealed class TaskbarHost : IDisposable private readonly TaskbarList3Facade? _facade; private readonly TaskbarProgressCoordinator? _coordinator; private readonly DispatcherQueueTimer? _tickTimer; + private readonly TaskbarOverlayFacade? _overlayFacade; + private readonly TaskbarAttentionCoordinator? _attention; + private readonly Window? _window; public bool IsAvailable => _coordinator is not null; @@ -39,6 +44,18 @@ public TaskbarHost(Window window, TabManager tabs, ILogger logger) _facade, () => DateTime.UtcNow); + _overlayFacade = new TaskbarOverlayFacade(hwnd); + _attention = new TaskbarAttentionCoordinator(tabs, _overlayFacade); + _window = window; + // Window focus drives the attention badge: an unfocused bell + // sets it, regaining focus clears it. Seed from the current + // foreground state so a bell that arrives before the first + // Activated event is judged against real focus, not the + // coordinator's pessimistic default; Activated then tracks + // every later transition. + _attention.SetFocused(PInvoke.GetForegroundWindow() == new HWND(hwnd)); + window.Activated += OnWindowActivated; + _tickTimer = window.DispatcherQueue.CreateTimer(); _tickTimer.Interval = TimeSpan.FromSeconds(2); _tickTimer.IsRepeating = true; @@ -68,9 +85,21 @@ public void OnAppWindowChanged(AppWindow appWindow) } } + /// + /// Drive the attention badge from window focus. Gaining focus clears + /// any pending badge; an unfocused bell sets it. + /// + private void OnWindowActivated(object sender, WindowActivatedEventArgs args) + { + _attention?.SetFocused( + args.WindowActivationState != WindowActivationState.Deactivated); + } + public void Dispose() { _tickTimer?.Stop(); + if (_window is not null) _window.Activated -= OnWindowActivated; + _overlayFacade?.Dispose(); } } diff --git a/windows/Ghostty/Taskbar/AttentionOverlayIcon.cs b/windows/Ghostty/Taskbar/AttentionOverlayIcon.cs new file mode 100644 index 00000000000..780c36bd721 --- /dev/null +++ b/windows/Ghostty/Taskbar/AttentionOverlayIcon.cs @@ -0,0 +1,109 @@ +using System; +using Windows.Win32; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Ghostty.Taskbar; + +/// +/// Builds a small filled-dot HICON for the taskbar attention overlay, +/// sized to the system small-icon metrics. Zero external assets: the +/// pixels are rasterized into a 32-bpp ARGB DIB section at runtime. +/// Caller owns the returned HICON and must DestroyIcon it. +/// +internal static class AttentionOverlayIcon +{ + // A filled dot reads as a standard corner "attention" badge on the + // taskbar button and, unlike FlashWindowEx, stays put until focus + // clears it and coexists with the progress indicator on the same + // button. Opaque early-sunrise amber, written premultiplied below. + private const byte ColorR = 0xFF, ColorG = 0x8A, ColorB = 0x1E; + + public static unsafe HICON Create() + { + int w = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSMICON); + int h = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSMICON); + if (w <= 0) w = 16; + if (h <= 0) h = 16; + + var header = new BITMAPV5HEADER + { + bV5Size = (uint)sizeof(BITMAPV5HEADER), + bV5Width = w, + bV5Height = -h, // negative => top-down rows + bV5Planes = 1, + bV5BitCount = 32, + bV5Compression = BI_COMPRESSION.BI_BITFIELDS, + bV5RedMask = 0x00FF0000, + bV5GreenMask = 0x0000FF00, + bV5BlueMask = 0x000000FF, + bV5AlphaMask = 0xFF000000, + }; + + void* bits; + HDC screen = PInvoke.GetDC(default); + HBITMAP color; + try + { + color = PInvoke.CreateDIBSection( + screen, (BITMAPINFO*)&header, DIB_USAGE.DIB_RGB_COLORS, + out bits, default, 0); + } + finally + { + PInvoke.ReleaseDC(default, screen); + } + if (color.IsNull) return default; + if (bits is null) + { + // CreateDIBSection never hands back a live handle with null + // bits in practice, but free it rather than leak the section + // if it ever did. + PInvoke.DeleteObject(color); + return default; + } + + RasterizeDot((byte*)bits, w, h); + + // CreateIconIndirect needs a mask bitmap even for a 32-bpp alpha + // color plane; an all-zero monochrome mask leaves the alpha in + // charge of the shape. + HBITMAP mask = PInvoke.CreateBitmap(w, h, 1, 1, null); + var info = new ICONINFO + { + fIcon = true, + hbmMask = mask, + hbmColor = color, + }; + HICON icon = PInvoke.CreateIconIndirect(in info); + + // CreateIconIndirect copies the bitmaps; free our originals. + if (!color.IsNull) PInvoke.DeleteObject(color); + if (!mask.IsNull) PInvoke.DeleteObject(mask); + return icon; + } + + // Fill a centered, edge-feathered disc into a top-down BGRA buffer + // with premultiplied alpha (CreateIconIndirect honors the alpha). + private static unsafe void RasterizeDot(byte* p, int w, int h) + { + double cx = (w - 1) / 2.0, cy = (h - 1) / 2.0; + double radius = (w < h ? w : h) / 2.0 - 0.5; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + double dx = x - cx, dy = y - cy; + double d = Math.Sqrt(dx * dx + dy * dy); + // 1px feather at the edge for anti-aliasing. + double cover = radius - d; + double a = cover >= 1 ? 1.0 : cover <= 0 ? 0.0 : cover; + byte* px = p + (y * w + x) * 4; + px[0] = (byte)(ColorB * a + 0.5); // blue (premultiplied) + px[1] = (byte)(ColorG * a + 0.5); // green + px[2] = (byte)(ColorR * a + 0.5); // red + px[3] = (byte)(0xFF * a + 0.5); // alpha + } + } + } +} diff --git a/windows/Ghostty/Taskbar/TaskbarOverlayFacade.cs b/windows/Ghostty/Taskbar/TaskbarOverlayFacade.cs new file mode 100644 index 00000000000..562b967723f --- /dev/null +++ b/windows/Ghostty/Taskbar/TaskbarOverlayFacade.cs @@ -0,0 +1,77 @@ +using System; +using System.Runtime.InteropServices; +using Ghostty.Core.Taskbar; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Ghostty.Taskbar; + +/// +/// Real implementation of . CoCreates +/// an , calls HrInit once, and forwards +/// attention writes to SetOverlayIcon against the window's HWND. +/// The dot HICON is built lazily on first show and cached for the +/// lifetime of the facade. +/// +/// One facade per window. +/// constructs it. Mirrors . +/// +internal sealed class TaskbarOverlayFacade : ITaskbarOverlaySink, IDisposable +{ + // Accessibility text surfaced by screen readers on the overlay. + private const string OverlayDescription = "Bell"; + + private readonly HWND _hwnd; + private readonly ITaskbarList3 _taskbar; + private HICON _icon; + + public TaskbarOverlayFacade(IntPtr hwnd) + { + _hwnd = new HWND(hwnd); + _taskbar = TaskbarList.CreateInstance(); + _taskbar.HrInit(); + } + + public void SetAttention(bool active) + { + // The overlay badge is a nice-to-have, like the progress + // indicator. SetAttention runs on the UI thread off Window. + // Activated and the bell path, so a COM failure here must not + // bubble into those callbacks and tear the window down — swallow + // it and leave the badge in its previous state. + try + { + if (active) + { + // Create() returns a null HICON on the rare GDI failure; + // SetOverlayIcon then just clears, and the next bell retries. + if (_icon.IsNull) _icon = AttentionOverlayIcon.Create(); + _taskbar.SetOverlayIcon(_hwnd, _icon, OverlayDescription); + } + else + { + // Null HICON clears the overlay; empty description clears + // the accessibility text alongside it. + _taskbar.SetOverlayIcon(_hwnd, default, string.Empty); + } + } + catch (COMException) + { + // Deliberately not logged: this fires on every focus + // transition, the indicator is cosmetic, and the sibling + // TaskbarList3Facade is likewise logger-free. A failure here + // just leaves the badge unchanged until the next bell/focus. + } + } + + public void Dispose() + { + if (!_icon.IsNull) + { + PInvoke.DestroyIcon(_icon); + _icon = default; + } + } +}