Give the phone its screens back by not stealing a data context

Signing in on the phone reached an unlocked shell showing PREFERENCES over the middle of the
screen, with a bottom bar whose four buttons did nothing. The buttons were fine. Every one
of them changed the shell's screen exactly as asked, and nothing moved, because an opaque
panel was sitting on top of the whole page area and never came down.

PendingScreen set DataContext = this in its constructor, so that Heading and Detail could be
written as plain bindings inside its own XAML. That is not a private arrangement: a binding
the parent writes ON one of these — IsVisible="{Binding IsPreferencesShowing}" in
PhoneShell — resolves against this control's data context, which was no longer the shell.
The binding looked for a shell property on a PendingScreen, found nothing, and left
IsVisible at its default. Its default is true. So the panel that says "this screen is not
built yet" was permanently visible, last in the Panel and therefore on top of the host list
and the keychain both — and the terminal underneath them.

The two properties are read with $parent now and the control inherits its context like every
other screen. There is no x:DataType on it any more either, so a plain binding here is a
compile error rather than a silently missing screen.

The desktop head has the other half of this lesson written down already: MainWindow gives the
terminal's IsVisible a FallbackValue precisely because an unresolved visibility binding does
not hide anything, it shows everything. That note was about the previewer. This is what it
looks like at runtime.

Verified by reproducing the mechanism rather than by reasoning about it: a control that owns
its data context ignores a parent's IsVisible binding and stays visible; one that inherits
obeys it. The head builds in Debug and Release. Nothing has been run on a device, as ever —
see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:35:04 +02:00
co-authored by Claude Opus 5
parent ffab2be22a
commit a0d53b0c9d
2 changed files with 24 additions and 12 deletions
@@ -2,7 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PendingScreen"
x:DataType="views:PendingScreen"
Background="{StaticResource Canvas}">
<!--
@@ -12,6 +11,10 @@
Two properties rather than a shared string, because the copy is written per state. "No items" would be
the exact failure this control is here to avoid.
The two properties are read off this control by $parent rather than by binding to its DataContext, and
there is no x:DataType here for the same reason: this control must NOT own its data context. Setting one
is what broke the phone's navigation — see the code-behind, which is where that story is written down.
-->
<ScrollViewer>
@@ -24,11 +27,11 @@
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Text="{Binding Heading}" Foreground="{StaticResource Text}"
<TextBlock Text="{Binding $parent[views:PendingScreen].Heading}" Foreground="{StaticResource Text}"
FontFamily="{StaticResource MonoFont}" FontSize="14" FontWeight="SemiBold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Detail}" Foreground="{StaticResource TextDim}"
<TextBlock Text="{Binding $parent[views:PendingScreen].Detail}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="11" LineHeight="19"
TextWrapping="Wrap" />
@@ -8,8 +8,25 @@ namespace DodoSSH.Client.Android.Views;
/// A state this head has not built, saying so in its own words.
/// </summary>
/// <remarks>
/// <para>
/// Styled properties rather than a view model: there is no state behind this control, and giving it one
/// would make it look like a screen that might one day have data.
/// </para>
/// <para>
/// <b>It must not set its own DataContext, and the two properties are read with <c>$parent</c> for exactly
/// that reason.</b> This control once did <c>DataContext = this</c> so the properties could be bound as
/// <c>{Binding Heading}</c>, and it cost the phone its navigation. A binding written by the <em>parent</em>
/// on one of these — <c>IsVisible="{Binding IsPreferencesShowing}"</c> in PhoneShell — resolves against
/// this control's own data context, so with one set here the binding looked for a shell property on a
/// PendingScreen, found nothing, and left <c>IsVisible</c> at its default of true. The result was an opaque
/// PREFERENCES panel permanently on top of the host list, over a bottom bar whose buttons all worked
/// perfectly and changed nothing anybody could see.
/// </para>
/// <para>
/// The desktop head carries the other half of this lesson in <c>MainWindow.axaml</c>, where the terminal's
/// visibility is given a <c>FallbackValue</c> for the same reason: an unresolved <c>IsVisible</c> binding
/// does not hide anything, it shows everything.
/// </para>
/// </remarks>
internal sealed partial class PendingScreen : UserControl
{
@@ -19,15 +36,7 @@ internal sealed partial class PendingScreen : UserControl
public static readonly StyledProperty<string> DetailProperty =
AvaloniaProperty.Register<PendingScreen, string>(nameof(Detail), string.Empty);
public PendingScreen()
{
AvaloniaXamlLoader.Load(this);
// Its own data context, so the two properties can be bound in XAML like anything else. Safe here
// and only here: this control deliberately shows nothing from the shell, so there is no inherited
// context worth keeping.
DataContext = this;
}
public PendingScreen() => AvaloniaXamlLoader.Load(this);
public string Heading
{