@@ -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}
0 commit comments