From ccaf7a8e72e64631c86ace5285922d0762f25389 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 10 Aug 2026 16:49:44 +0200 Subject: [PATCH] Give the terminal back the width and the keyboard the session shell took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven things reported from a day's use of v5b's session shell, and they are one commit because five of them are the same complaint from different angles: the window spends too much of itself on chrome describing the session, and the parts that are not chrome do not behave. ◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design. Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the address on the left, a cross-surface button on the right. Both facts are worth having and the strip they sat in was not — the tab already names the host, and a full-width bar repeating it was the cheapest 60 pixels in the layout to give back. The address is now the first line of the sidebar and the button is stretched across the column under it, so nothing is lost and the pane is taller. Which word that button carries and which command it runs used to be handed in from the two usage sites in MainWindow.axaml, because the control was drawn twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress already decides which surface's fact to read. The two directions underneath are untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync for a fresh terminal at whatever SFTP has open. SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops subtracting its 60 pixels from every session screen's budget — the same treatment the retired window-wide tab strip got, and for the same reason: a constant for chrome nobody draws is a suite quietly measuring the wrong rectangle. ◆ AND THE SIDEBAR CLOSES, which the design has no state for at all. 300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is often two rows long. The column now folds to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel that vanishes leaving no trace is one people report as lost rather than as closed. Both states live in the one control and swap on IsSessionSidebarOpen, so MainWindow's own "Auto" column takes whichever width is showing without knowing the state exists. Written through to ClientSettings.SessionSidebarOpen rather than held for the session. It is a decision about how much of the window a terminal gets, and one that had to be made again on every launch would not really be on offer. ---- THE FOUR SMALLER ONES ---- A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it got there. Two causes with nothing in common. The click moved Win32 focus onto the sidebar row, and term.focus() in the page cannot take it back — only the host can, so the shell raises TerminalFocusRequested and the window answers with the same posted focus every other path here uses. The highlight was bash: xterm wraps a paste in bracketed-paste markers, readline marks what arrives inside them as an active region, and it stays in reverse video until the next keystroke. Right for a clipboard paste, wrong for a snippet picked off a sidebar. Single-line snips are typed rather than pasted now, which needs no markers; multi-line still pastes, because "runs three commands unasked" is the worse of the two failures and the markers are the whole of what prevents it. A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling viewport #000 — its own comment explains why, and it is a macOS scrollbar concern. Everywhere else that black is covered by the rows, except along the bottom: the fit addon floors the row count, so the remainder below the last whole row is bare viewport, up to a line tall, against this page's #171a26. The light square at its right-hand end is where WebView2's classic scrollbar corner lands. The viewport is repainted in the page's own background, and the scrollbar with it — thin and in these colours rather than a grey Windows channel down the side of a near-black terminal, and kept rather than hidden, because a surface that scrolls with no sign that it does is worse than a quiet bar. THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face at all — that file is the 2019 build and its cmap skips E944 and E946 — so the rail's Pins row and the hosts screen's own pin badge have both been drawing a missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage is U+F10D, verified against the file rather than against a codepoints table for a later release of the font. Every other icon codepoint in the repository was audited the same way; this was the only miss. THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip reading ⌘K, and this build substitutes CTRL K — six characters and a space, wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's footprint survives for the day this face has a ⌘ to draw. ---- AND THE POPOVER UNDER THE USER CHIP ---- Reported as not matching the design, and it was not: Button.poprow set a corner radius and a padding and never touched the Background, so every row wore the Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the design draws as six lines of text — and the hover rule underneath was already correct and simply invisible against a fill that never went away. Set on the ContentPresenter as well as on the Button, the same as Button.flat, because the theme binds its brush there and a Background set only on the control loses to it. The panel itself gets this window's own radius-12 card treatment through a FlyoutPresenter class rather than by widening the shared context-menu rule, and Vaults and Preferences stop being drawn one step dimmer than Settings and Logout, which read as two disabled entries in a menu of five live ones. ---- WHAT PROVES IT ---- Three tests in the layout suite, two of them checked against the defect they describe: the popover row's resting fill (fails with #33ffffff without the style), and the kbd chip against the natural width of its own text, measured on a detached copy because a TextBlock's DesiredSize is already clipped to what it was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests is new — the sidebar has never been laid out by a test, and it now holds a string of unbounded length beside a button that has to stay clickable. In the shell suite: the cross-surface row in both directions, the closed state surviving to disk, and the focus request being made when a snip lands and not made when it does not. --- docs/design-import-gaps.md | 21 ++ src/DodoSSH.Client.App/App.axaml | 60 +++++- .../Views/HostsScreen.axaml | 2 +- src/DodoSSH.Client.App/Views/MainWindow.axaml | 36 ++-- .../Views/MainWindow.axaml.cs | 11 ++ src/DodoSSH.Client.App/Views/NavRail.axaml | 40 ++-- .../Views/SessionHeader.axaml | 49 ----- .../Views/SessionHeader.axaml.cs | 58 ------ .../Views/SessionSidebar.axaml | 183 +++++++++++------ .../Views/SettingsTitleBar.axaml | 4 +- src/DodoSSH.Client.App/Views/TitleBar.axaml | 12 +- .../Views/TransfersScreen.axaml | 5 +- src/DodoSSH.Client.Session/ClientSettings.cs | 24 ++- .../ViewModels/MainWindowViewModel.cs | 119 ++++++++++- .../WebAssets/terminal.css | 44 +++++ .../WebAssets/terminal.js | 26 ++- .../LayoutHarness.cs | 16 +- .../NavRailTests.cs | 45 +++++ .../ScreenLayoutTests.cs | 2 +- .../SessionSidebarTests.cs | 184 ++++++++++++++++++ .../TitleBarTests.cs | 55 ++++++ .../ShellFlowTests.cs | 85 ++++++++ 22 files changed, 865 insertions(+), 216 deletions(-) delete mode 100644 src/DodoSSH.Client.App/Views/SessionHeader.axaml delete mode 100644 src/DodoSSH.Client.App/Views/SessionHeader.axaml.cs create mode 100644 tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 6216db9..c497ccc 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -720,3 +720,24 @@ grid of cards with a drawer — see above. The split it describes did not change running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire, `IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected. + +--- + +## v5c-4 — two more, asked for after living with v5b + +**The session shell's host header is gone, and it is a deliberate departure from the design.** +`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the +left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two +facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the +button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the +one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a +full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give +back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped +subtracting the retired window-wide tab strip. + +**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to +spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column +to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel +that vanishes without trace is one people report as lost. The choice is written through to +`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the +window a terminal gets, and one that had to be made again on every launch would not really be on offer. diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml index 3cb95e3..68240a2 100644 --- a/src/DodoSSH.Client.App/App.axaml +++ b/src/DodoSSH.Client.App/App.axaml @@ -475,11 +475,34 @@ + + + + + + + + + + + + + - + @@ -191,17 +197,22 @@ - - + + + - @@ -277,11 +295,11 @@ - + - + - - - - - - - - - - + - - - - - - - - + + + + - + + + + + + - - + + @@ -90,19 +120,54 @@ - - + + - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml index b1e151a..8c8b33f 100644 --- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml +++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml @@ -9,8 +9,8 @@ the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and the same three window-control glyphs on the right TitleBar.axaml already draws. - A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and - SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window, + A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and + SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window, and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's own code-behind rather than shared through a base class — four short handlers, and the day one of the two diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml b/src/DodoSSH.Client.App/Views/TitleBar.axaml index ed5c3b5..6dbf537 100644 --- a/src/DodoSSH.Client.App/Views/TitleBar.axaml +++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml @@ -79,8 +79,16 @@ --> - - + /// Whether this machine looks for a newer build on its own. See the remarks on the property. /// +/// +/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property. +/// public sealed record ClientSettings( int TerminalFontSize = ClientSettings.DefaultTerminalFontSize, - bool AutomaticUpdateChecks = true) + bool AutomaticUpdateChecks = true, + bool SessionSidebarOpen = true) { /* A positional record, and the defaults live on the parameters rather than on property initializers. @@ -102,6 +106,24 @@ public sealed record ClientSettings( warns against. */ + /* + SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here. + + On by default, because the sidebar is where a session's pins, its snips and the way across to the + other surface live — a first launch that hid all three would be hiding the feature rather than + offering to. + + Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal + gets, and a choice that has to be made again on every launch is one the application is not really + offering. It belongs in this file rather than in the vault for the same reason the font size does: + it is a fact about this screen, not about this keychain, and following somebody from a 27-inch + monitor onto a laptop would be a preference nobody asked for. + + Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see + SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a + window that appears to lose it at random. + */ + /// Brings a value inside the range this type will store. public static int ClampTerminalFontSize(int pixels) => Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize); diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index 26d879c..39a297b 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -550,16 +550,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp updateScreen = CreateUpdateScreen(updates); - // Read straight away rather than at first use, so the value is right before anything can read it — - // a phone draws its terminal buttons from this, and a size that arrived a moment later would show - // as the interface correcting itself. - TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize); + ApplyStoredPreferences(); _ = TellRendererTheFontSizeAsync(); StartSessionShellTracking(); } + /// + /// Takes this machine's own preferences off disk, before anything can read them. + /// + /// + /// Read straight away rather than at first use, and both of them for the same reason: whatever is stored + /// is what the first window draws. A phone builds its terminal's font buttons from the size, and the + /// session shell decides whether to give a sidebar 300 pixels — either arriving a moment later shows as + /// the interface correcting itself in front of the user. + /// + private void ApplyStoredPreferences() + { + var stored = settings.Read(); + + TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize); + IsSessionSidebarOpen = stored.SessionSidebarOpen; + } + /// /// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline. /// @@ -1003,6 +1017,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// internal event EventHandler? TerminalSessionOpened; + /// + /// Raised when something this shell did belongs in the terminal the user is already looking at, so the + /// view can put the keyboard back there. + /// + /// + /// Separate from because no session opened: the sidebar's SNIPS row + /// typed into one that was already running, and the click that did it moved Win32 focus onto an Avalonia + /// button. The page cannot fix that from its side — see the term.focus() at the end of + /// terminal.js's paste handler, which only ever reaches document.activeElement — so the + /// half that can only be done by the host is asked for here. The view re-checks that a terminal is + /// actually showing before it acts; see MainWindow.FocusTerminalWhenLaidOut. + /// + internal event EventHandler? TerminalFocusRequested; + internal bool IsStarting => State == ShellState.Starting; internal bool IsNeedingServer => State == ShellState.NeedsServer; @@ -3824,6 +3852,72 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp (IsTerminalSurface && SelectedTab is not null) || (IsTransfersShowing && Transfers.IsConnected); + /// + /// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back. + /// + /// + /// A separate question from , and the two are not interchangeable: + /// that one is "is there a session for this to be about", which the shell answers, and this one is "does + /// the person want to see it", which only they can. Closed still draws something — a 34-pixel rail with + /// the way back on it; see SessionSidebar.axaml — because a panel that vanishes with no trace of + /// how to get it back is one people report as lost rather than as closed. Remembered between launches; + /// see and ClientSettings.SessionSidebarOpen. + /// + [ObservableProperty] + private bool isSessionSidebarOpen = true; + + /// Opens the session sidebar, or closes it to its rail. + /// + /// Written through on every toggle rather than on shutdown: this shell is disposed on paths that do not + /// all run to completion — a killed process, a phone's activity going away — and a preference that + /// survives only a clean exit is one that will sometimes be forgotten for no reason the user can see. + /// The store swallows its own failures and says whether it wrote; nothing here can do anything useful + /// with the answer, so the toggle stands whether or not the disk took it. + /// + [RelayCommand] + private void ToggleSessionSidebar() + { + IsSessionSidebarOpen = !IsSessionSidebarOpen; + + _ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen }); + } + + /// + /// The label on the sidebar's cross-surface row: where the other half of this host is. + /// + /// + /// v5c-4 moved this button off the session shell's own 60-pixel header row and into the sidebar, and the + /// header went with it — see SessionSidebar.axaml. What the two surfaces hand in separately used + /// to be a pair of properties on the header control; it is resolved here now, for the same reason + /// is: the sidebar is one control drawn on both surfaces, and a view that + /// branched on which one it was would be asking a question the shell has already answered. + /// + internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal"; + + /// Goes to the other half of the session the sidebar is about. + /// + /// The two directions were two commands bound from two usages of the header control, and they still are + /// two methods — takes a tab and opens an SFTP connection to its host; + /// dials a fresh terminal at whatever SFTP has open, because + /// there is no terminal session to reuse. What is new is only that one control now asks for both, so the + /// branch lives here beside , which has to agree with it. + /// + [RelayCommand] + private async Task OpenOtherSurfaceAsync() + { + if (IsTerminalSurface) + { + if (SelectedTab is { } tab) + { + await SelectFilesHostAsync(tab).ConfigureAwait(true); + } + + return; + } + + await OpenTerminalForFilesHostAsync().ConfigureAwait(true); + } + /// /// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins. /// @@ -3896,6 +3990,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp if (screen.CanInsert) { await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true); + + // The click that got here took the keyboard off the terminal and gave it to the sidebar row, so + // the command lands at a prompt that cannot be typed at until somebody clicks the pane. Asked + // for after the insert rather than before it, so the caret arrives to find the text already + // there. See TerminalFocusRequested. + TerminalFocusRequested?.Invoke(this, EventArgs.Empty); return; } @@ -3989,14 +4089,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp } /// - /// The account and endpoint the session shell's header and status bar are about right now, or null when + /// The account and endpoint the session shell's sidebar and status bar are about right now, or null when /// neither surface has one. /// /// /// One property reading whichever surface is showing, rather than one binding per surface reading its own - /// source directly — SessionHeader.axaml and SessionStatusBar.axaml are the same markup on + /// source directly — SessionSidebar.axaml and SessionStatusBar.axaml are the same markup on /// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view - /// to. The terminal's is 's own address; SFTP's is , + /// to. It was the retired header row that printed this first; v5c-4 moved the line into the sidebar's own + /// session block and left this property exactly as it was. The terminal's is 's own address; SFTP's is , /// which is already the account and endpoint actually dialled — nothing here re-derives it. /// internal string? SessionAddress => Surface switch @@ -4141,6 +4242,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp OnPropertyChanged(nameof(SessionIdentityLabel)); OnPropertyChanged(nameof(SessionIdentityText)); OnPropertyChanged(nameof(ShowsQuickAccessSidebar)); + + // v5c-4: the sidebar's cross-surface row says where the other half of this session is, so it turns + // over with the surface exactly as the facts above do. + OnPropertyChanged(nameof(SessionCrossSurfaceLabel)); } /// diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.css b/src/DodoSSH.Client.Shell/WebAssets/terminal.css index 8992ec5..6dc9380 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.css +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.css @@ -78,6 +78,50 @@ body { height: 100%; } +/* + ── THE BLACK STRIP UNDER THE TERMINAL ─────────────────────────────────────────────────────────────── + xterm.css paints its scrolling viewport #000 — literally black, and its own comment says why: on macOS + the overlay scrollbar is only fully opaque over an opaque backdrop. Everywhere else that black is a + surface nobody sees, because the rows cover it — except along the bottom, where they do not. The fit + addon floors the row count, so whatever is left of the pane below the last whole row is viewport with + nothing drawn on it: a full-width black bar under the terminal, up to one line tall, against this + page's own #171a26. On Windows it is also where the classic scrollbar's bottom corner lands, which is + the light square at its right-hand end. + + Repainting it in the page's own background is the whole fix. The remainder is still there — it is the + cost of a grid that has to divide evenly — but it now reads as the terminal's own margin rather than + as a strip of chrome that belongs to something else. +*/ +.xterm .xterm-viewport { + background-color: var(--dodo-background); + + /* + And the scrollbar itself, which WebView2 draws in the classic Windows style: a 15-pixel light-grey + channel with arrow buttons, down the right of a near-black terminal. Thin and in this page's own + colours instead — kept rather than hidden, because the scrollback is real and a surface that scrolls + with no sign that it does is worse than a quiet bar saying where you are. + + Both spellings. scrollbar-width/-color is the standard one and is what current WebView2 and WebKitGTK + honour; ::-webkit-scrollbar is what older Chromium builds and WKWebView answer to. Neither is + load-bearing on its own and the two do not conflict — whichever the host understands wins. + */ + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--dodo-muted) 45%, transparent) transparent; +} + +.xterm .xterm-viewport::-webkit-scrollbar { + width: 9px; +} + +.xterm .xterm-viewport::-webkit-scrollbar-track { + background: transparent; +} + +.xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--dodo-muted) 45%, transparent); + border-radius: 5px; +} + #status { position: absolute; left: 0; diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js index c2d4664..1d7f8b1 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js @@ -417,8 +417,23 @@ function handleFrame(buffer) { is something only this page sees — and a shell that receives a multi-line command inside those markers treats every newline as text. Without them it treats each one as "run this", so a three-line snippet runs three commands the moment it is inserted. + + ◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline + uses to decide it has been pasted into, and bash marks the result as an active region: the + inserted command sits at the prompt in reverse video, looking selected, until the next + keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a + snippet they picked off the sidebar, which should read as though they had typed it. + + The markers are only load-bearing for text carrying a newline — that is the whole of what the + paragraph above protects against — so a single-line snippet does not need them and is written + as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands + unasked" is the worse of the two. */ - session.term.paste(text); + if (text.includes('\n') || text.includes('\r')) { + session.term.paste(text); + } else { + session.term.input(text); + } /* And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the @@ -430,6 +445,15 @@ function handleFrame(buffer) { session.term.input('\r'); } + /* + The caret goes back where the text landed. Half of it, anyway: this reaches + document.activeElement and nothing further, so it is what makes the pane the page's own focused + element and what stops a hidden textarea from keeping the caret. The other half is Win32 + focus — the sidebar row that sent this frame took it — and only the host can give that back; + see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut. + */ + session.term.focus(); + break; } diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs index 2454569..6beee52 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs @@ -122,8 +122,14 @@ internal static class LayoutHarness /// The v5b session shell's own right-hand sidebar, from SessionSidebar.axaml. internal const double SessionSidebarWidth = 300; - /// The v5b session shell's own host header, from SessionHeader.axaml. - internal const double SessionHeaderHeight = 60; + /* + A third session-shell constant stood here through wave B and C: SessionHeaderHeight, 60 pixels, for + the host header that sat above the pane on both surfaces. v5c-4 retires that row — its address and + its cross-surface button both live in the sidebar now; see SessionSidebar.axaml — so the pane between + the tab row and the status bar is 60 pixels taller and this budget no longer subtracts anything for + it. The same treatment the retired window-wide tab strip got above, and for the same reason: a + constant for chrome that is not drawn is a budget that quietly under-measures every screen. + */ /// The v5b session shell's own status bar, from SessionStatusBar.axaml. internal const double SessionStatusBarHeight = 37; @@ -146,12 +152,12 @@ internal static class LayoutHarness /// The arithmetic, top to bottom: less on /// both the top and the bottom of the outer padded column, less for the /// tab row that sits above the bordered container, less on both - /// the top and the bottom of that border, less and - /// for the two fixed strips the pane sits between. + /// the top and the bottom of that border, less for the one fixed + /// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was. /// internal static double SessionScreenHeight => ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness) - - SessionHeaderHeight - SessionStatusBarHeight; + - SessionStatusBarHeight; /// /// The width a session-shell screen gets, with or without SessionSidebar's own QUICK ACCESS diff --git a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs index 8c50eea..3080823 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs @@ -1,8 +1,10 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Headless; using Avalonia.Input; +using Avalonia.Media; using Avalonia.VisualTree; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; @@ -272,6 +274,49 @@ public sealed class NavRailTests : IAsyncLifetime }); } + /// + /// Every row in the popover rests flat, and the pointer is what fills one. + /// + /// + /// Button.poprow set a radius and a padding and left the Background alone, so each row wore the + /// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six + /// lines of text. Read as a colour off the templated presenter rather than off the Button, because that + /// is where the theme puts its brush and therefore the only place the absence of one can be proven. + /// + /// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows + /// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse + /// answer than one that answers wrongly. + /// + [Fact] + public async Task PopoverRowsAreFlatUntilThePointerFindsThem() + { + await OnTheRailAsync((rail, window) => + { + Click(UserChip(rail), window); + + var row = PopoverRow(window, "Settings"); + var presenter = row.GetVisualDescendants() + .OfType() + .First(candidate => candidate.Name is "PART_ContentPresenter"); + + var resting = presenter.Background as ISolidColorBrush; + + (resting is null || resting.Color.A == 0).ShouldBeTrue( + $"a popover row rests flat, and this one is filled with {resting?.Color}"); + + var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window) + ?? throw new InvalidOperationException("the row is not in this window's tree"); + + window.MouseMove(centre); + LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight); + + row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it"); + (presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe( + (byte)0, + "a row that does not change under the pointer is one nobody can tell is clickable"); + }); + } + // ---- Helpers ---- private Task OnTheRailAsync(Action body) => diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs index 488cf1f..dd0d8b9 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs @@ -1274,7 +1274,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime /// this screen — see ShowsQuickAccessSidebar — so this is the test that actually reaches the /// 472-pixel budget computes, 204 pixels a side. DISCONNECT /// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to - /// share the row with it moved out, because SessionHeader already prints the same address above + /// share the row with it moved out, because the session shell already prints the same address beside /// this screen — see TransfersScreen.axaml's own remark on the strip for why keeping both was the /// thing squeezing DISCONNECT off the edge at this width. /// diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs new file mode 100644 index 0000000..03fe383 --- /dev/null +++ b/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs @@ -0,0 +1,184 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.VisualTree; +using DodoSSH.Client.App.Views; +using DodoSSH.Client.Session; +using DodoSSH.Client.Shell.ViewModels; +using DodoSSH.Client.Ssh; +using DodoSSH.Client.Storage; +using DodoSSH.Client.Terminal; +using NSubstitute; + +namespace DodoSSH.Client.App.Layout.Tests; + +/// +/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares. +/// +/// +/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block +/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that +/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width +/// column holding a string of unbounded length beside a button that must stay clickable; the second is a +/// width the rest of the window has to cope with, and MainWindow.axaml copes with it by asking this +/// control how wide it is rather than by knowing. +/// +public sealed class SessionSidebarTests : IAsyncLifetime +{ + /// The widths SessionSidebar.axaml declares for its two states. + private const double OpenWidth = 300; + + /// + private const double RailWidth = 34; + + /// + /// Long on purpose, and longer than the column is wide at this font: the address is the one string here + /// whose length nobody controls, and it shares its row with the button that closes the column. + /// + private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022"; + + private string directory = null!; + private ClientCacheFactory caches = null!; + private TerminalWorkspace workspace = null!; + private MainWindowViewModel shell = null!; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + /// + public ValueTask InitializeAsync() + { + // A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written + // through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths + // would be editing the preferences of whoever ran it. + directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}"); + caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}"); + + workspace = new TerminalWorkspace( + new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), + Substitute.For(), + TimeProvider.System); + + shell = new MainWindowViewModel( + new ClientPaths(directory), + caches, + workspace, + new VaultKnownHostStore(), + Substitute.For(), + (_, _) => throw new NotSupportedException("nothing here signs in"), + TimeProvider.System, + Substitute.For()) + { + State = ShellState.Unlocked, + }; + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask DisposeAsync() + { + await shell.DisposeAsync(); + await workspace.DisposeAsync(); + caches.Dispose(); + + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// The address trims and the close button stays where it is; that is the whole claim. Asserted through + /// rather than by reading the address's own width, because what + /// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing + /// beside it was pushed out of the column to make room. + /// + [Fact] + public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt() + { + await OnTheSidebarAsync((sidebar, window) => + { + // DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on + // the Border inside it, so what the surrounding "Auto" column is given — and what this asks for + // — is what the control asks for, not how wide a host window happened to stretch it. + sidebar.DesiredSize.Width.ShouldBe(OpenWidth); + + shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one"); + + LayoutHarness.Unreachable(window).ShouldBeEmpty(); + }); + } + + /// + /// The cross-surface button that used to live in the header row. Read off the control rather than off the + /// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still + /// draw as an empty button — fails this. + /// + [Fact] + public async Task TheCrossSurfaceButtonNamesTheOtherSurface() + { + await OnTheSidebarAsync((sidebar, _) => + { + var button = sidebar.GetVisualDescendants() + .OfType