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
6 changes: 6 additions & 0 deletions windows/Ghostty.Core/Panes/IPaneHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ internal interface IPaneHost
/// tab-level indicator.</summary>
event EventHandler<TabProgressState>? ProgressChanged;

/// <summary>Raised when the active leaf rings the bell (libghostty
/// ring-bell action). Only the active leaf is forwarded, matching
/// <see cref="ProgressChanged"/>; background panes ring audibly but
/// do not drive the window-level attention badge.</summary>
event EventHandler? BellRang;

/// <summary>
/// Split the active leaf with the given orientation. The new leaf
/// becomes the active leaf. <paramref name="snapshot"/> is recorded
Expand Down
12 changes: 12 additions & 0 deletions windows/Ghostty.Core/Tabs/TabManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ internal sealed class TabManager
public event EventHandler? LastTabClosed;
public event EventHandler? WindowTitleChanged;

/// <summary>Raised when any owned tab's active leaf rings the bell.
/// Window-level: the taskbar attention coordinator subscribes here.</summary>
public event EventHandler? BellRang;

/// <summary>
/// Raised AFTER the tab's manager subscriptions have been unwired
/// but BEFORE the tab is removed from <see cref="Tabs"/>. Fired
Expand Down Expand Up @@ -230,6 +234,10 @@ private TabModel CreateTab(ProfileSnapshot? snapshot)
// without needing a shared dictionary.
EventHandler<TabProgressState> 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
Expand All @@ -241,6 +249,7 @@ private TabModel CreateTab(ProfileSnapshot? snapshot)
tab.OnClose = () =>
{
host.ProgressChanged -= progressHandler;
host.BellRang -= bellHandler;
host.LastLeafClosed -= lastLeafHandler;
};
tab.PropertyChanged += OnTabPropertyChanged;
Expand Down Expand Up @@ -352,6 +361,8 @@ private void WireAdoptedTab(TabModel tab)
tab.PaneHost.LeafFocused += OnLeafFocused;
EventHandler<TabProgressState> 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
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions windows/Ghostty.Core/Taskbar/ITaskbarOverlaySink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Ghostty.Core.Taskbar;

/// <summary>
/// Narrow surface the <see cref="TaskbarAttentionCoordinator"/> writes
/// to. Implemented in the WinUI project by a facade forwarding to
/// <c>ITaskbarList3::SetOverlayIcon</c>. Tests use a recording fake.
///
/// Pure Ghostty.Core — no WinUI types so the coordinator is unit-
/// testable without dragging WinAppSDK in.
/// </summary>
internal interface ITaskbarOverlaySink
{
/// <summary>Show (<paramref name="active"/> == true) or clear the
/// attention overlay badge. Expected to be idempotent.</summary>
void SetAttention(bool active);
}
51 changes: 51 additions & 0 deletions windows/Ghostty.Core/Taskbar/TaskbarAttentionCoordinator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Ghostty.Core.Tabs;

namespace Ghostty.Core.Taskbar;

/// <summary>
/// Drives the Windows taskbar overlay "attention" badge. The badge is
/// the Windows equivalent of Ghostty's <c>bell-features = attention</c>
/// (on by default): request the user's attention when an unfocused
/// window rings the bell, until the window is refocused.
///
/// Pure-logic state machine. <see cref="OnBell"/> is wired to
/// <see cref="TabManager.BellRang"/>; <see cref="SetFocused"/> is driven
/// by the window's activation event. The WinUI-side facade writes to
/// <c>ITaskbarList3::SetOverlayIcon</c>. The <c>_attentionActive</c> guard
/// keeps it to one sink write per attention episode regardless of how many
/// bells fire.
/// </summary>
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();
}

/// <summary>A bell rang on the active leaf of some owned tab.</summary>
public void OnBell()
{
if (_focused) return;
if (_attentionActive) return;
_attentionActive = true;
_sink.SetAttention(true);
}

/// <summary>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).</summary>
public void SetFocused(bool focused)
{
_focused = focused;
if (focused && _attentionActive)
{
_attentionActive = false;
_sink.SetAttention(false);
}
}
}
3 changes: 3 additions & 0 deletions windows/Ghostty.Tests/Tabs/FakePaneHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down
46 changes: 46 additions & 0 deletions windows/Ghostty.Tests/Tabs/TabManagerBellTests.cs
Original file line number Diff line number Diff line change
@@ -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<FakePaneHost> hosts) NewManager()
{
var hosts = new List<FakePaneHost>();
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);
}
}
12 changes: 12 additions & 0 deletions windows/Ghostty.Tests/Taskbar/FakeTaskbarOverlaySink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using Ghostty.Core.Taskbar;

namespace Ghostty.Tests.Taskbar;

/// <summary>Recording fake for <see cref="ITaskbarOverlaySink"/>.
/// Stores every <see cref="SetAttention"/> argument in order.</summary>
internal sealed class FakeTaskbarOverlaySink : ITaskbarOverlaySink
{
public List<bool> Writes { get; } = new();
public void SetAttention(bool active) => Writes.Add(active);
}
101 changes: 101 additions & 0 deletions windows/Ghostty.Tests/Taskbar/TaskbarAttentionCoordinatorTests.cs
Original file line number Diff line number Diff line change
@@ -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<FakePaneHost> hosts, FakeTaskbarOverlaySink sink, TaskbarAttentionCoordinator coord) New()
{
var hosts = new List<FakePaneHost>();
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);
}
}
7 changes: 7 additions & 0 deletions windows/Ghostty/Controls/TerminalControl.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -307,6 +308,11 @@ private void OnScrollBarPointerWheelChanged(object sender, PointerRoutedEventArg
public event EventHandler? CloseRequested;
internal event EventHandler<Ghostty.Core.Tabs.TabProgressState>? ProgressChanged;

/// <summary>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.</summary>
internal event EventHandler? BellRang;

/// <summary>Raised when the shell prompt becomes interactive (OSC 133;B).
/// The first such event per surface marks the shell as responsive.</summary>
public event EventHandler? PromptReady;
Expand Down Expand Up @@ -545,6 +551,7 @@ internal void DisposeSurface()
ProgressChanged = null;
PromptReady = null;
FirstRender = null;
BellRang = null;
}

private static IntPtr AllocEmptyUtf8()
Expand Down
9 changes: 9 additions & 0 deletions windows/Ghostty/Hosting/GhosttyHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 12 additions & 0 deletions windows/Ghostty/NativeMethods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading