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
18 changes: 18 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,24 @@ Three rules about it:
vocabulary is app-wide across the secondary windows and dialogs; the main
window's command bar deliberately keeps its flat minimalist `toolbar`
buttons (rule 1) and is the one surface exempt.
**A status line is `TextBlock.statusMessage`** (`Styles/Theme.axaml`,
2026-10, 1.0.1 release pass): one line (`MaxLines=1`: Npgsql puts a server
error's `DETAIL` after a newline, which grew the bar a line), cut with an
ellipsis, and its whole text in a tooltip only while it is cut
(`Converters/CutTextTip`, which reads the block's `TextLayout` for a
collapsed line or lines left out). The main window's message and
cap warning, and the Activity, Database Overview and Security windows' status
lines use it; Slow Queries keeps its wrapping line, a paragraph of caveats
meant to be read. Found because a failed safe-mode commit read "Commit
failed…" in a narrow window with no way to see why: the main message had
carried a `ToolTip.Tip` since the bar was built, and it never once opened.
**Landmine: a `TextBlock` or panel with no `Background` is not hit-testable**
(not even over its glyphs: 0 of 4,536 points over the status text reached
it), so a tooltip on it is dead and the pointer lands on whatever has a
background behind it. The style sets `Background="Transparent"`, and so does
`Border.statusBar StackPanel`, whose segment tooltips (read-only reason, In
transaction, staged changes) had opened only over an icon's ink.
`StatusLineTests` hovers each with real pointer input.
**Surfaces and dialogs have a vocabulary too** (2026-09, DESIGN.md rules 15
and 16). Every secondary window sits on the shell tone — a `Window` style in
`Styles/Theme.axaml` sets it, and the Inter font, so no window sets either —
Expand Down
31 changes: 31 additions & 0 deletions src/PgNimbus.App/Converters/CutTextTip.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Data.Converters;

namespace PgNimbus.App.Converters;

/// <summary>
/// The tooltip of a <c>TextBlock.statusMessage</c>: its whole text while the
/// line is cut with an ellipsis, and nothing while it fits (a tooltip repeating
/// what is already on screen is noise). Bound as a multi-binding over the text
/// block itself, its text and its width, so it is asked again whenever either
/// changes; by then the block's layout reflects the new width.
/// </summary>
public sealed class CutTextTip : IMultiValueConverter
{
public static readonly CutTextTip Instance = new();

public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture) =>
values is [TextBlock { Text: { Length: > 0 } text } block, ..] && IsCut(block) ? text : null;

/// <summary>
/// Whether <paramref name="block"/> is showing less than its text: a line
/// ended in an ellipsis, or lines left out by <c>MaxLines</c>.
/// </summary>
public static bool IsCut(TextBlock block)
{
var lines = block.TextLayout.TextLines;
return lines.Any(line => line.HasCollapsed)
|| lines.Sum(line => line.Length) < (block.Text?.Length ?? 0);
}
}
35 changes: 34 additions & 1 deletion src/PgNimbus.App/Styles/Theme.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
xmlns:cc="clr-namespace:AvaloniaEdit.CodeCompletion;assembly=AvaloniaEdit"
xmlns:search="clr-namespace:AvaloniaEdit.Search;assembly=AvaloniaEdit"
xmlns:completion="using:PgNimbus.App.Completion"
xmlns:cmd="using:PgNimbus.App">
xmlns:cmd="using:PgNimbus.App"
xmlns:conv="using:PgNimbus.App.Converters">

<!--
PowerToys-inspired design tokens: soft rounded cards, pill-shaped nav
Expand Down Expand Up @@ -614,4 +615,36 @@
</Setter>
</Style>

<!--
A status line: one line, cut with an ellipsis when the window is too
narrow, and the whole text in a tooltip while it is cut. Every window's
status message uses it, so a long error ("Commit failed — no staged
changes were applied: …") can always be read without a control of its
own. The transparent background is load-bearing: a TextBlock without
one is not hit-testable, so the pointer reached the status bar behind
it and the main window's tooltip, bound since the bar was built, never
once opened (2026-10, 1.0.1 release pass).
-->
<Style Selector="TextBlock.statusMessage">
<!-- One line even for a message that has two: Npgsql puts a server
error's DETAIL after a newline, which grew the status bar. -->
<Setter Property="MaxLines" Value="1" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="ToolTip.Tip">
<MultiBinding Converter="{x:Static conv:CutTextTip.Instance}">
<Binding Path="$self" />
<Binding Path="$self.Text" />
<Binding Path="$self.Bounds.Width" />
</MultiBinding>
</Setter>
</Style>

<!-- The status bar's segments carry tooltips of their own (why results are
read-only, what "In transaction" means, what is staged), which opened
only over an icon's ink for the same reason. -->
<Style Selector="Border.statusBar StackPanel">
<Setter Property="Background" Value="Transparent" />
</Style>

</Styles>
2 changes: 1 addition & 1 deletion src/PgNimbus.App/Views/ActivityWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@
<!-- One status strip flush along the window's bottom edge, showing the
visible tab's line — not a floating caption per tab. -->
<Border Grid.Row="1" Classes="statusBar" Margin="0,12,0,0">
<TextBlock Classes="statusText dim" Text="{Binding ActiveStatus}" />
<TextBlock Classes="statusText dim statusMessage" Text="{Binding ActiveStatus}" />
</Border>

</Grid>
Expand Down
2 changes: 1 addition & 1 deletion src/PgNimbus.App/Views/DatabaseOverviewWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
</TabItem>
</TabControl>

<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.7" Margin="2,8,2,0" />
<TextBlock Grid.Row="3" Classes="statusMessage" Text="{Binding Status}" FontSize="11" Opacity="0.7" Margin="2,8,2,0" />

</Grid>

Expand Down
16 changes: 7 additions & 9 deletions src/PgNimbus.App/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -522,15 +522,14 @@
<!-- Files-style segmented status bar: message · rows · timing · cap warning -->
<!-- The bar is a Grid, not a StackPanel, so it can never overflow the
window: the message sits in the lone star column and truncates
(full text on hover), while every other segment is compact and
keeps its width. Long cap text truncates the same way. -->
(statusMessage: full text on hover while it is cut), while every
other segment is compact and keeps its width. Long cap text
truncates the same way. -->
<Border Grid.Row="1" Classes="statusBar">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding ActiveTab.Status}"
Classes="statusText" Classes.error="{Binding ActiveTab.HasError}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
HorizontalAlignment="Left"
ToolTip.Tip="{Binding ActiveTab.Status}" />
Classes="statusText statusMessage" Classes.error="{Binding ActiveTab.HasError}"
VerticalAlignment="Center" HorizontalAlignment="Left" />
<!-- Running indicator: indeterminate bar + live elapsed tick while a query is in flight -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
Margin="10,0,0,0"
Expand Down Expand Up @@ -603,9 +602,8 @@
<StackPanel Grid.Column="8" Orientation="Horizontal" Spacing="10" Margin="10,0,0,0"
IsVisible="{Binding ActiveTab.CapStatusText, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<Rectangle Classes="statusSeparator" />
<TextBlock Classes="statusText warn" Text="{Binding ActiveTab.CapStatusText}" VerticalAlignment="Center"
MaxWidth="320" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding ActiveTab.CapStatusText}" />
<TextBlock Classes="statusText warn statusMessage" Text="{Binding ActiveTab.CapStatusText}"
VerticalAlignment="Center" MaxWidth="320" />
</StackPanel>
<!-- Browse-mode paging: the one piece of the old browse bar that
couldn't move into the SQL editor. Disabled while a query is
Expand Down
2 changes: 1 addition & 1 deletion src/PgNimbus.App/Views/Security/SecurityWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
</TabItem>
</TabControl>

<TextBlock Grid.Row="2" Text="{Binding Status}" FontSize="11" Opacity="0.7" Margin="2,8,2,0" />
<TextBlock Grid.Row="2" Classes="statusMessage" Text="{Binding Status}" FontSize="11" Opacity="0.7" Margin="2,8,2,0" />

</Grid>

Expand Down
179 changes: 179 additions & 0 deletions tests/PgNimbus.App.Tests/StatusLineTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using PgNimbus.App.Converters;
using PgNimbus.App.ViewModels;
using PgNimbus.App.ViewModels.Security;
using PgNimbus.Screenshot;

namespace PgNimbus.App.Tests;

/// <summary>
/// A status line too long for its window is cut with an ellipsis, and its whole
/// text has to be readable somewhere. The 1.0.1 release pass found a failed
/// safe-mode commit shown as "Commit failed…" with the reason out of reach: the
/// main window's status text had carried a tooltip since the bar was built, but
/// a TextBlock with no background is not hit-testable, so the pointer reached
/// the bar behind it and the tooltip never opened. The secondary windows' status
/// lines had no tooltip and no ellipsis at all.
/// </summary>
public class StatusLineTests
{
private const string Failure =
"Commit failed — no staged changes were applied: 23505: duplicate key value violates unique constraint "
+ "\"order_lines_pkey\". DETAIL: Key (order_id, line)=(1042, 3) already exists. The staged edits are kept, "
+ "so the offending change can be fixed, restaged or discarded before committing again.";

[Test]
public async Task A_cut_status_message_shows_its_whole_text_on_hover()
{
await Ui.Run(async () =>
{
var (window, vm) = Scenarios.Shell();
Ui.Show(window);
vm.ActiveTab.Status = Failure;
vm.ActiveTab.HasError = true;
Ui.Settle();

var status = TextBlockShowing(window, Failure);
await Assert.That(CutTextTip.IsCut(status)).IsTrue();
await Assert.That(await HoverAsync(window, status)).IsEqualTo(Failure);

window.Close();
});
}

/// <summary>
/// The live failure: Npgsql puts a server error's DETAIL on a second line,
/// and the status bar grew a line to show it, each line cut on its own.
/// </summary>
[Test]
public async Task A_message_of_two_lines_shows_one_and_its_whole_text_on_hover()
{
await Ui.Run(async () =>
{
const string twoLines = "Commit failed — no staged changes were applied: 23505: duplicate key value violates unique constraint \"orders_code_key\"\n"
+ "DETAIL: Detail redacted as it may contain sensitive data.";
var (window, vm) = Scenarios.Shell();
Ui.Show(window);
vm.ActiveTab.Status = "Ready";
Ui.Settle();
var oneLineHeight = TextBlockShowing(window, "Ready").Bounds.Height;

vm.ActiveTab.Status = twoLines;
Ui.Settle();

var status = TextBlockShowing(window, twoLines);
await Assert.That(status.Bounds.Height).IsEqualTo(oneLineHeight);
await Assert.That(CutTextTip.IsCut(status)).IsTrue();
await Assert.That(await HoverAsync(window, status)).IsEqualTo(twoLines);

window.Close();
});
}

[Test]
public async Task A_status_message_that_fits_has_no_tooltip()
{
await Ui.Run(async () =>
{
var (window, vm) = Scenarios.Shell();
Ui.Show(window);
vm.ActiveTab.Status = "Committed 3 staged changes";
Ui.Settle();

var status = TextBlockShowing(window, "Committed 3 staged changes");
await Assert.That(CutTextTip.IsCut(status)).IsFalse();
await Assert.That(ToolTip.GetTip(status)).IsNull();

window.Close();
});
}

[Test]
public async Task Every_status_bar_segment_with_a_tooltip_answers_the_pointer_over_its_text()
{
await Ui.Run(async () =>
{
var (window, vm) = Scenarios.Shell();
Ui.Show(window);
vm.IsInTransaction = true;
vm.ActiveTab.ReadOnlyHint = "the result has no primary key";
Ui.Settle();

var bar = window.GetVisualDescendants().OfType<Border>().First(b => b.Classes.Contains("statusBar"));
var segments = bar.GetVisualDescendants().OfType<StackPanel>()
.Where(p => p.IsEffectivelyVisible && ToolTip.GetTip(p) is not null)
.ToList();
await Assert.That(segments.Count).IsGreaterThanOrEqualTo(2);

foreach (var segment in segments)
{
var label = segment.GetVisualDescendants().OfType<TextBlock>().First(t => t.IsEffectivelyVisible);
var point = label.TranslatePoint(new Point(label.Bounds.Width / 2, label.Bounds.Height / 2), window)!.Value;
var hit = window.InputHitTest(point) as Visual;
var reaches = hit is not null && (ReferenceEquals(hit, segment) || hit.GetVisualAncestors().Contains(segment));
await Assert.That(reaches).IsTrue().Because($"the pointer over \"{label.Text}\" must reach its segment");
}

window.Close();
});
}

[Test]
public async Task Each_window_status_line_is_cut_with_its_whole_text_on_hover()
{
await Ui.Run(async () =>
{
foreach (var (window, setStatus) in new (Window, Action<string>)[]
{
Open(Scenarios.DatabaseOverview(), w => ((DatabaseOverviewViewModel)w.DataContext!).Status = Failure),
Open(Scenarios.Security(), w => ((SecurityViewModel)w.DataContext!).Status = Failure),
Open(Scenarios.Activity(), w => ((ActivityViewModel)w.DataContext!).Status = Failure),
})
{
setStatus(Failure);
window.Width = 700;
Ui.Settle();

var status = TextBlockShowing(window, Failure);
await Assert.That(CutTextTip.IsCut(status)).IsTrue().Because(window.GetType().Name);
await Assert.That(await HoverAsync(window, status)).IsEqualTo(Failure).Because(window.GetType().Name);
window.Close();
}
});

static (Window, Action<string>) Open(Window window, Action<Window> set)
{
Ui.Show(window);
return (window, _ => set(window));
}
}

private static TextBlock TextBlockShowing(Window window, string text) =>
window.GetVisualDescendants().OfType<TextBlock>().First(t => t.Text == text && t.IsEffectivelyVisible);

// Moves the pointer onto the start of the text, where the glyphs are, and
// returns what the tooltip that opens shows. The show delay is a dispatcher
// timer the headless clock never advances, so it is zero here; everything
// else is the real path (hit test, pointer-over, the tooltip service).
private static async Task<object?> HoverAsync(Window window, TextBlock block)
{
ToolTip.SetShowDelay(block, 0);
var point = block.TranslatePoint(new Point(8, block.Bounds.Height / 2), window)!.Value;
window.MouseMove(point);
Ui.SettleUntil(() =>
{
Thread.Sleep(10);
return ToolTip.GetIsOpen(block);
}, maxPasses: 100);

var tip = ToolTip.GetIsOpen(block) ? ToolTip.GetTip(block) : null;
window.MouseMove(new Point(1, 1));
Ui.Settle();
await Task.CompletedTask;
return tip;
}
}
Loading