Android: the redesign — a Vaults tab with hosts, keychain and snippets, card lists, a keychain that can add, and files that act on what is chosen
ci / build and test (push) Successful in 3m49s
ci / android head (push) Successful in 3m55s
ci / api image (push) Successful in 6s
ci / desktop nightly (push) Successful in 1m22s

The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded
Material font and a count of open sessions on Connections, which replaces the strip of shells above the
bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync
light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain
and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists
them), and back from either goes to the hosts. Settings becomes an account card and grouped sections:
security, vaults and history, files, this app.

Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the
host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts
CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a
hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar
stands down while the software keyboard is up.

The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a
password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with
labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get
a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar
(save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload
button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet
opens SFTP on that terminal's own host.

The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the
glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept
for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown,
checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills
behind, and lists, nav entries and icon buttons carry names for TalkBack.

Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a
password, connecting through the new sheet, files on the terminal's host with the selection bar and
its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_
FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass.
This commit is contained in:
jaap-jan committed 2026-10-04 15:50:09 +02:00
1 parent 6806e542f9
commit 5b32c76c95
24 files changed
+2022 -654

No files matched your search

@@ -43,6 +43,13 @@ internal sealed partial class PhoneShell : UserControl
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
private IInputPane? keyboard;
/// <summary>
/// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
/// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
/// <see cref="ApplyKeyboardInset(double)"/>.
/// </summary>
private bool keyboardOpen;
/// <summary>
/// Whether the lock screen currently showing is the one the application launched into.
/// </summary>
@@ -89,6 +96,7 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null)
{
shell.PropertyChanged -= OnShellChanged;
shell.Transfers.PropertyChanged -= OnVaultChanged;
}
shell = DataContext as MainWindowViewModel;
@@ -96,6 +104,11 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null)
{
shell.PropertyChanged += OnShellChanged;
// The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
// scrim covers for, so its openness is heard here too. Transfers is built once with the shell
// and never replaced, unlike the vault.
shell.Transfers.PropertyChanged += OnVaultChanged;
ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true;
@@ -170,9 +183,13 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
public static readonly StyledProperty<bool> ShowsShellStripProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
/// <summary>Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.</summary>
/// <remarks>
/// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
/// three of the rail's entries an inch to the right of them would be the same choice offered twice.
/// </remarks>
public static readonly StyledProperty<bool> ShowsVaultTabsProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultTabs));
/// <inheritdoc cref="IsWideProperty" />
public bool IsWide
@@ -209,11 +226,22 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsHostSelectionBarProperty, value);
}
/// <inheritdoc cref="ShowsShellStripProperty" />
public bool ShowsShellStrip
/// <summary>Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.</summary>
public static readonly StyledProperty<bool> ShowsSheetScrimProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsSheetScrim));
/// <inheritdoc cref="ShowsSheetScrimProperty" />
public bool ShowsSheetScrim
{
get => GetValue(ShowsShellStripProperty);
private set => SetValue(ShowsShellStripProperty, value);
get => GetValue(ShowsSheetScrimProperty);
private set => SetValue(ShowsSheetScrimProperty, value);
}
/// <inheritdoc cref="ShowsVaultTabsProperty" />
public bool ShowsVaultTabs
{
get => GetValue(ShowsVaultTabsProperty);
private set => SetValue(ShowsVaultTabsProperty, value);
}
/// <summary>
@@ -243,6 +271,13 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface.
/// </para>
/// <para>
/// ◆ <b>Narrow, it is the VAULTS tab's header now, on all three of its screens.</b> The keychain and
/// snippets moved from behind SETTINGS to the vault's own switch (see
/// <see cref="MainWindowViewModel.IsVaultSurface"/>), so the header and the switch under it are drawn
/// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
/// surface to make room.
/// </para>
/// <para>
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// <see cref="vault"/>. While hosts are ticked the header stands down and
@@ -272,9 +307,11 @@ internal sealed partial class PhoneShell : UserControl
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
// not the vault's switch, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to.
var editing = vault?.IsEditing == true;
// The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
var editing = vault?.IsEditing == true
|| (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == true);
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
@@ -293,10 +330,17 @@ internal sealed partial class PhoneShell : UserControl
IsWide = wide;
ShowsRail = wide && (pages || connectPage) && !editing;
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
ShowsShellStrip = pages && !editing;
// ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
// bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
// most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
ShowsVaultTabs = ShowsVaultHeader && !wide;
ShowsSheetScrim = pages
&& ((vault is not null && HasASheetUp(vault))
|| (shell is { Transfers.IsEntryMenuOpen: true }
&& (shell.IsTransfersShowing || shell.IsBucketsShowing)));
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -452,10 +496,18 @@ internal sealed partial class PhoneShell : UserControl
DispatcherPriority.Background);
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
{
keyboardOpen = e.NewState is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
RefreshChrome();
}
private void ApplyKeyboardInset(IInputPane pane)
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
{
keyboardOpen = pane.State is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
RefreshChrome();
}
/// <summary>
/// Holds the phone's whole interface clear of the software keyboard.
@@ -568,7 +620,7 @@ internal sealed partial class PhoneShell : UserControl
/// application opens on, which is what every other Android app does.
/// </para>
/// <para>
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's second case
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
@@ -608,6 +660,14 @@ internal sealed partial class PhoneShell : UserControl
return;
}
// The files screen's actions sheet, raised from its selection bar.
if (current.Transfers.IsEntryMenuOpen)
{
current.Transfers.IsEntryMenuOpen = false;
e.Handled = true;
return;
}
if (TryCloseAnOpenEditor(current))
{
e.Handled = true;
@@ -621,28 +681,43 @@ internal sealed partial class PhoneShell : UserControl
return;
}
if (TryGoUp(current))
{
e.Handled = true;
}
}
/// <summary>Moves one level up the screens, for the back gesture.</summary>
/// <returns>Whether it moved, and so whether back has been spent.</returns>
/// <remarks>See <see cref="OnBackRequested"/>, whose last step this is.</remarks>
private bool TryGoUp(MainWindowViewModel current)
{
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
or ShellScreen.Keychain:
// ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
// moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
// HOSTS is where that switch starts.
case ShellScreen.Keychain or ShellScreen.Snippets:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
return true;
case ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
// a second back — and the arrows in these screens' own headers are hidden there for the
// same reason. Home is where back goes when the thing you came from is not on the surface.
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
e.Handled = true;
break;
return true;
case ShellScreen.More:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
e.Handled = true;
break;
return true;
default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
break;
return false;
}
}
@@ -690,6 +765,25 @@ internal sealed partial class PhoneShell : UserControl
return false;
}
/// <summary>Whether one of the hosts screen's four sheets is up.</summary>
/// <remarks>The same four <see cref="TryLowerASheet"/> lowers, in the same terms.</remarks>
private static bool HasASheetUp(VaultViewModel vault) =>
vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
|| vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
/// <summary>A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.</summary>
private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
{
if (shell is { Transfers.IsEntryMenuOpen: true })
{
shell.Transfers.IsEntryMenuOpen = false;
}
else if (shell?.Vault is { } open)
{
TryLowerASheet(open);
}
}
/// <summary>
/// Lowers whatever the hosts screen has raised over its list, topmost first.
/// </summary>
@@ -755,6 +849,12 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
if (vault.IsKeychainAddSheetOpen)
{
vault.CloseKeychainAddSheetCommand.Execute(null);
return true;
}
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
@@ -792,6 +892,11 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
if (KeychainScreen.TryCancelAnEditor(vault))
{
return true;
}
if (vault.IsEditingGroup)
{
vault.CancelGroupEditCommand.Execute(null);