Files
DodoSSH/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml
T
jaap-jan 367cebae9b
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00

326 lines
18 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource DeepChrome}">
<!--
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
one tap from the prompt.
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
either way — see TerminalScreen for which one is drawn when.
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
one somebody reaches for on this device, and the next launch should know it too. See
TerminalKeyboardViewModel.SelectedTab.
-->
<UserControl.Styles>
<!--
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
control, so a button added to it later cannot forget.
-->
<Style Selector="Button">
<Setter Property="Focusable" Value="False" />
</Style>
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
<Style Selector="Button.qtab">
<Setter Property="Width" Value="48" />
<Setter Property="Height" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.qtab.selected">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
</Style>
<!-- A group's caption over its keys. -->
<Style Selector="TextBlock.groupcaption">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="4,10,0,4" />
</Style>
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
<Style Selector="Button.hotkey.panelkey">
<Setter Property="Height" Value="38" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="Margin" Value="3" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<!-- ============ the tabs ============ -->
<!--
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
accessibility, which is where a word belongs when there is no room to print one.
-->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content="&#xE5C3;"
AutomationProperties.Name="Keys"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content="&#xE86F;"
AutomationProperties.Name="Snippets"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content="&#xE889;"
AutomationProperties.Name="History"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content="&#xE40A;"
AutomationProperties.Name="Appearance"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
</StackPanel>
<!--
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
hairline: it does not change what this panel shows, it puts the panel away.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
<Button Classes="qtab" Content="&#xE312;" AutomationProperties.Name="Show the keyboard"
Click="OnKeyboardRequested" />
</StackPanel>
</Grid>
<Panel Grid.Row="1">
<!-- ============ KEYS ============ -->
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12">
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
Command="{Binding Keyboard.OpenCustomizeCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE429;"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
open beside the terminal. A key somebody put on their bar is one they want within reach, and
hiding the bar must not hide it.
-->
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyRowViewModel">
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyGroupViewModel">
<StackPanel>
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
<!--
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
in the mono face without shrinking it, and still four keys a thumb can tell apart.
-->
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- ============ SNIPPETS ============ -->
<!--
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
a second place that story could go stale. The ▶ says which ones run.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="row" MinHeight="48" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Preview}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
IsVisible="{Binding RunsOnInsert}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ HISTORY ============ -->
<!--
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
TypedCommandHistory for the whole of what is and is not kept.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding Keyboard.History}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="kb:HistoryEntry">
<Button Classes="row" MinHeight="44" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Host}"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
IsVisible="{Binding !Keyboard.HasHistory}"
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
IsVisible="{Binding Keyboard.HasHistory}"
Command="{Binding Keyboard.ClearHistoryCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ APPEARANCE ============ -->
<!--
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
terminal having stopped responding.
And the cursor gesture's switch, for somebody whose long press means something else to them.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="12,6,12,12" Spacing="8">
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
Command="{Binding ResetTerminalFontCommand}">
<TextBlock Classes="label" FontSize="9" Text="RESET" />
</Button>
</Grid>
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Moves the cursor or walks through history. Drag further to go faster." />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
Foreground="{StaticResource Live}" VerticalAlignment="Center"
IsVisible="{Binding Keyboard.CursorGestures}" />
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
IsVisible="{Binding !Keyboard.CursorGestures}" />
</Grid>
</Button>
</StackPanel>
</ScrollViewer>
</Panel>
</Grid>
</UserControl>