Public Access
Tell the phone's keyboard these are secrets, and get it off the box
Five boxes on this head take a secret and every one of them was drawing dots and saying nothing. `PasswordChar` is a screen property: Windows has no opinion about what is being typed into a text box, so the desktop head needs nothing more. Android's software keyboard has an opinion, and left at its default it read a vault passphrase as prose — completions offered in the suggestion strip above the box, and the passphrase itself learned into the IME's dictionary. Dots on screen with a word bar over them is the worst of both: hidden from the person typing it and offered to the room. `TextInputOptions.ContentType` is the property the Android backend maps onto `InputType`, and it is what turns both off. Both attributes now live in one `TextBox.secret` class rather than being repeated per box, because they are two halves of one fact and the next box added would have got one of them. The keyboard also went on covering whichever box had raised it. That is in `PhoneShell` rather than on each screen: everything the phone draws is inside its one root panel, so a bottom margin shortens all eleven screens at once, and a screen added later cannot forget to handle something it never had to know about. Two mechanisms, and it matters that neither is a backstop for the other. Before Android 15 the activity now declares `AdjustResize` and the platform shortens the window itself; left unspecified Android chooses, and what it chooses for a window whose entire content is one native view — which is what an Avalonia surface is — is to pan, sliding the window by however much it thinks the focused native view needs and leaving the box exactly where it was. That was the bug. From Android 15 the attribute is ignored, edge-to-edge being enforced and the window no longer resized for the keyboard at all, and the reported inset is what there is. Each is dead where the other applies — where the window resizes, the inset arrives already consumed and measures zero — which is why the margin comes from the inset alone. Both added together would strand the interface an entire keyboard above the keyboard. Scrolling the box back into view keys off the size change rather than off either mechanism. `ScrollViewer` already brings a newly focused child into view; what it cannot know is that the visible region shrank after the focus, and both ways of losing that region end in the same resize. None of it is reachable by a test. The software keyboard is an inset the platform reports and a headless top level reports none, so phase 10 of `docs/manual-checks.md` is the whole of the verification — including the note to run it on one device each side of Android 15, since a build exercised on only one of the two will look correct and be half broken.
This commit is contained in:
@@ -562,3 +562,16 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
|
|||||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||||
confirmed to exist before anything is written to solve it.
|
confirmed to exist before anything is written to solve it.
|
||||||
|
- **The software keyboard is kept off the interface in one place, and by two mechanisms.** `PhoneShell`
|
||||||
|
owns it rather than each screen, because everything the phone draws is inside that one control and a
|
||||||
|
screen added later would otherwise have to remember. The two mechanisms are not a belt and braces: before
|
||||||
|
Android 15 the activity's `AdjustResize` has the platform shorten the window and the reported keyboard
|
||||||
|
inset arrives already consumed, and from Android 15 edge-to-edge is enforced, the window is no longer
|
||||||
|
resized for the keyboard, and the inset is what there is. Each is dead where the other applies, which is
|
||||||
|
why the margin is taken from the inset alone — the two added together would strand the interface an
|
||||||
|
entire keyboard too high. See `docs/manual-checks.md` phase 10, which is the only way either is verified.
|
||||||
|
- **A box that takes a secret says so twice.** `PasswordChar` is what the screen draws and
|
||||||
|
`TextInputOptions.ContentType` is what the software keyboard is told, and only the second one turns off
|
||||||
|
the suggestion strip and keeps the passphrase out of the IME's learning dictionary. The desktop head
|
||||||
|
needs only the first, which is why the phone's `TextBox.secret` class carries both rather than the two
|
||||||
|
being set per box.
|
||||||
|
|||||||
@@ -834,3 +834,58 @@ nothing else about them changes. Neither host is queued for push.
|
|||||||
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
|
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
|
||||||
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
||||||
being squeezed to nothing.
|
being squeezed to nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — The software keyboard and the boxes that take secrets
|
||||||
|
|
||||||
|
Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
|
||||||
|
them: the software keyboard is an inset the platform reports, and a headless top level reports none.
|
||||||
|
Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
|
||||||
|
later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
|
||||||
|
activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
|
||||||
|
`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
|
||||||
|
correct and be half broken.
|
||||||
|
|
||||||
|
### 10.1 The vault passphrase box is treated as a password by the keyboard
|
||||||
|
|
||||||
|
Launch to the lock screen, tap the passphrase box, type a few characters.
|
||||||
|
|
||||||
|
**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
|
||||||
|
typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
|
||||||
|
and confirm the suggestions come back there.
|
||||||
|
|
||||||
|
**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
|
||||||
|
directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
|
||||||
|
what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
|
||||||
|
dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
|
||||||
|
|
||||||
|
### 10.2 The keyboard does not cover the box being typed into
|
||||||
|
|
||||||
|
The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
|
||||||
|
Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
|
||||||
|
password on HOSTS, and the connect password on FILES.
|
||||||
|
|
||||||
|
**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
|
||||||
|
— the header stays where it is rather than scrolling off the top.
|
||||||
|
|
||||||
|
**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
|
||||||
|
`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
|
||||||
|
resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
|
||||||
|
|
||||||
|
### 10.3 Nothing is stranded when the keyboard closes
|
||||||
|
|
||||||
|
Dismiss the keyboard with back or the down-chevron from each of those screens.
|
||||||
|
|
||||||
|
**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
|
||||||
|
bottom and no scroll position left part way down.
|
||||||
|
|
||||||
|
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||||||
|
event, or the margin is only ever added to.
|
||||||
|
|
||||||
|
### 10.4 Rotating with the keyboard up
|
||||||
|
|
||||||
|
Focus a passphrase box, then turn the phone sideways.
|
||||||
|
|
||||||
|
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
|
||||||
|
rotation rather than being recreated, and live shells survive it.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using DodoSSH.Client.Android.Platform;
|
|||||||
using global::Android.App;
|
using global::Android.App;
|
||||||
using global::Android.Content;
|
using global::Android.Content;
|
||||||
using global::Android.Content.PM;
|
using global::Android.Content.PM;
|
||||||
|
using global::Android.Views;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android;
|
namespace DodoSSH.Client.Android;
|
||||||
|
|
||||||
@@ -34,12 +35,26 @@ namespace DodoSSH.Client.Android;
|
|||||||
/// other launch mode answers it with a second copy of this activity on top of the first — which on this
|
/// other launch mode answers it with a second copy of this activity on top of the first — which on this
|
||||||
/// head would mean a second Avalonia application over a live one.
|
/// head would mean a second Avalonia application over a live one.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><c>AdjustResize</c> is declared rather than left unspecified</b>, and it is half of how this head
|
||||||
|
/// keeps the software keyboard off the box being typed into; <c>PhoneShell</c> is the other half. Left
|
||||||
|
/// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view
|
||||||
|
/// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks
|
||||||
|
/// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on
|
||||||
|
/// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can
|
||||||
|
/// answer, and a screen built around a <c>ScrollViewer</c> then scrolls the focused box into view by
|
||||||
|
/// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the
|
||||||
|
/// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the
|
||||||
|
/// reported inset. The two are complementary and never both in effect: where the window resizes, the
|
||||||
|
/// keyboard inset arrives already consumed and measures zero.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Activity(
|
[Activity(
|
||||||
Label = "DodoSSH",
|
Label = "DodoSSH",
|
||||||
Theme = "@style/DodoTheme",
|
Theme = "@style/DodoTheme",
|
||||||
MainLauncher = true,
|
MainLauncher = true,
|
||||||
LaunchMode = LaunchMode.SingleTask,
|
LaunchMode = LaunchMode.SingleTask,
|
||||||
|
WindowSoftInputMode = SoftInput.AdjustResize,
|
||||||
ConfigurationChanges = ConfigChanges.Orientation
|
ConfigurationChanges = ConfigChanges.Orientation
|
||||||
| ConfigChanges.ScreenSize
|
| ConfigChanges.ScreenSize
|
||||||
| ConfigChanges.ScreenLayout
|
| ConfigChanges.ScreenLayout
|
||||||
|
|||||||
@@ -320,6 +320,27 @@
|
|||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
||||||
|
times because the two attributes are not interchangeable and both are needed.
|
||||||
|
|
||||||
|
<b>PasswordChar is what the screen shows; ContentType is what the keyboard is told.</b> The desktop head
|
||||||
|
needs only the first — a Windows text box has no opinion about what is being typed into it. Android's
|
||||||
|
software keyboard does: left at its default it treats a passphrase box as ordinary prose, which means
|
||||||
|
the suggestion strip offers completions from everything that phone has ever been typed into, and the
|
||||||
|
IME's own learning dictionary remembers what was typed. Dots on screen and a word-suggestion bar above
|
||||||
|
them is the worst of both — the secret is hidden from the person typing it and offered to the room.
|
||||||
|
TextInputOptions.ContentType is the property the Android backend maps onto InputType, and Password is
|
||||||
|
what turns the suggestions off and keeps the entry out of the dictionary.
|
||||||
|
|
||||||
|
A field class is not implied. Two of the five boxes carry their own metrics — see LockedScreen — so this
|
||||||
|
one sets nothing about size or colour, and the other three say Classes="field secret".
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBox.secret">
|
||||||
|
<Setter Property="PasswordChar" Value="•" />
|
||||||
|
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
Text="This passphrase encrypts your vault on this phone and on the server. Nothing can recover it — not the operator, not a reset link. A recovery code follows, and losing both makes the vault unopenable." />
|
Text="This passphrase encrypts your vault on this phone and on the server. Nothing can recover it — not the operator, not a reset link. A recovery code follows, and losing both makes the vault unopenable." />
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="PASSPHRASE" Margin="0,20,0,0" />
|
<TextBlock Classes="label" Text="PASSPHRASE" Margin="0,20,0,0" />
|
||||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding Passphrase}" PasswordChar="•"
|
<TextBox Classes="field secret" Margin="0,6,0,0" Text="{Binding Passphrase}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="CONFIRM" Margin="0,14,0,0" />
|
<TextBlock Classes="label" Text="CONFIRM" Margin="0,14,0,0" />
|
||||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding ConfirmPassphrase}" PasswordChar="•"
|
<TextBox Classes="field secret" Margin="0,6,0,0" Text="{Binding ConfirmPassphrase}"
|
||||||
IsEnabled="{Binding !IsBusy}">
|
IsEnabled="{Binding !IsBusy}">
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||||
|
|||||||
@@ -92,8 +92,8 @@
|
|||||||
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
|
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
|
||||||
password appear to work twice.
|
password appear to work twice.
|
||||||
-->
|
-->
|
||||||
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||||
Text="{Binding TypedPassword}" PasswordChar="•" PlaceholderText="password" />
|
Text="{Binding TypedPassword}" PlaceholderText="password" />
|
||||||
|
|
||||||
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
|
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
|||||||
@@ -419,8 +419,8 @@
|
|||||||
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
|
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
|
||||||
is an invitation to type a secret nothing will use.
|
is an invitation to type a secret nothing will use.
|
||||||
-->
|
-->
|
||||||
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||||
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
|
Text="{Binding ConnectPassword}" PlaceholderText="password">
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
|
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
|
||||||
phone client called slow.
|
phone client called slow.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding Passphrase}" PasswordChar="•" PlaceholderText="vault passphrase"
|
<TextBox Classes="secret" Text="{Binding Passphrase}" PlaceholderText="vault passphrase"
|
||||||
Height="48" Padding="14,0" VerticalContentAlignment="Center"
|
Height="48" Padding="14,0" VerticalContentAlignment="Center"
|
||||||
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
|
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
|
||||||
|
|||||||
@@ -23,7 +23,12 @@
|
|||||||
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Panel>
|
<!--
|
||||||
|
Named, and the name is load-bearing: everything the phone draws is inside this one element, so its
|
||||||
|
bottom margin is the single place the software keyboard can be kept off the box being typed into,
|
||||||
|
whichever of the eleven screens is showing. See PhoneShell.axaml.cs.
|
||||||
|
-->
|
||||||
|
<Panel x:Name="Body">
|
||||||
|
|
||||||
<!-- ============ getting in ============ -->
|
<!-- ============ getting in ============ -->
|
||||||
<views:PendingScreen IsVisible="{Binding IsStarting}"
|
<views:PendingScreen IsVisible="{Binding IsStarting}"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ using global::Android.Views;
|
|||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
@@ -15,6 +17,9 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
{
|
{
|
||||||
private MainWindowViewModel? shell;
|
private MainWindowViewModel? shell;
|
||||||
|
|
||||||
|
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||||
|
private IInputPane? keyboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the lock screen currently showing is the one the application launched into.
|
/// Whether the lock screen currently showing is the one the application launched into.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -32,6 +37,11 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// Subscribed once, for the life of the control, rather than in OnAttachedToVisualTree: Body is this
|
||||||
|
// control's own child and cannot outlive it, and re-subscribing on every attach is how a handler
|
||||||
|
// ends up registered twice.
|
||||||
|
Body.SizeChanged += OnBodyResized;
|
||||||
|
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
if (shell is not null)
|
if (shell is not null)
|
||||||
@@ -132,6 +142,14 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
if (TopLevel.GetTopLevel(this) is { } top)
|
if (TopLevel.GetTopLevel(this) is { } top)
|
||||||
{
|
{
|
||||||
top.BackRequested += OnBackRequested;
|
top.BackRequested += OnBackRequested;
|
||||||
|
|
||||||
|
keyboard = top.InputPane;
|
||||||
|
|
||||||
|
if (keyboard is not null)
|
||||||
|
{
|
||||||
|
keyboard.StateChanged += OnKeyboardChanged;
|
||||||
|
ApplyKeyboardInset(keyboard);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +161,99 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
top.BackRequested -= OnBackRequested;
|
top.BackRequested -= OnBackRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyboard is not null)
|
||||||
|
{
|
||||||
|
keyboard.StateChanged -= OnKeyboardChanged;
|
||||||
|
keyboard = null;
|
||||||
|
}
|
||||||
|
|
||||||
base.OnDetachedFromVisualTree(e);
|
base.OnDetachedFromVisualTree(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||||
|
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||||
|
|
||||||
|
private void ApplyKeyboardInset(IInputPane pane)
|
||||||
|
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the phone's whole interface clear of the software keyboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Here rather than on each screen, because the keyboard is not a screen's business.</b> Five of them
|
||||||
|
/// have a box that can be typed into and every one of them would need the same handler; a sixth added
|
||||||
|
/// later would silently not have it. Everything the phone draws is inside <c>Body</c>, so one bottom
|
||||||
|
/// margin shortens all of them at once — which is the same thing the window resizing would have done,
|
||||||
|
/// and is why the two paths below never both apply.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two paths, one of which is dead on any given device.</b> Before Android 15, the activity's
|
||||||
|
/// <c>AdjustResize</c> makes the platform shorten the window itself and the keyboard inset reaches
|
||||||
|
/// Avalonia already consumed — this measures zero and the margin stays where it is. From Android 15 the
|
||||||
|
/// window is no longer resized for the keyboard at all, edge-to-edge being enforced, and the inset is
|
||||||
|
/// reported instead: that is the number applied here. Adding a margin on top of a window that had
|
||||||
|
/// already shrunk would strand the interface an entire keyboard above the keyboard, which is why the
|
||||||
|
/// value is taken from the inset alone and never from both.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Scrolling the box back into view is deliberately not done here. <c>ScrollViewer</c> already brings a
|
||||||
|
/// newly focused child into view, and every screen with a box on it is inside one; what it cannot know
|
||||||
|
/// is that the visible region shrank *after* the focus. So the trigger is the resize this margin causes
|
||||||
|
/// — see <see cref="OnBodyResized"/> — and not this method, which would run a layout pass too early to
|
||||||
|
/// have anything to scroll to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void ApplyKeyboardInset(double occluded)
|
||||||
|
{
|
||||||
|
var inset = double.IsFinite(occluded) ? Math.Max(occluded, 0) : 0;
|
||||||
|
|
||||||
|
if (Math.Abs(Body.Margin.Bottom - inset) > 0.5)
|
||||||
|
{
|
||||||
|
Body.Margin = new Thickness(0, 0, 0, inset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrolls whatever has the keyboard back into view once the room left for it is known.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one moment this is needed is the one no other handler sees: the box was focused while the whole
|
||||||
|
/// screen was available, and the space it sits in shrank afterwards. Both ways of losing that space end
|
||||||
|
/// here — the margin applied above, and the platform shortening the window on Android 14 and earlier —
|
||||||
|
/// which is why the resize is the trigger rather than either of the two things that cause it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during
|
||||||
|
/// the layout pass that caused it: asking a <c>ScrollViewer</c> to scroll to a child whose new bounds
|
||||||
|
/// have not been written yet scrolls to where the child used to be.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only while the keyboard is up. Every rotation and every screen change resizes this control too, and
|
||||||
|
/// a shell that scrolled to the focused control on each of them would be a shell that moves under you.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnBodyResized(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (keyboard is not { State: InputPaneState.Open })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
// Whatever holds focus, not the passphrase box by name: this runs for eleven screens and
|
||||||
|
// the one the keyboard is up for is the only one that can answer which box that is.
|
||||||
|
if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is Control focused)
|
||||||
|
{
|
||||||
|
focused.BringIntoView();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes the system back gesture up the hierarchy rather than out of the application.
|
/// Takes the system back gesture up the hierarchy rather than out of the application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user