Skip to content

Commit e33d770

Browse files
committed
Improve NanoKVM on-screen keyboard: portrait layout, tap feedback, Windows key
- Add separate portrait/landscape layouts that rebuild on orientation change so the keyboard never overflows the screen edge in portrait - Keep portrait keys comfortably sized (split the wide function/arrow row) - Add brief highlight feedback when a key is tapped - Add Windows key: first tap arms it as a one-shot modifier (e.g. Win+R), a second tap (no key in between) fires the Windows key alone (Start menu)
1 parent 1fe4608 commit e33d770

2 files changed

Lines changed: 176 additions & 18 deletions

File tree

src/ServerRemote.App/Components/OnScreenKeyboard.cs

Lines changed: 173 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,24 @@ public NanoKvmViewModel? ViewModel
3333
set => SetValue(ViewModelProperty, value);
3434
}
3535

36+
private const double RowSpacing = 4;
37+
3638
private bool _shift;
37-
private Border? _shiftBorder, _ctrlBorder, _altBorder;
39+
private Border? _shiftBorder, _ctrlBorder, _altBorder, _metaBorder;
3840
private readonly List<(Label label, string lower, string upper)> _dual = new();
3941
// All keys with their base width — allows orientation-dependent scaling without a rebuild.
4042
private readonly List<(Border border, Label label, double baseWidth)> _keys = new();
43+
// Per-row natural geometry, used to scale the keyboard down so it never overflows the screen width.
44+
private readonly List<(double scalableWidth, int keyCount)> _rows = new();
45+
46+
// Which orientation the currently built layout was created for (null = nothing built yet).
47+
private bool? _layoutIsLandscape;
4148

4249
public OnScreenKeyboard()
4350
{
4451
BackgroundColor = Colors.Transparent;
4552
Padding = new Thickness(6, 4);
46-
Build();
53+
Rebuild(IsLandscape(DeviceDisplay.Current.MainDisplayInfo));
4754

4855
Loaded += OnLoaded;
4956
Unloaded += OnUnloaded;
@@ -60,17 +67,44 @@ private void OnUnloaded(object? sender, EventArgs e)
6067

6168
private void OnDisplayInfoChanged(object? sender, DisplayInfoChangedEventArgs e) => UpdateMetrics();
6269

63-
// In landscape, height is scarce → flatter, slightly narrower keys and smaller font,
64-
// so that the upper part of the image stays visible.
70+
private static bool IsLandscape(DisplayInfo info)
71+
=> info.Orientation == DisplayOrientation.Landscape
72+
|| (info.Orientation == DisplayOrientation.Unknown && info.Width > info.Height);
73+
74+
// Landscape and portrait use genuinely different key arrangements (see BuildLandscape/BuildPortrait),
75+
// so when the orientation category flips we rebuild the layout rather than just rescaling it.
6576
private void UpdateMetrics()
6677
{
6778
var info = DeviceDisplay.Current.MainDisplayInfo;
68-
bool landscape = info.Orientation == DisplayOrientation.Landscape
69-
|| (info.Orientation == DisplayOrientation.Unknown && info.Width > info.Height);
79+
bool landscape = IsLandscape(info);
80+
81+
if (_layoutIsLandscape != landscape)
82+
Rebuild(landscape);
83+
84+
// Available width in device-independent units (MAUI layout space), minus the
85+
// ContentView padding (6 left + 6 right) and a small safety margin.
86+
double available = (info.Density > 0 ? info.Width / info.Density : info.Width) - 12 - 4;
87+
88+
// Largest scale at which the widest row still fits the available width.
89+
double fitScale = double.PositiveInfinity;
90+
foreach (var (scalableWidth, keyCount) in _rows)
91+
{
92+
if (scalableWidth <= 0) continue;
93+
double spacing = (keyCount - 1) * RowSpacing;
94+
fitScale = Math.Min(fitScale, (available - spacing) / scalableWidth);
95+
}
96+
97+
// Landscape keeps the compact look (slightly narrower keys, flatter, smaller font);
98+
// portrait keeps full-size, comfortable keys and only narrows the width if it must fit.
99+
double desiredScale = landscape ? 0.86 : 1.0;
100+
double scale = Math.Min(desiredScale, fitScale);
101+
if (scale <= 0 || double.IsInfinity(scale)) scale = desiredScale;
70102

71-
double scale = landscape ? 0.86 : 1.0;
72-
double height = landscape ? 30 : KeyH;
73-
double font = landscape ? 13 : 16;
103+
// Height and font keep the key aspect ratio sensible. In landscape we deliberately
104+
// flatten; in portrait we keep a comfortable, fixed key height regardless of the
105+
// width scale so the keys never become tiny.
106+
double height = landscape ? 30 : 40;
107+
double font = landscape ? 13 : 14;
74108

75109
foreach (var (border, label, baseWidth) in _keys)
76110
{
@@ -80,7 +114,29 @@ private void UpdateMetrics()
80114
}
81115
}
82116

83-
private void Build()
117+
private void Rebuild(bool landscape)
118+
{
119+
_keys.Clear();
120+
_rows.Clear();
121+
_dual.Clear();
122+
_shiftBorder = _ctrlBorder = _altBorder = _metaBorder = null;
123+
124+
Content = landscape ? BuildLandscape() : BuildPortrait();
125+
_layoutIsLandscape = landscape;
126+
127+
// Restore the persisted modifier state onto the freshly built keys.
128+
RefreshShiftLabels();
129+
SetActive(_shiftBorder, _shift);
130+
if (ViewModel is { } vm)
131+
{
132+
SetActive(_ctrlBorder, vm.CtrlActive);
133+
SetActive(_altBorder, vm.AltActive);
134+
SetActive(_metaBorder, vm.MetaActive);
135+
}
136+
}
137+
138+
// Wide landscape layout: function and arrow keys share the bottom row, which fits the broad screen.
139+
private View BuildLandscape()
84140
{
85141
var rows = new VerticalStackLayout { Spacing = 4, HorizontalOptions = LayoutOptions.Center };
86142

@@ -104,23 +160,72 @@ private void Build()
104160
Special("↵", WideW, Enter)));
105161

106162
rows.Add(Row(
107-
CtrlKey(), AltKey(),
163+
WinKey(), CtrlKey(), AltKey(),
108164
Special("Tab", WideW, () => SendChar('\t')),
109-
Special("Space", 240, () => SendChar(' ')),
165+
Special("Space", 220, () => SendChar(' ')),
110166
Special("Esc", 56, () => RunCommand(ViewModel?.KeyEscapeCommand)),
111167
Special("←", 48, () => RunCommand(ViewModel?.KeyLeftCommand)),
112168
Special("↑", 48, () => RunCommand(ViewModel?.KeyUpCommand)),
113169
Special("↓", 48, () => RunCommand(ViewModel?.KeyDownCommand)),
114170
Special("→", 48, () => RunCommand(ViewModel?.KeyRightCommand))));
115171

116-
Content = rows;
172+
return rows;
117173
}
118174

119-
private static HorizontalStackLayout Row(params View[] keys)
175+
// Portrait layout: the wide function/arrow row is split across two extra rows so the widest
176+
// row is just the 11-key letter rows. That keeps the fit-scale high (~0.7) and the keys large.
177+
private View BuildPortrait()
120178
{
121-
var row = new HorizontalStackLayout { Spacing = 4, HorizontalOptions = LayoutOptions.Center };
179+
var rows = new VerticalStackLayout { Spacing = 4, HorizontalOptions = LayoutOptions.Center };
180+
181+
rows.Add(Row(
182+
Dual("1", "!"), Dual("2", "\""), Dual("3", "§"), Dual("4", "$"), Dual("5", "%"),
183+
Dual("6", "&"), Dual("7", "/"), Dual("8", "("), Dual("9", ")"), Dual("0", "="),
184+
Special("⌫", WideW, Backspace)));
185+
186+
rows.Add(Row(
187+
Dual("q", "Q"), Dual("w", "W"), Dual("e", "E"), Dual("r", "R"), Dual("t", "T"), Dual("z", "Z"),
188+
Dual("u", "U"), Dual("i", "I"), Dual("o", "O"), Dual("p", "P"), Dual("ü", "Ü")));
189+
190+
rows.Add(Row(
191+
Dual("a", "A"), Dual("s", "S"), Dual("d", "D"), Dual("f", "F"), Dual("g", "G"), Dual("h", "H"),
192+
Dual("j", "J"), Dual("k", "K"), Dual("l", "L"), Dual("ö", "Ö"), Dual("ä", "Ä")));
193+
194+
rows.Add(Row(
195+
ShiftKey(),
196+
Dual("y", "Y"), Dual("x", "X"), Dual("c", "C"), Dual("v", "V"), Dual("b", "B"), Dual("n", "N"), Dual("m", "M"),
197+
Dual(",", ";"), Dual(".", ":"), Dual("-", "_")));
198+
199+
// Modifier / whitespace row.
200+
rows.Add(Row(
201+
WinKey(), CtrlKey(), AltKey(),
202+
Special("Tab", KeyW, () => SendChar('\t')),
203+
Special("Space", 120, () => SendChar(' ')),
204+
Special("↵", WideW, Enter)));
205+
206+
// Navigation row.
207+
rows.Add(Row(
208+
Special("Esc", WideW, () => RunCommand(ViewModel?.KeyEscapeCommand)),
209+
Special("←", KeyW, () => RunCommand(ViewModel?.KeyLeftCommand)),
210+
Special("↑", KeyW, () => RunCommand(ViewModel?.KeyUpCommand)),
211+
Special("↓", KeyW, () => RunCommand(ViewModel?.KeyDownCommand)),
212+
Special("→", KeyW, () => RunCommand(ViewModel?.KeyRightCommand))));
213+
214+
return rows;
215+
}
216+
217+
private HorizontalStackLayout Row(params View[] keys)
218+
{
219+
var row = new HorizontalStackLayout { Spacing = RowSpacing, HorizontalOptions = LayoutOptions.Center };
220+
double scalableWidth = 0;
122221
foreach (var k in keys)
222+
{
123223
row.Add(k);
224+
// At build time each Border's WidthRequest equals its base width.
225+
if (k is Border b)
226+
scalableWidth += b.WidthRequest;
227+
}
228+
_rows.Add((scalableWidth, keys.Length));
124229
return row;
125230
}
126231

@@ -157,12 +262,33 @@ private static void OnTap(Border b, Func<Task> action)
157262
b.GestureRecognizers.Add(tap);
158263
}
159264

265+
// Like OnTap, but briefly highlights the key so a press is visibly acknowledged.
266+
// Used for character/special keys; modifier keys already signal via their persistent active state.
267+
private static void OnTapFlash(Border b, Func<Task> action)
268+
{
269+
var tap = new TapGestureRecognizer();
270+
tap.Tapped += async (_, _) =>
271+
{
272+
b.BackgroundColor = KeyBgActive;
273+
try
274+
{
275+
await action();
276+
}
277+
finally
278+
{
279+
await Task.Delay(90);
280+
b.BackgroundColor = KeyBg;
281+
}
282+
};
283+
b.GestureRecognizers.Add(tap);
284+
}
285+
160286
// Character key with lowercase/uppercase variant (depending on Shift).
161287
private View Dual(string lower, string upper)
162288
{
163289
var (border, label) = MakeKey(_shift ? upper : lower, KeyW);
164290
_dual.Add((label, lower, upper));
165-
OnTap(border, async () =>
291+
OnTapFlash(border, async () =>
166292
{
167293
var s = _shift ? upper : lower;
168294
if (s.Length > 0)
@@ -176,7 +302,7 @@ private View Dual(string lower, string upper)
176302
private View Special(string label, double width, Func<Task> action)
177303
{
178304
var (border, _) = MakeKey(label, width);
179-
OnTap(border, async () =>
305+
OnTapFlash(border, async () =>
180306
{
181307
await action();
182308
ResetOneShot();
@@ -230,6 +356,33 @@ private View AltKey()
230356
return border;
231357
}
232358

359+
// Windows key: first tap arms it as a one-shot modifier (for combos like Win+R);
360+
// a second tap (while armed) fires the Windows key on its own (e.g. open the Start menu).
361+
private View WinKey()
362+
{
363+
var (border, _) = MakeKey("⊞", WideW);
364+
_metaBorder = border;
365+
OnTap(border, async () =>
366+
{
367+
if (ViewModel is not { } vm)
368+
return;
369+
370+
if (vm.MetaActive)
371+
{
372+
// Second tap: send Win alone and disarm.
373+
vm.MetaActive = false;
374+
SetActive(_metaBorder, false);
375+
await vm.SendWindowsKeyAsync();
376+
}
377+
else
378+
{
379+
vm.MetaActive = true;
380+
SetActive(_metaBorder, true);
381+
}
382+
});
383+
return border;
384+
}
385+
233386
// ----- Sending / state -----
234387

235388
private Task SendChar(char c) => ViewModel?.SendCharAsync(c) ?? Task.CompletedTask;
@@ -255,7 +408,7 @@ private static void SetActive(Border? border, bool active)
255408
border.BackgroundColor = active ? KeyBgActive : KeyBg;
256409
}
257410

258-
// Shift/Ctrl/Alt each apply only to the next key — reset after sending.
411+
// Shift/Ctrl/Alt/Win each apply only to the next key — reset after sending.
259412
private void ResetOneShot()
260413
{
261414
if (_shift)
@@ -269,8 +422,10 @@ private void ResetOneShot()
269422
{
270423
if (vm.CtrlActive) vm.CtrlActive = false;
271424
if (vm.AltActive) vm.AltActive = false;
425+
if (vm.MetaActive) vm.MetaActive = false;
272426
}
273427
SetActive(_ctrlBorder, false);
274428
SetActive(_altBorder, false);
429+
SetActive(_metaBorder, false);
275430
}
276431
}

src/ServerRemote.App/ViewModels/NanoKvmViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ private async Task DisconnectInputAsync()
303303
private Task ScrollDown() => Hid.ScrollAsync(-1);
304304

305305
// Special keys of the on-screen keyboard.
306+
// Windows/Meta key pressed on its own (e.g. open the Start menu).
307+
public Task SendWindowsKeyAsync() => Hid.SendKeyAsync(0, false, false, false, meta: true);
308+
306309
[RelayCommand] private Task KeyEnter() => SendRawKeyAsync(HidKeys.Enter);
307310
[RelayCommand] private Task KeyBackspace() => SendRawKeyAsync(HidKeys.Backspace);
308311
[RelayCommand] private Task KeyTab() => SendRawKeyAsync(HidKeys.Tab);

0 commit comments

Comments
 (0)