// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Aspire.Tray;
/// <summary>
/// The flyout panel shown when the tray icon is activated. It is the tray's only UI: it owns the
/// layout and every bit of interaction state (hover, press, keyboard focus, scrolling, the
/// collapsed recent list, a row's open action list) and turns <see cref="TrayViewState"/> into a
/// <see cref="TrayPanelScene"/>. The macOS and Windows hosts only forward input, draw the display
/// list, and run the returned actions, so both platforms look and behave the same.
/// </summary>
/// <remarks>
/// Not thread-safe: hosts call it from their UI thread only.
/// </remarks>
internal sealed class TrayPanel
{
public const double Width = 360;
public const double CornerRadius = 8;
public const double HeaderHeight = 44;
public const double FooterHeight = 44;
public const double RowHeight = 52;
public const double SectionHeaderHeight = 28;
public const double EmptyStateHeight = 132;
public const double ListTopPadding = 4;
public const double ListBottomPadding = 8;
public const double ButtonSize = 28;
public const double DefaultMaxHeight = 600;
public const double ConfirmHeight = 92;
public const double SettingsRowHeight = 62;
public const double AboutHeight = 84;
public const double ActionItemHeight = 30;
// The action list is a card under its row: 4 px above and below its items, plus a 6 px gap
// below the card so it doesn't touch the next row.
private const double ActionListPadding = 4;
private const double ActionListGap = 6;
// Trailing row buttons are laid out from the right edge of the row's content area.
private const double RowInset = 8;
private const double RowContentRight = Width - RowInset - 8;
private const double ButtonGap = 2;
private const double TextLeft = 52;
// The banner estimates whether its text needs a second line from an average glyph width
// for 12 px system UI text, rather than measuring, so both platforms lay it out the same.
private const double AverageBodyCharacterWidth = 6.4;
private TrayViewState _state;
private TrayViewState? _pendingState;
private TrayPanelPalette _palette;
private TrayPanelScene? _scene;
private double _maxHeight = DefaultMaxHeight;
private double _scroll;
private double _contentHeight;
private PanelRect _viewport;
private (double X, double Y)? _pointer;
private PanelTargetKey? _hover;
private PanelTargetKey? _pressed;
private PanelTargetKey? _focus;
private List<PanelAccessibleElement> _accessible = [];
private bool _focusVisible;
private bool? _recentExpanded;
private PanelConfirm? _confirm;
private PanelSettings? _settings;
private (PanelSection Section, AppHostId Host)? _actions;
private bool _openInExpanded;
private IReadOnlyList<string> _openInApplications = [];
private string _fileManagerName = "Finder";
private string _quitVerb = "Quit";
private double _listScroll;
public TrayPanel(TrayViewState state, TrayPanelPalette palette)
{
_state = state;
_palette = palette;
}
public TrayViewState State => _state;
public TrayPanelPalette Palette
{
get => _palette;
set
{
if (_palette != value)
{
_palette = value;
_scene = null;
}
}
}
/// <summary>The tallest the panel may be, usually the work area height minus a margin.</summary>
public double MaxHeight
{
get => _maxHeight;
set
{
value = Math.Max(HeaderHeight + FooterHeight + RowHeight, value);
if (_maxHeight != value)
{
_maxHeight = value;
_scene = null;
}
}
}
public TrayPanelScene Scene => _scene ??= Build();
/// <summary>The scrolled list area in panel coordinates.</summary>
public PanelRect Viewport
{
get
{
_ = Scene;
return _viewport;
}
}
public double ScrollOffset
{
get
{
_ = Scene;
return _scroll;
}
}
public PanelTargetKey? FocusedTarget => _focus;
public bool IsRecentExpanded => _recentExpanded ?? _state.AppHosts.Count == 0;
/// <summary>The inline confirmation being shown, if any.</summary>
public PanelConfirmKind? Confirmation => _confirm?.Kind;
/// <summary>Whether the panel shows its Settings view instead of the AppHost list.</summary>
public bool IsSettingsOpen => _settings is not null;
/// <summary>The row whose action list is open, if any.</summary>
public (PanelSection Section, AppHostId Host)? ActionsRow => _actions;
/// <summary>
/// The installed applications a row's action list offers to open the AppHost folder in, in
/// display order. The host discovers them; each one becomes an "Open in" item.
/// </summary>
public IReadOnlyList<string> OpenInApplications
{
get => _openInApplications;
set
{
if (!_openInApplications.SequenceEqual(value))
{
_openInApplications = [.. value];
_scene = null;
}
}
}
/// <summary>The platform's file manager, for the "Show in …" item: "Finder" or "File Explorer".</summary>
public string FileManagerName
{
get => _fileManagerName;
set
{
if (_fileManagerName != value)
{
_fileManagerName = value;
_scene = null;
}
}
}
/// <summary>
/// The platform's verb for leaving an app: "Quit" on macOS, "Exit" on Windows, as tray apps
/// there say. The button only closes the tray, so its tooltip says the AppHosts keep running.
/// </summary>
public string QuitVerb
{
get => _quitVerb;
set
{
if (_quitVerb != value)
{
_quitVerb = value;
_scene = null;
}
}
}
/// <summary>
/// Shows the keyboard focus on the first control, for a panel opened from the keyboard or by a
/// screen reader, so the user can see where Tab and the arrow keys start.
/// </summary>
public PanelResponse FocusFirst()
=> FocusAt(Scene.Targets.Where(target => target.Enabled && IsPrimary(target.Kind)).ToList(), 0);
/// <summary>
/// Switches to the Settings view, or refreshes it when it is already open. Focus starts on
/// the back arrow so Enter or Escape returns to the list.
/// </summary>
public PanelResponse ShowSettings(PanelSettings settings)
{
if (_settings is null)
{
_confirm = null;
_actions = null;
_listScroll = _scroll;
_scroll = 0;
_focus = new(PanelTargetKind.Back, PanelSection.None, null);
}
_settings = settings;
RefreshHover();
return PanelResponse.Changed;
}
/// <summary>Refreshes the Settings view after a toggle was applied; ignored when it is closed.</summary>
public PanelResponse UpdateSettings(PanelSettings settings)
{
if (_settings is null)
{
return PanelResponse.None;
}
_settings = settings;
_scene = null;
return PanelResponse.Changed;
}
/// <summary>
/// Replaces a recent row with a prompt to remove it, after the host found its source file
/// missing on Start. Confirming returns <see cref="PanelActionKind.RemoveRecent"/>.
/// </summary>
public PanelResponse ConfirmRemoveMissing(AppHostMenuItem host)
{
_settings = null;
_actions = null;
_recentExpanded = true;
BeginConfirm(new(PanelConfirmKind.RemoveMissing, PanelSection.Recent, host));
RefreshHover();
return PanelResponse.Changed;
}
/// <summary>
/// Replaces the state. While a press is in progress the update is deferred until release,
/// so the row under the pointer cannot move between press and release.
/// </summary>
public bool Update(TrayViewState state)
{
if (_pressed is not null)
{
_pendingState = state;
return false;
}
_state = state;
_scene = null;
return true;
}
/// <summary>Clears transient interaction state when the panel is shown again.</summary>
public void Reset()
{
if (_pendingState is not null)
{
_state = _pendingState;
_pendingState = null;
}
_pointer = null;
_hover = null;
_pressed = null;
_focus = null;
_focusVisible = false;
_scroll = 0;
_listScroll = 0;
_confirm = null;
_actions = null;
_openInExpanded = false;
_settings = null;
_scene = null;
}
/// <summary>
/// Activates a target the way a click would, for a screen reader's press (AXPress, UIA Invoke
/// or Toggle). The target also takes keyboard focus, so a follow-up key press continues from it.
/// </summary>
public PanelResponse Invoke(PanelTargetKey key)
{
var target = Scene.Targets.FirstOrDefault(target => target.Key == key);
if (target is not { Enabled: true })
{
return PanelResponse.None;
}
_focus = key;
var action = Activate(target);
if (_pendingState is not null)
{
_state = _pendingState;
_pendingState = null;
}
_scene = null;
return new(true, action);
}
/// <summary>
/// Moves keyboard focus to a target when a screen reader sets focus on its element, and
/// scrolls it into view like arrow keys do.
/// </summary>
public PanelResponse Focus(PanelTargetKey key)
{
var targets = Scene.Targets.Where(target => target.Enabled).ToList();
var index = targets.FindIndex(target => target.Key == key);
return index < 0 || _focus == key ? PanelResponse.None : FocusAt(targets, index);
}
public PanelHitTarget? HitTest(double x, double y)
{
var targets = Scene.Targets;
// Later targets sit on top: row buttons are added after their row.
for (var i = targets.Count - 1; i >= 0; i--)
{
if (targets[i].HitBounds.Contains(x, y))
{
return targets[i];
}
}
return null;
}
public PanelResponse PointerMoved(double x, double y)
{
_pointer = (x, y);
return SetHover(HitTest(x, y)?.Key);
}
public PanelResponse PointerLeft()
{
_pointer = null;
return SetHover(null);
}
public PanelResponse PointerDown(double x, double y)
{
_pointer = (x, y);
var target = HitTest(x, y);
_hover = target?.Key;
_pressed = target is { Enabled: true } ? target.Key : null;
_focusVisible = false;
_scene = null;
return PanelResponse.Changed;
}
public PanelResponse PointerUp(double x, double y)
{
_pointer = (x, y);
var target = HitTest(x, y);
var action = _pressed is { } pressed && target is { Enabled: true } && target.Key == pressed ? Activate(target) : null;
_pressed = null;
if (_pendingState is not null)
{
_state = _pendingState;
_pendingState = null;
}
_scene = null;
// Hit test again: a deferred state update or a toggled section may have moved rows.
_hover = HitTest(x, y)?.Key;
_scene = null;
return new(true, action);
}
/// <summary>
/// Scrolls the list by <paramref name="deltaY"/> DIPs. A positive delta reveals content above,
/// matching both NSEvent.scrollingDeltaY and a positive WM_MOUSEWHEEL delta (wheel pushed away).
/// </summary>
public PanelResponse Scroll(double deltaY)
{
var before = ScrollOffset;
_scroll = Math.Clamp(before - deltaY, 0, Math.Max(0, _contentHeight - _viewport.Height));
if (_scroll == before)
{
return PanelResponse.None;
}
_scene = null;
if (_pointer is { } pointer)
{
_hover = HitTest(pointer.X, pointer.Y)?.Key;
_scene = null;
}
return PanelResponse.Changed;
}
public PanelResponse Key(PanelKey key)
{
switch (key)
{
case PanelKey.Escape:
// Escape steps back one level: out of a confirmation or an action list, then
// out of Settings, and only then closes the panel.
if (_confirm is not null)
{
CancelConfirm();
return PanelResponse.Changed;
}
if (_actions is { } open && _openInExpanded)
{
// Out of the "Open in" applications first, back to the item that showed them.
_openInExpanded = false;
_focus = new(PanelTargetKind.OpenInMenu, open.Section, open.Host);
_scene = null;
return PanelResponse.Changed;
}
if (_actions is not null)
{
CloseActions(returnFocus: true);
return PanelResponse.Changed;
}
if (_settings is not null)
{
CloseSettings();
return PanelResponse.Changed;
}
return new(false, new PanelAction(PanelActionKind.Close));
case PanelKey.Enter or PanelKey.Space:
var focused = Scene.Targets.FirstOrDefault(target => target.Key == _focus);
if (focused is not { Enabled: true })
{
return PanelResponse.None;
}
var action = Activate(focused);
_scene = null;
return new(true, action);
case PanelKey.Tab or PanelKey.ShiftTab:
return MoveFocus(Scene.Targets.Where(target => target.Enabled).ToList(), key == PanelKey.Tab ? 1 : -1, wrap: true);
default:
var primary = Scene.Targets.Where(target => target.Enabled && IsPrimary(target.Kind)).ToList();
return key switch
{
PanelKey.Home => FocusAt(primary, 0),
PanelKey.End => FocusAt(primary, primary.Count - 1),
_ => MoveFocus(primary, key == PanelKey.Down ? 1 : -1, wrap: false)
};
}
}
private static bool IsPrimary(PanelTargetKind kind)
=> kind is PanelTargetKind.Row or PanelTargetKind.ToggleRecent or PanelTargetKind.Documentation
or PanelTargetKind.Settings or PanelTargetKind.Quit or PanelTargetKind.ConfirmCancel or PanelTargetKind.Back
or PanelTargetKind.LaunchAtSignIn || IsActionItem(kind);
private static bool IsConfirmTarget(PanelTargetKind kind)
=> kind is PanelTargetKind.ConfirmCancel or PanelTargetKind.ConfirmAccept;
/// <summary>The items of a row's action list.</summary>
private static bool IsActionItem(PanelTargetKind kind)
=> kind is PanelTargetKind.OpenInMenu or PanelTargetKind.OpenIn or PanelTargetKind.ShowInFolder or PanelTargetKind.CopyPath
or PanelTargetKind.TogglePin or PanelTargetKind.RemoveRecent;
/// <summary>
/// Opens the action list of a row under it, or closes it when it is already open. The list
/// takes the place of the native context menu: Open in, Show in Finder or File Explorer,
/// Copy path, Pin or Unpin, and Remove for a recent row.
/// </summary>
private void ToggleActions(PanelTargetKey row)
{
if (_actions == (row.Section, row.Host!.Value))
{
CloseActions(returnFocus: true);
return;
}
_confirm = null;
_actions = (row.Section, row.Host!.Value);
_openInExpanded = false;
_scene = null;
// Focus stays on the button that opened it: Down moves into the list.
RevealActions(row);
}
/// <summary>
/// Scrolls an action list that is below the fold into view, after it opened or grew, without
/// ever scrolling its row out of view.
/// </summary>
private void RevealActions(PanelTargetKey row)
{
var items = Scene.Targets.Where(target => IsActionItem(target.Kind)).ToList();
if (items.Count > 0)
{
var bottom = items.Max(target => target.Bounds.Bottom) + ActionListPadding;
var top = Scene.Targets.FirstOrDefault(target => target.Key == row with { Kind = PanelTargetKind.Row, Item = 0 })?.Bounds.Y ?? bottom;
if (bottom > _viewport.Bottom)
{
_scroll += Math.Min(bottom - _viewport.Bottom, Math.Max(0, top - _viewport.Y));
}
_scene = null;
}
}
private void CloseActions(bool returnFocus)
{
if (_actions is not { } actions)
{
return;
}
_actions = null;
_openInExpanded = false;
if (returnFocus)
{
// Return focus to the button that opened the list.
_focus = new(PanelTargetKind.More, actions.Section, actions.Host);
}
_scene = null;
}
private void BeginConfirm(PanelConfirm confirm)
{
_confirm = confirm;
_actions = null;
// Focus starts on Cancel, so Enter or Space right after the click never confirms.
_focus = new(PanelTargetKind.ConfirmCancel, confirm.Section, confirm.Host?.Id);
_scene = null;
// The strip is taller than the row it replaces and may be below the fold (a recent row
// reached by scrolling), so scroll the whole strip into view.
var buttons = Scene.Targets.Where(target => IsConfirmTarget(target.Kind)).ToList();
if (buttons.Count > 0)
{
var bottom = buttons.Max(target => target.Bounds.Bottom) + 14;
var top = bottom - ConfirmHeight;
if (bottom > _viewport.Bottom)
{
_scroll += bottom - _viewport.Bottom;
}
if (top < _viewport.Y)
{
_scroll -= _viewport.Y - top;
}
_scene = null;
}
}
private void CancelConfirm()
{
if (_confirm is not { } confirm)
{
return;
}
_confirm = null;
// Return focus to the control that opened the confirmation.
_focus = confirm.Kind switch
{
PanelConfirmKind.ClearRecent => new(PanelTargetKind.ClearRecent, confirm.Section, null),
_ => new(PanelTargetKind.Start, confirm.Section, confirm.Host?.Id)
};
_scene = null;
}
private void CloseSettings()
{
_settings = null;
_scroll = _listScroll;
_focus = new(PanelTargetKind.Settings, PanelSection.None, null);
RefreshHover();
}
/// <summary>Hit tests the pointer again after the layout changed under it.</summary>
private void RefreshHover()
{
_scene = null;
if (_pointer is { } pointer)
{
_hover = HitTest(pointer.X, pointer.Y)?.Key;
_scene = null;
}
}
private PanelResponse MoveFocus(List<PanelHitTarget> order, int step, bool wrap)
{
if (order.Count == 0)
{
return PanelResponse.None;
}
var index = order.FindIndex(target => target.Key == _focus);
if (index < 0 && _focus is { Host: not null } current)
{
// Up and Down from an inline button continue from that button's row.
index = order.FindIndex(target => target.Kind == PanelTargetKind.Row && target.Key.Host == current.Host
&& target.Key.Section == current.Section);
}
var next = index < 0
? (step > 0 ? 0 : order.Count - 1)
: wrap ? (index + step + order.Count) % order.Count : Math.Clamp(index + step, 0, order.Count - 1);
return FocusAt(order, next);
}
private PanelResponse FocusAt(List<PanelHitTarget> order, int index)
{
if (index < 0 || index >= order.Count)
{
return PanelResponse.None;
}
var target = order[index];
_focus = target.Key;
_focusVisible = true;
if (IsChrome(target.Kind))
{
// Header and footer buttons never scroll.
}
else if (target.Bounds.Y < _viewport.Y)
{
_scroll -= _viewport.Y - target.Bounds.Y;
}
else if (target.Bounds.Bottom > _viewport.Bottom)
{
_scroll += target.Bounds.Bottom - _viewport.Bottom;
}
_scene = null;
return PanelResponse.Changed;
}
private PanelResponse SetHover(PanelTargetKey? key)
{
if (_hover == key)
{
return PanelResponse.None;
}
_hover = key;
_focusVisible = false;
_scene = null;
return PanelResponse.Changed;
}
private PanelAction? Activate(PanelHitTarget target)
{
var host = target.Host;
var recent = target.Key.Section == PanelSection.Recent;
if (_confirm is not null && !IsConfirmTarget(target.Kind))
{
// Acting on anything else abandons the pending confirmation.
_confirm = null;
_scene = null;
}
if (_actions is not null && target.Kind is not (PanelTargetKind.More or PanelTargetKind.Row) && !IsActionItem(target.Kind))
{
// Likewise, acting on anything but the list (or the button that toggles it) closes it.
CloseActions(returnFocus: false);
}
switch (target.Kind)
{
// Clicking the row itself does nothing, whatever the AppHost's state, so it behaves
// the same for running and stopped AppHosts; its buttons carry the actions. The row
// stays a target so hover and keyboard focus reveal those buttons.
case PanelTargetKind.Row:
return null;
case PanelTargetKind.Dashboard:
return new(PanelActionKind.OpenDashboard, host, target.Bounds) { Recent = recent };
case PanelTargetKind.More:
ToggleActions(target.Key);
return null;
case PanelTargetKind.Stop:
return new(PanelActionKind.Stop, host, target.Bounds) { Recent = recent };
case PanelTargetKind.Start:
return new(PanelActionKind.Start, host, target.Bounds) { Recent = recent };
case PanelTargetKind.OpenInMenu:
// Show or hide the applications under "Open in", keeping the list open.
_openInExpanded = !_openInExpanded;
_scene = null;
RevealActions(target.Key);
return null;
case PanelTargetKind.OpenIn:
CloseActions(returnFocus: true);
return new(PanelActionKind.OpenIn, host, target.Bounds)
{
Recent = recent,
Application = target.Key.Item < _openInApplications.Count ? _openInApplications[target.Key.Item] : null
};
case PanelTargetKind.ShowInFolder:
CloseActions(returnFocus: true);
return new(PanelActionKind.ShowInFolder, host, target.Bounds) { Recent = recent };
case PanelTargetKind.CopyPath:
CloseActions(returnFocus: true);
return new(PanelActionKind.CopyPath, host, target.Bounds) { Recent = recent };
case PanelTargetKind.TogglePin:
CloseActions(returnFocus: true);
return new(PanelActionKind.TogglePin, host, target.Bounds) { Recent = recent };
case PanelTargetKind.RemoveRecent:
CloseActions(returnFocus: false);
return new(PanelActionKind.RemoveRecent, host, target.Bounds) { Recent = true };
case PanelTargetKind.ToggleRecent:
_recentExpanded = !IsRecentExpanded;
_scene = null;
return new(PanelActionKind.ToggleRecent, Anchor: target.Bounds);
case PanelTargetKind.ClearRecent:
BeginConfirm(new(PanelConfirmKind.ClearRecent, PanelSection.Recent, null));
return null;
case PanelTargetKind.ConfirmCancel:
CancelConfirm();
return null;
case PanelTargetKind.ConfirmAccept when _confirm is { } confirmed:
_confirm = null;
_scene = null;
return confirmed.Kind switch
{
PanelConfirmKind.ClearRecent => new(PanelActionKind.ClearRecent, Anchor: target.Bounds),
_ => new(PanelActionKind.RemoveRecent, host, target.Bounds) { Recent = true }
};
case PanelTargetKind.Back:
case PanelTargetKind.Settings when _settings is not null:
CloseSettings();
return null;
case PanelTargetKind.LaunchAtSignIn:
return new(PanelActionKind.SetLaunchAtSignIn, Anchor: target.Bounds) { Enabled = _settings?.Startup?.Enabled != true };
case PanelTargetKind.Documentation:
return new(PanelActionKind.Documentation, Anchor: target.Bounds);
case PanelTargetKind.Settings:
return new(PanelActionKind.Settings, Anchor: target.Bounds);
case PanelTargetKind.Quit:
return new(PanelActionKind.Quit, Anchor: target.Bounds);
default:
return null;
}
}
private TrayPanelScene Build()
{
var palette = _palette;
var state = _state;
var live = state.Discovery == DiscoveryState.Live;
var commands = new List<PanelCommand>();
var targets = new List<PanelHitTarget>();
// AddTarget and AddLabel append here in drawing order, which is also reading order.
var accessible = new List<PanelAccessibleElement>();
_accessible = accessible;
// Lay out the list first: its height decides the panel height and the scroll range.
var items = new List<ListItem>();
var y = ListTopPadding;
var running = state.AppHosts.Where(host => host.Id.AppHostPid != 0).ToList();
var pinned = state.AppHosts.Where(host => host.Id.AppHostPid == 0).ToList();
if (_settings is { } settings)
{
AddSettingsItems(settings);
}
else
{
RevalidateConfirm();
RevalidateActions();
if (state.AppHosts.Count == 0)
{
Add(ListItemKind.Empty, EmptyStateHeight, PanelSection.None, null);
}
AddSection(PanelSection.Running, running, expanded: true);
AddSection(PanelSection.Pinned, pinned, expanded: true);
AddSection(PanelSection.Recent, state.RecentAppHosts, IsRecentExpanded);
}
_contentHeight = y + ListBottomPadding;
void Add(ListItemKind kind, double height, PanelSection section, AppHostMenuItem? host, PanelTargetKind target = default)
{
items.Add(new(kind, y, height, section, host, target));
y += height;
}
void AddSection(PanelSection section, IReadOnlyList<AppHostMenuItem> hosts, bool expanded)
{
if (hosts.Count == 0)
{
return;
}
// An inline confirmation takes the place of the item it is about, so the list
// keeps its order and the strip sits exactly where the user clicked.
if (section == PanelSection.Recent && _confirm is { Kind: PanelConfirmKind.ClearRecent })
{
Add(ListItemKind.Confirm, ConfirmHeight, section, null);
}
else
{
Add(ListItemKind.SectionHeader, SectionHeaderHeight, section, null);
}
if (!expanded)
{
return;
}
foreach (var host in hosts)
{
var confirming = _confirm is { Kind: PanelConfirmKind.RemoveMissing } confirm
&& confirm.Section == section && confirm.Host?.Id == host.Id;
Add(confirming ? ListItemKind.Confirm : ListItemKind.Row, confirming ? ConfirmHeight : RowHeight, section, host);
if (!confirming && _actions == (section, host.Id))
{
var count = GetRowActions(host, section).Count;
Add(ListItemKind.Actions, count * ActionItemHeight + 2 * ActionListPadding + ActionListGap, section, host);
}
}
}
void AddSettingsItems(PanelSettings settings)
{
Add(ListItemKind.SectionHeader, SectionHeaderHeight, PanelSection.None, null, PanelTargetKind.LaunchAtSignIn);
Add(ListItemKind.SettingsToggle, SettingsRowHeight, PanelSection.None, null, PanelTargetKind.LaunchAtSignIn);
Add(ListItemKind.SectionHeader, SectionHeaderHeight, PanelSection.None, null, PanelTargetKind.Documentation);
Add(ListItemKind.About, AboutHeight, PanelSection.None, null);
}
var notice = string.IsNullOrWhiteSpace(state.Notice) ? null : state.Notice.Trim();
var noticeSingleLine = notice is not null && notice.Length * AverageBodyCharacterWidth <= NoticeTextWidth;
// The banner sits 8 px below the header bar and 8 px above the list.
var noticeHeight = notice is null ? 0 : (noticeSingleLine ? 34 : 50) + 16;
var chrome = HeaderHeight + noticeHeight + FooterHeight;
var height = Math.Min(_maxHeight, chrome + _contentHeight);
_viewport = new(0, HeaderHeight + noticeHeight, Width, Math.Max(0, height - chrome));
_scroll = Math.Clamp(_scroll, 0, Math.Max(0, _contentHeight - _viewport.Height));
var listOrigin = _viewport.Y - _scroll;
// Panel background and footer strip. The window itself is transparent (macOS) or
// rounded by DWM or a region (Windows), so the panel draws its own rounded body.
var panel = new PanelRect(0, 0, Width, height);
var footer = new PanelRect(0, height - FooterHeight, Width, FooterHeight);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(panel, CornerRadius), palette.Background));
commands.Add(new FillPathCommand(PanelPath.RoundedRect(footer, 0, 0, CornerRadius, CornerRadius), palette.Gutter));
commands.Add(new FillPathCommand(PanelPath.Rectangle(new(0, footer.Y, Width, 1)), palette.Separator));
// The header is a bar like the footer: the same height and color, with a separator that
// mirrors the footer's on its bottom edge.
commands.Add(new FillPathCommand(PanelPath.RoundedRect(new(0, 0, Width, HeaderHeight), CornerRadius, CornerRadius, 0, 0), palette.Gutter));
commands.Add(new FillPathCommand(PanelPath.Rectangle(new(0, HeaderHeight - 1, Width, 1)), palette.Separator));
if (_settings is null)
{
AddHeader(commands, state, palette);
}
else
{
AddSettingsHeader(commands, targets, palette);
}
if (notice is not null)
{
AddNotice(commands, notice, noticeSingleLine, noticeHeight - 16, palette);
}
if (_scroll > 0 && notice is not null)
{
// Mark the edge only when content is scrolled under the banner; under the header
// bar, its own separator already does.
commands.Add(new FillPathCommand(PanelPath.Rectangle(new(0, _viewport.Y, Width, 1)), palette.Separator));
}
var duplicateNames = running.GroupBy(host => host.DisplayName, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1).Select(group => group.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
commands.Add(new PushClipCommand(_viewport));
var hidden = new List<PanelCommand>();
foreach (var item in items)
{
var top = listOrigin + item.Y;
// Items scrolled out of view are not drawn but keep their targets (with empty hit
// bounds), so keyboard navigation can reach them and scroll them into view.
var sink = top + item.Height <= _viewport.Y || top >= _viewport.Bottom ? hidden : commands;
switch (item.Kind)
{
case ListItemKind.Empty:
AddEmptyState(sink, state.Discovery, top, palette);
break;
case ListItemKind.SectionHeader when _settings is not null:
var general = item.Target == PanelTargetKind.LaunchAtSignIn;
var headerBounds = new PanelRect(20, top + 4, 200, 24);
sink.Add(new TextCommand(headerBounds, general ? "GENERAL" : "ABOUT", PanelFontRole.Section, palette.TextTertiary));
// Screen readers get the title case: some spell out all-caps words letter by letter.
AddLabel(general ? "settings.general" : "settings.about", general ? "General" : "About", headerBounds, chrome: false);
break;
case ListItemKind.SectionHeader:
AddSectionHeader(sink, targets, item.Section, top, state, palette);
break;
case ListItemKind.Row:
AddRow(sink, targets, item.Host!, item.Section, top, live,
item.Section == PanelSection.Running && duplicateNames.Contains(item.Host!.DisplayName), palette);
break;
case ListItemKind.Confirm:
AddConfirm(sink, targets, _confirm!, item.Host, top, palette);
break;
case ListItemKind.Actions:
AddActions(sink, targets, item.Host!, item.Section, top, item.Height, palette);
break;
case ListItemKind.SettingsToggle:
AddSettingsToggle(sink, targets, item.Target, _settings!, top, palette);
break;
case ListItemKind.About:
AddAbout(sink, _settings!, top, palette);
break;
}
}
commands.Add(new PopClipCommand());
if (_contentHeight > _viewport.Height && _viewport.Height > 0)
{
var thumb = Math.Max(24, _viewport.Height * _viewport.Height / _contentHeight);
var travel = _viewport.Height - thumb - 8;
var thumbY = _viewport.Y + 4 + travel * _scroll / (_contentHeight - _viewport.Height);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(new(Width - 7, thumbY, 4, thumb), 2), palette.TextTertiary.WithAlpha(0.4)));
}
AddFooter(commands, targets, footer, palette);
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(panel.Inset(0.5, 0.5), CornerRadius - 0.5), palette.Border, 1));
// Forget hover and focus on targets that disappeared, such as a row that was removed.
if (_focus is { } focus && !targets.Any(target => target.Key == focus))
{
_focus = null;
_focusVisible = false;
}
if (_hover is { } hover && !targets.Any(target => target.Key == hover))
{
_hover = null;
}
if (_focusVisible && targets.FirstOrDefault(target => target.Key == _focus) is { } focused)
{
var clip = IsChrome(focused.Kind) ? panel : _viewport;
commands.Add(new PushClipCommand(clip));
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(focused.Bounds.Inset(-1, -1), 7), palette.Focus, 2));
commands.Add(new PopClipCommand());
}
return new(Width, height, commands, targets, accessible);
}
private const double NoticeTextWidth = Width - 46 - 22;
// The header's one line: the logo over the rows' status glyphs, "Aspire" where the AppHost
// names start, then the connection dot and the status. "Aspire" is a fixed string, so its
// slot is sized for 15 px semibold system text (about 49 DIPs in SF and Segoe UI Variable)
// rather than measured, which keeps the layout identical on both platforms.
private const double HeaderTitleWidth = 56;
private void AddHeader(List<PanelCommand> commands, TrayViewState state, TrayPanelPalette palette)
{
var middle = HeaderHeight / 2;
commands.Add(new LogoCommand(new(20, middle - 10, 20, 20)));
var title = new PanelRect(TextLeft, middle - 10, HeaderTitleWidth, 20);
commands.Add(new TextCommand(title, "Aspire", PanelFontRole.Title, palette.TextPrimary));
AddLabel("header.title", "Aspire", title, chrome: true);
var (status, dot) = GetHeaderStatus(state, palette);
commands.Add(new FillPathCommand(PanelPath.Circle(title.Right + 4, middle, 4), dot));
var statusLeft = title.Right + 14;
var statusBounds = new PanelRect(statusLeft, middle - 8, Width - statusLeft - 16, 16);
commands.Add(new TextCommand(statusBounds, status, PanelFontRole.Body, palette.TextSecondary));
AddLabel("header.status", status, statusBounds, chrome: true);
}
private void AddNotice(List<PanelCommand> commands, string notice, bool singleLine, double height, TrayPanelPalette palette)
{
var banner = new PanelRect(12, HeaderHeight + 8, Width - 24, height);
AddLabel("notice", notice, banner, chrome: true);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(banner, 6), palette.Banner));
if (palette.IsHighContrast)
{
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(banner.Inset(0.5, 0.5), 5.5), palette.Border, 1));
}
TrayPanelGlyphs.AddIcon(commands, PanelIcon.Alert, new(banner.X + 10, banner.Y + 9, 16, 16), palette.BannerText);
var text = new PanelRect(banner.X + 34, banner.Y + 8, NoticeTextWidth, height - 16);
commands.Add(singleLine
? new TextCommand(text, notice, PanelFontRole.Body, palette.BannerText)
: new TextCommand(text, notice, PanelFontRole.Body, palette.BannerText, MaxLines: 2));
}
private void AddEmptyState(List<PanelCommand> commands, DiscoveryState discovery, double top, TrayPanelPalette palette)
{
var (title, hint) = discovery switch
{
DiscoveryState.Live => ("No AppHosts running", "Run “aspire run” in a project, or pin one to keep it here."),
DiscoveryState.Connecting => ("Looking for AppHosts…", "Connecting to the Aspire CLI."),
_ => ("AppHosts unavailable", "Running AppHosts will appear here once discovery is back.")
};
var art = new PanelRect(Width / 2 - 16, top + 20, 32, 32);
if (discovery == DiscoveryState.Connecting)
{
TrayPanelGlyphs.AddStatus(commands, PanelStatusGlyph.Busy, art.Inset(2, 2), palette);
}
else
{
TrayPanelGlyphs.AddIcon(commands, PanelIcon.Layers, art, palette.TextTertiary);
}
var titleBounds = new PanelRect(20, top + 62, Width - 40, 20);
var hintBounds = new PanelRect(44, top + 84, Width - 88, 34);
commands.Add(new TextCommand(titleBounds, title, PanelFontRole.Name, palette.TextPrimary, PanelTextAlignment.Center));
commands.Add(new TextCommand(hintBounds, hint, PanelFontRole.Body, palette.TextSecondary,
PanelTextAlignment.Center, MaxLines: 2));
AddLabel("empty.title", title, titleBounds, chrome: false);
AddLabel("empty.hint", hint, hintBounds, chrome: false);
}
private void AddSectionHeader(List<PanelCommand> commands, List<PanelHitTarget> targets, PanelSection section, double top,
TrayViewState state, TrayPanelPalette palette)
{
var label = section switch
{
PanelSection.Running => "RUNNING",
PanelSection.Pinned => "PINNED",
_ => $"RECENT · {state.RecentAppHosts.Count}"
};
var labelBounds = new PanelRect(20, top + 4, 200, 24);
if (section != PanelSection.Recent)
{
commands.Add(new TextCommand(labelBounds, label, PanelFontRole.Section, palette.TextTertiary));
AddLabel(section == PanelSection.Running ? "section.running" : "section.pinned",
section == PanelSection.Running ? "Running" : "Pinned", labelBounds, chrome: false);
return;
}
var expanded = IsRecentExpanded;
var toggle = new PanelRect(RowInset, top + 4, Width - 2 * RowInset, 24);
var toggleKey = new PanelTargetKey(PanelTargetKind.ToggleRecent, section, null);
AddInteractionFill(commands, toggleKey, toggle, palette, includeFocus: false);
commands.Add(new TextCommand(labelBounds, label, PanelFontRole.Section, palette.TextTertiary));
TrayPanelGlyphs.AddIcon(commands, expanded ? PanelIcon.ChevronDown : PanelIcon.ChevronRight,
new(RowContentRight - 22, top + 8, 16, 16), palette.TextTertiary);
var count = state.RecentAppHosts.Count;
AddTarget(targets, toggleKey, toggle, expanded ? $"Hide {count} recent AppHosts" : $"Show {count} recent AppHosts", true, null);
if (state.CanClearRecent)
{
var clear = new PanelRect(RowContentRight - 30 - 52, top + 6, 52, 20);
var clearKey = new PanelTargetKey(PanelTargetKind.ClearRecent, section, null);
AddInteractionFill(commands, clearKey, clear, palette, includeFocus: false);
commands.Add(new TextCommand(clear, "Clear", PanelFontRole.Body, palette.Accent, PanelTextAlignment.Center));
AddTarget(targets, clearKey, clear, "Clear recent AppHosts", true, null);
}
}
private void AddRow(List<PanelCommand> commands, List<PanelHitTarget> targets, AppHostMenuItem host, PanelSection section,
double top, bool live, bool showPid, TrayPanelPalette palette)
{
var rowKey = new PanelTargetKey(PanelTargetKind.Row, section, host.Id);
var row = new PanelRect(RowInset, top + 2, Width - 2 * RowInset, RowHeight - 4);
// A row whose action list is open keeps its buttons visible, so its "More" button can be
// seen as the list's owner and clicked again to close it.
var expanded = _actions == (section, host.Id);
var active = expanded || IsWithinRow(_hover, rowKey) || IsWithinRow(_pressed, rowKey) || (_focusVisible && IsWithinRow(_focus, rowKey));
// No pressed shade for the row itself: clicking it does nothing.
if (active)
{
commands.Add(new FillPathCommand(PanelPath.RoundedRect(row, 6), palette.Hover));
}
TrayPanelGlyphs.AddStatus(commands, GetStatusGlyph(host, live), new(20, top + 16, 20, 20), palette);
var buttons = host.Id.AppHostPid != 0
? new[] { PanelTargetKind.Dashboard, PanelTargetKind.Stop, PanelTargetKind.More }
: [PanelTargetKind.Start, PanelTargetKind.More];
var textRight = RowContentRight - buttons.Length * ButtonSize - (buttons.Length - 1) * ButtonGap - 8;
var (detail, isError) = GetRowDetail(host, live, showPid);
commands.Add(new TextCommand(new(TextLeft, top + 8, textRight - TextLeft, 18), host.DisplayName, PanelFontRole.Name, palette.TextPrimary));
commands.Add(new TextCommand(new(TextLeft, top + 27, textRight - TextLeft, 16), detail, PanelFontRole.Body,
isError ? palette.Danger : palette.TextSecondary, Truncation: isError ? PanelTruncation.End : PanelTruncation.Middle));
AddTarget(targets, rowKey, row, GetRowAccessibleName(host, detail), true, host);
var x = RowContentRight - buttons.Length * ButtonSize - (buttons.Length - 1) * ButtonGap;
foreach (var kind in buttons)
{
var bounds = new PanelRect(x, top + (RowHeight - ButtonSize) / 2, ButtonSize, ButtonSize);
x += ButtonSize + ButtonGap;
var key = rowKey with { Kind = kind };
var (icon, name, tooltip, enabled) = kind switch
{
PanelTargetKind.Dashboard => (PanelIcon.ExternalLink, $"Open dashboard for {host.DisplayName}", "Open dashboard", host.CanOpenDashboard),
PanelTargetKind.Stop => (PanelIcon.Stop, $"Stop {host.DisplayName}", "Stop", host.CanStop),
PanelTargetKind.Start => (PanelIcon.Play, $"Start {host.DisplayName}", "Start", host.CanStart),
_ => (PanelIcon.More, expanded ? $"Hide actions for {host.DisplayName}" : $"More actions for {host.DisplayName}",
expanded ? "Hide actions" : "More actions", true)
};
// Buttons always keep their space so the text never reflows on hover, and they
// stay hit-testable: the pointer is inside the row, so they are visible, before any click.
AddTarget(targets, key, bounds, name, enabled, host, tooltip: tooltip);
if (!active)
{
continue;
}
if (kind == PanelTargetKind.More && expanded && _hover != key && _pressed != key)
{
// Mark the button as selected while its list is open, like the gear for Settings.
commands.Add(new FillPathCommand(PanelPath.RoundedRect(bounds, 6), palette.Pressed));
}
if (enabled)
{
AddInteractionFill(commands, key, bounds, palette, includeFocus: false);
}
var color = !enabled ? palette.TextTertiary.WithAlpha(0.45)
: _hover == key || _pressed == key ? palette.TextPrimary
: palette.TextSecondary;
TrayPanelGlyphs.AddIcon(commands, icon, bounds.Inset(6, 6), color);
}
}
/// <summary>Closes an action list whose row went away, such as an AppHost that stopped.</summary>
private void RevalidateActions()
{
if (_actions is not { } actions)
{
return;
}
var hosts = actions.Section switch
{
PanelSection.Running => _state.AppHosts.Where(host => host.Id.AppHostPid != 0),
PanelSection.Pinned => _state.AppHosts.Where(host => host.Id.AppHostPid == 0),
_ => IsRecentExpanded ? _state.RecentAppHosts : []
};
if (!hosts.Any(host => host.Id == actions.Host))
{
CloseActions(returnFocus: false);
}
}
/// <summary>
/// The items of a row's action list, in display order. "Open in" is one item; while it is
/// expanded, the installed applications follow it as nested items.
/// </summary>
private List<(PanelTargetKind Kind, int Item, PanelIcon Icon, string Label, string Name)> GetRowActions(AppHostMenuItem host, PanelSection section)
{
var name = host.DisplayName;
var actions = new List<(PanelTargetKind, int, PanelIcon, string, string)>();
if (_openInApplications.Count > 0)
{
actions.Add((PanelTargetKind.OpenInMenu, 0, PanelIcon.AppWindow, "Open in",
_openInExpanded ? $"Hide applications to open {name} in" : $"Open {name} in…"));
for (var i = 0; _openInExpanded && i < _openInApplications.Count; i++)
{
var application = _openInApplications[i];
actions.Add((PanelTargetKind.OpenIn, i, PanelIcon.AppWindow, application, $"Open {name} in {application}"));
}
}
actions.Add((PanelTargetKind.ShowInFolder, 0, PanelIcon.Folder, $"Show in {_fileManagerName}", $"Show {name} in {_fileManagerName}"));
actions.Add((PanelTargetKind.CopyPath, 0, PanelIcon.Copy, "Copy path", $"Copy the folder path of {name}"));
actions.Add(host.IsPinned
? (PanelTargetKind.TogglePin, 0, PanelIcon.Unpin, "Unpin", $"Unpin {name}")
: (PanelTargetKind.TogglePin, 0, PanelIcon.Pin, "Pin", $"Pin {name}"));
if (section == PanelSection.Recent)
{
actions.Add((PanelTargetKind.RemoveRecent, 0, PanelIcon.Remove, "Remove from recent", $"Remove {name} from recent AppHosts"));
}
return actions;
}
/// <summary>
/// Draws a row's action list: a card under the row with one item per action, each an icon and
/// a label. It looks the same on both platforms, unlike the native menus it replaces, and its
/// items are ordinary targets, so the keyboard and screen readers reach them like any button.
/// </summary>
private void AddActions(List<PanelCommand> commands, List<PanelHitTarget> targets, AppHostMenuItem host, PanelSection section,
double top, double height, TrayPanelPalette palette)
{
var card = new PanelRect(RowInset, top, Width - 2 * RowInset, height - ActionListGap);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(card, 8), palette.Gutter));
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(card.Inset(0.5, 0.5), 7.5), palette.Border, 1));
var y = card.Y + ActionListPadding;
foreach (var (kind, index, icon, label, name) in GetRowActions(host, section))
{
var item = new PanelRect(card.X + ActionListPadding, y, card.Width - 2 * ActionListPadding, ActionItemHeight);
var key = new PanelTargetKey(kind, section, host.Id, index);
AddInteractionFill(commands, key, item, palette, includeFocus: false);
var iconColor = _hover == key ? palette.TextPrimary : palette.TextSecondary;
if (kind == PanelTargetKind.OpenIn)
{
// Applications are nested under "Open in": their own icon where the labels above
// start, and the name after it.
var fallback = new List<PanelCommand>();
TrayPanelGlyphs.AddIcon(fallback, PanelIcon.AppWindow, new(TextLeft, item.Y + 7, 16, 16), iconColor);
commands.Add(new AppIconCommand(new(TextLeft, item.Y + 7, 16, 16), _openInApplications[index], fallback));
commands.Add(new TextCommand(new(TextLeft + 26, item.Y, item.Right - 8 - TextLeft - 26, item.Height), label,
PanelFontRole.Body, palette.TextPrimary));
}
else
{
// Line the icons up with the status glyphs and the labels with the AppHost names above.
TrayPanelGlyphs.AddIcon(commands, icon, new(22, item.Y + 7, 16, 16), iconColor);
var labelRight = item.Right - 8;
if (kind == PanelTargetKind.OpenInMenu)
{
// A chevron on the right tells that the item opens the applications under it.
labelRight -= 24;
TrayPanelGlyphs.AddIcon(commands, _openInExpanded ? PanelIcon.ChevronDown : PanelIcon.ChevronRight,
new(item.Right - 8 - 16, item.Y + 7, 16, 16), palette.TextTertiary);
}
commands.Add(new TextCommand(new(TextLeft, item.Y, labelRight - TextLeft, item.Height), label, PanelFontRole.Body, palette.TextPrimary));
}
AddTarget(targets, key, item, name, true, host);
y += ActionItemHeight;
}
}
/// <summary>
/// Drops a confirmation whose subject went away (the AppHost stopped, or the recent list was
/// cleared elsewhere) and picks up the subject's latest state for the strip's text.
/// </summary>
private void RevalidateConfirm()
{
if (_confirm is not { } confirm)
{
return;
}
var current = confirm.Kind switch
{
PanelConfirmKind.RemoveMissing => _state.RecentAppHosts.FirstOrDefault(host => host.Id == confirm.Host!.Id),
_ => null
};
var valid = confirm.Kind == PanelConfirmKind.ClearRecent ? _state.CanClearRecent : current is not null;
if (!valid)
{
CancelConfirm();
}
else if (current is not null)
{
_confirm = confirm with { Host = current };
}
}
/// <summary>
/// Draws an inline confirmation strip: a question, one line of detail, and a row of controls
/// (Cancel, then the destructive button).
/// </summary>
private void AddConfirm(List<PanelCommand> commands, List<PanelHitTarget> targets, PanelConfirm confirm, AppHostMenuItem? host,
double top, TrayPanelPalette palette)
{
var card = new PanelRect(RowInset, top + 4, Width - 2 * RowInset, ConfirmHeight - 8);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(card, 8), palette.Gutter));
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(card.Inset(0.5, 0.5), 7.5), palette.Border, 1));
var name = host?.DisplayName ?? "";
var (question, detail, accept, acceptName) = confirm.Kind switch
{
PanelConfirmKind.ClearRecent => ("Clear recent AppHosts?", "Pinned AppHosts are kept.", "Clear", "Clear recent AppHosts"),
_ => ($"{name} was not found", host!.Id.AppHostPath, "Remove", $"Remove {name} from recent AppHosts")
};
var textWidth = card.Width - 24;
var questionBounds = new PanelRect(card.X + 12, card.Y + 9, textWidth, 18);
var detailBounds = new PanelRect(card.X + 12, card.Y + 28, textWidth, 16);
commands.Add(new TextCommand(questionBounds, question, PanelFontRole.Name, palette.TextPrimary));
commands.Add(new TextCommand(detailBounds, detail, PanelFontRole.Body, palette.TextSecondary,
Truncation: confirm.Kind == PanelConfirmKind.RemoveMissing ? PanelTruncation.Middle : PanelTruncation.End));
AddLabel("confirm.question", question, questionBounds, chrome: false);
AddLabel("confirm.detail", detail, detailBounds, chrome: false);
var controlsY = card.Bottom - 10 - ButtonSize;
var section = confirm.Section;
var id = host?.Id;
const double ButtonWidth = 64;
var acceptBounds = new PanelRect(card.Right - 10 - ButtonWidth, controlsY, ButtonWidth, ButtonSize);
var cancelBounds = new PanelRect(acceptBounds.X - 8 - ButtonWidth, controlsY, ButtonWidth, ButtonSize);
var cancelKey = new PanelTargetKey(PanelTargetKind.ConfirmCancel, section, id);
var acceptKey = new PanelTargetKey(PanelTargetKind.ConfirmAccept, section, id);
// Cancel is a neutral outlined button; the destructive one is filled with the danger
// fill, which keeps its label readable where the danger text color would not.
commands.Add(new FillPathCommand(PanelPath.RoundedRect(cancelBounds, 6), palette.Background));
AddInteractionFill(commands, cancelKey, cancelBounds, palette, includeFocus: false);
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(cancelBounds.Inset(0.5, 0.5), 5.5), palette.Border, 1));
commands.Add(new TextCommand(cancelBounds, "Cancel", PanelFontRole.Button, palette.TextPrimary, PanelTextAlignment.Center));
AddTarget(targets, cancelKey, cancelBounds, "Cancel", true, host);
commands.Add(new FillPathCommand(PanelPath.RoundedRect(acceptBounds, 6), palette.DangerFill));
if (_pressed == acceptKey || _hover == acceptKey)
{
// Darken the filled button: the shared hover tints are tuned for the panel background.
commands.Add(new FillPathCommand(PanelPath.RoundedRect(acceptBounds, 6),
PanelColor.FromRgba(0, 0, 0, _pressed == acceptKey ? 0.22 : 0.12)));
}
if (palette.IsHighContrast)
{
commands.Add(new StrokePathCommand(PanelPath.RoundedRect(acceptBounds.Inset(0.5, 0.5), 5.5), palette.Border, 1));
}
commands.Add(new TextCommand(acceptBounds, accept, PanelFontRole.Button, palette.OnColor, PanelTextAlignment.Center));
AddTarget(targets, acceptKey, acceptBounds, acceptName, true, host);
}
private void AddSettingsHeader(List<PanelCommand> commands, List<PanelHitTarget> targets, TrayPanelPalette palette)
{
var back = new PanelRect(12, (HeaderHeight - ButtonSize) / 2, ButtonSize, ButtonSize);
var backKey = new PanelTargetKey(PanelTargetKind.Back, PanelSection.None, null);
AddInteractionFill(commands, backKey, back, palette, includeFocus: false);
TrayPanelGlyphs.AddIcon(commands, PanelIcon.ChevronLeft, back.Inset(6, 6), _hover == backKey ? palette.TextPrimary : palette.TextSecondary);
AddTarget(targets, backKey, back, "Back to AppHosts", true, null, tooltip: "Back");
var title = new PanelRect(back.Right + 8, (HeaderHeight - 20) / 2, Width - back.Right - 24, 20);
commands.Add(new TextCommand(title, TraySettingsText.PanelTitle, PanelFontRole.Title, palette.TextPrimary));
AddLabel("settings.title", TraySettingsText.PanelTitle, title, chrome: true);
}
private void AddSettingsToggle(List<PanelCommand> commands, List<PanelHitTarget> targets, PanelTargetKind kind, PanelSettings settings,
double top, TrayPanelPalette palette)
{
var row = new PanelRect(RowInset, top + 2, Width - 2 * RowInset, SettingsRowHeight - 4);
var key = new PanelTargetKey(kind, PanelSection.None, null);
var startup = settings.Startup;
var on = startup?.Enabled == true;
// Turning it off always works; turning it on needs a stable native installation.
var enabled = startup is not null && (on || startup.CanEnable);
var title = TraySettingsText.PanelStartupOption;
var status = startup is null ? settings.StartupError ?? "" : TraySettingsText.GetStartupStatus(startup, settings.StartupError);
var error = settings.StartupError is not null;
var description = string.IsNullOrEmpty(status) ? TraySettingsText.StartupDescription : status.Replace(Environment.NewLine, " ");
if (enabled)
{
AddInteractionFill(commands, key, row, palette, includeFocus: false);
}
var toggle = new PanelRect(RowContentRight - 36, top + 9, 32, 18);
var textWidth = toggle.X - 12 - 20;
commands.Add(new TextCommand(new(20, top + 8, textWidth, 20), title, PanelFontRole.Name,
enabled ? palette.TextPrimary : palette.TextTertiary));
commands.Add(new TextCommand(new(20, top + 28, textWidth, 30), description, PanelFontRole.Body,
error ? palette.Danger : palette.TextSecondary, MaxLines: 2));
TrayPanelGlyphs.AddToggle(commands, on, enabled, toggle, palette);
AddTarget(targets, key, row, title, enabled, null, PanelAccessibleRole.Switch, on, description);
}
private void AddAbout(List<PanelCommand> commands, PanelSettings settings, double top, TrayPanelPalette palette)
{
var name = new PanelRect(20, top + 4, Width - 40, 20);
commands.Add(new TextCommand(name, "Aspire Tray", PanelFontRole.Name, palette.TextPrimary));
AddLabel("about.name", "Aspire Tray", name, chrome: false);
var lines = TraySettingsText.GetVersionText().Split(Environment.NewLine)
.Append(TraySettingsText.GetAboutDescription(settings.Preview));
var y = top + 24;
var index = 0;
foreach (var line in lines)
{
// Informational versions carry a long commit hash at the end; keep both ends visible.
var bounds = new PanelRect(20, y, Width - 40, 16);
commands.Add(new TextCommand(bounds, line, PanelFontRole.Body, palette.TextSecondary, Truncation: PanelTruncation.Middle));
AddLabel($"about.line{index++}", line, bounds, chrome: false);
y += 18;
}
}
private void AddFooter(List<PanelCommand> commands, List<PanelHitTarget> targets, PanelRect footer, TrayPanelPalette palette)
{
// Three icon buttons on the right, each with a tooltip: Settings, Documentation, Quit.
// Quit closes only the tray, never the AppHosts, so it isn't called "Quit Aspire" and its
// tooltip and accessibility description say so.
var right = RowContentRight + 4;
var quitDescription = $"{_quitVerb} the tray. AppHosts keep running.";
foreach (var (kind, icon, name, tooltip, description, x) in new[]
{
(PanelTargetKind.Settings, PanelIcon.Gear, _settings is null ? "Settings" : "Close settings",
_settings is null ? "Settings" : "Close settings", (string?)null, right - 3 * ButtonSize - 2 * ButtonGap),
(PanelTargetKind.Documentation, PanelIcon.Globe, "Open Aspire documentation", "Documentation", null, right - 2 * ButtonSize - ButtonGap),
(PanelTargetKind.Quit, PanelIcon.Power, _quitVerb, quitDescription, quitDescription, right - ButtonSize)
})
{
var bounds = new PanelRect(x, footer.Y + 8, ButtonSize, ButtonSize);
var key = new PanelTargetKey(kind, PanelSection.None, null);
if (kind == PanelTargetKind.Settings && _settings is not null && _hover != key && _pressed != key)
{
// Mark the gear as selected while its view is open; clicking it again goes back.
commands.Add(new FillPathCommand(PanelPath.RoundedRect(bounds, 6), palette.Pressed));
}
AddInteractionFill(commands, key, bounds, palette, includeFocus: false);
TrayPanelGlyphs.AddIcon(commands, icon, bounds.Inset(6, 6), _hover == key ? palette.TextPrimary : palette.TextSecondary);
AddTarget(targets, key, bounds, name, true, null, description: description, tooltip: tooltip);
}
}
private void AddInteractionFill(List<PanelCommand> commands, PanelTargetKey key, PanelRect bounds, TrayPanelPalette palette, bool includeFocus)
{
var color = _pressed == key ? palette.Pressed
: _hover == key || (includeFocus && _focusVisible && _focus == key) ? palette.Hover
: (PanelColor?)null;
if (color is { } fill)
{
commands.Add(new FillPathCommand(PanelPath.RoundedRect(bounds, 6), fill));
}
}
private void AddTarget(List<PanelHitTarget> targets, PanelTargetKey key, PanelRect bounds, string name, bool enabled, AppHostMenuItem? host,
PanelAccessibleRole role = PanelAccessibleRole.Button, bool? isChecked = null, string? description = null, string? tooltip = null)
{
var hit = IsChrome(key.Kind) ? bounds : bounds.Intersect(_viewport);
var target = new PanelHitTarget(key, bounds, hit, name, enabled, host, role, isChecked, tooltip);
targets.Add(target);
_accessible.Add(new(PanelAccessibleKey.For(key), role, name, bounds, hit, target, description));
}
/// <summary>
/// Adds read-only text to the accessibility tree. Text in the scrolled list reports only the part
/// visible through the list clip, like hit targets; <paramref name="chrome"/> text never scrolls.
/// </summary>
private void AddLabel(string label, string text, PanelRect bounds, bool chrome)
{
if (string.IsNullOrEmpty(text))
{
return;
}
_accessible.Add(new(PanelAccessibleKey.For(label), PanelAccessibleRole.Text, text, bounds, chrome ? bounds : bounds.Intersect(_viewport)));
}
/// <summary>Targets in the fixed header or footer, outside the scrolled list.</summary>
private static bool IsChrome(PanelTargetKind kind)
=> kind is PanelTargetKind.Documentation or PanelTargetKind.Settings or PanelTargetKind.Quit or PanelTargetKind.Back;
private static bool IsWithinRow(PanelTargetKey? key, PanelTargetKey row)
=> key is { } value && value.Host == row.Host && value.Section == row.Section
&& value.Kind is PanelTargetKind.Row or PanelTargetKind.Dashboard or PanelTargetKind.Stop
or PanelTargetKind.Start or PanelTargetKind.More;
internal static PanelStatusGlyph GetStatusGlyph(AppHostMenuItem host, bool live) => host switch
{
{ IsStarting: true } or { IsStopping: true } => PanelStatusGlyph.Busy,
{ IsRunning: false } => host.Error is null ? PanelStatusGlyph.Stopped : PanelStatusGlyph.Warning,
_ when !live => PanelStatusGlyph.Unknown,
{ Error: not null } => PanelStatusGlyph.Warning,
{ Health: AppHostHealth.Healthy } => PanelStatusGlyph.Healthy,
{ Health: AppHostHealth.Warning } => PanelStatusGlyph.Warning,
{ Health: AppHostHealth.Unhealthy } => PanelStatusGlyph.Unhealthy,
_ => PanelStatusGlyph.Unknown
};
/// <summary>
/// Returns the second line of a row: <c>{short status} · {parent/dir}</c>, with the PID only when
/// two running rows share a display name. Errors replace the whole line.
/// </summary>
internal static (string Text, bool IsError) GetRowDetail(AppHostMenuItem host, bool live, bool showPid)
{
if (host.Error is { } error)
{
return (error, true);
}
var status = host switch
{
{ IsStarting: true } => "Starting…",
{ IsStopping: true } => "Stopping…",
// Offline rows carry the controller's source-file probe ("AppHost source file not found").
{ IsRunning: false } when host.Id.AppHostPid == 0 && host.Subtitle != "Stopped" => host.Subtitle,
{ IsRunning: false } => "Stopped",
_ when !live => "Status unavailable",
{ Health: AppHostHealth.Warning } => "Needs attention",
{ Health: AppHostHealth.Unhealthy } => "Unhealthy",
_ => "Running"
};
var detail = $"{status} · {AppHostPresentation.GetLocation(host.Id.AppHostPath)}";
return (showPid ? $"{detail} · PID {host.Id.AppHostPid}" : detail, false);
}
private static string GetRowAccessibleName(AppHostMenuItem host, string detail) => $"{host.DisplayName}, {detail}";
/// <summary>Returns the concise header status line and the color of its connection dot.</summary>
internal static (string Text, PanelColor Dot) GetHeaderStatus(TrayViewState state, TrayPanelPalette palette)
{
switch (state.Discovery)
{
case DiscoveryState.Connecting:
return ("Connecting…", palette.Neutral);
case DiscoveryState.Disconnected:
return ("Reconnecting…", palette.Warning);
case DiscoveryState.Incompatible:
return ("Incompatible CLI", palette.Danger);
case DiscoveryState.LimitExceeded:
return ("Discovery limit exceeded", palette.Danger);
}
var running = state.AppHosts.Count(host => host.IsRunning && host.Id.AppHostPid != 0);
var starting = state.AppHosts.Concat(state.RecentAppHosts).Count(host => host.IsStarting);
var attention = state.AppHosts.Concat(state.RecentAppHosts).Count(host => host.Error is not null
|| (host.IsRunning && host.Id.AppHostPid != 0 && host.Health is AppHostHealth.Warning or AppHostHealth.Unhealthy));
var parts = new List<string>();
if (running > 0)
{
parts.Add($"{running} running");
}
if (starting > 0)
{
parts.Add($"{starting} starting");
}
if (attention > 0)
{
parts.Add($"{attention} need{(attention == 1 ? "s" : "")} attention");
}
return (parts.Count == 0 ? "No AppHosts running" : string.Join(" · ", parts), palette.Success);
}
private enum ListItemKind
{
Empty,
SectionHeader,
Row,
Confirm,
SettingsToggle,
About,
Actions
}
/// <summary>One item of the scrolled list. <see cref="Target"/> tells Settings items apart.</summary>
private readonly record struct ListItem(ListItemKind Kind, double Y, double Height, PanelSection Section, AppHostMenuItem? Host,
PanelTargetKind Target);
/// <summary>A pending inline confirmation. <see cref="Host"/> is null for Clear recent.</summary>
private sealed record PanelConfirm(PanelConfirmKind Kind, PanelSection Section, AppHostMenuItem? Host);
}