diff --git a/docs/architecture.md b/docs/architecture.md index f6bf475..e4a596c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,7 +170,7 @@ Text entry on a phone comes from `zwp_text_input_v3`: a focused `TextEdit` enabl A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert` and `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. Those are what `keyboards/bar-terminal.json` is made of: the row a terminal needs, independent of the language, which the panel lays above the language's layout when its `Purpose` is `Terminal`. A `bar-*.json` file is a row, not a layout, and `available()` leaves it out of the layouts the layout-switch key cycles through. -**`Panel` is a state machine a host drives.** It holds the loaded layouts, the current layer, one-shot or locked shift, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with `set_rect( rect, scale )`, and the press in flight. The host feeds it `press_down`, `press_move`, `press_up` and, from its own timer, `tick`, each returning an `Event` the host acts on: `Text`, `Backspace`, `Enter`, `Hide`, `Key( keysym, modifiers )` for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or `Changed` when only the panel's own look changed. `set_purpose` switches the extra row; the runtime derives the purpose from the focused field, and a compositor takes it from the `content_type` a client sets on `zwp_text_input_v3`, which has a `terminal` purpose that terminal emulators built on VTE declare; for one that does not, the compositor can decide by `app_id`. `next_deadline()` says when `tick` next has work: the variants popup opens 450 ms into a press, backspace repeats after the same delay. `height( scale )` is what the panel needs for its current layer; `draw( canvas )` paints it with the active palette. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it. +**`Panel` is a state machine a host drives.** It holds the loaded layouts, the current layer, one-shot or locked shift, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with `set_rect( rect, scale )`, and the press in flight. The host feeds it `press_down`, `press_move`, `press_up` and, from its own timer, `tick`, each returning an `Event` the host acts on: `Text`, `Backspace`, `Enter`, `Hide`, `Key( keysym, modifiers )` for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or `Changed` when only the panel's own look changed. `set_purpose` switches the extra row; the runtime derives the purpose from the focused field, and a compositor takes it from the `content_type` a client sets on `zwp_text_input_v3`, which has a `terminal` purpose that terminal emulators built on VTE declare; for one that does not, the compositor can decide by `app_id`. `next_deadline()` says when `tick` next has work: the variants popup opens 450 ms into a press, backspace repeats after the same delay. `height( width, scale )` is what the panel needs at that width for its current layer, since keys are as tall as one key unit is wide; `draw( canvas )` paints it with the active palette. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it. **The runtime shows it under a focused text field** when the compositor advertises no `zwp_text_input_manager_v3`, when `LTK_BUILTIN_OSK=1` is set (useful to try it on a desktop that has an input method), or when the app returns `true` from `builtin_keyboard()`. The panel sits at the bottom of the surface; the widget tree is laid out in the space above it, so the focused field is never covered, and presses inside it never reach the gesture machine. `Text` goes through the same insertion path as a physical key, `Key` through the same dispatch as one, with Ctrl applied for its duration, `Enter` submits the field or inserts a newline in a multiline one, `Hide` keeps the focus and takes the panel away until the field is tapped again; losing the focus removes it. @@ -209,7 +209,7 @@ For dev iteration set `LTK_THEMES_DIR=/path/to/ltk/themes` so the lookup picks f 2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/ltk/themes//`) 3. `/usr/share/ltk/themes//` -Wallpapers ship as a single landscape PNG per variant. `ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback )` handles the disk-or-builtin fallback, and `bundle.for_size( sw, sh )` returns the right crop for landscape *or* portrait surfaces — no need to ship two PNGs. +Wallpapers ship as a single landscape PNG per variant. `ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback )` handles the disk-or-builtin fallback, and `bundle.for_size( sw, sh )` returns the right crop for landscape *or* portrait surfaces — no need to ship two PNGs. A panel that wants a frosted version of the same wallpaper takes `bundle.blurred( divisor, sigma )`, a bundle `divisor` times smaller on each side and blurred, which the renderer scales back up: a fraction of the memory of a second image, and no second decode. For many third-party apps, theming is optional at first. It is reasonable to start with the default theme and come back to the runtime theme APIs later as diff --git a/keyboards/bar-terminal.json b/keyboards/bar-terminal.json index 10009b5..3f55dcd 100644 --- a/keyboards/bar-terminal.json +++ b/keyboards/bar-terminal.json @@ -4,27 +4,23 @@ "layers": { "base": [ [ - { "key": "Escape", "label": "Esc" }, - { "key": "Tab" }, - { "modifier": "ctrl" }, - { "modifier": "alt" }, + { "key": "Escape", "label": "Esc", "width": 1.5 }, + { "key": "Tab", "width": 1.5 }, + { "modifier": "ctrl", "width": 1.5 }, + { "modifier": "alt", "width": 1.5 }, { "key": "Up" }, { "key": "Down" }, { "key": "Left" }, { "key": "Right" }, "-", "/", - "|", - "\\", - "~" + "|" ] ] }, "variants": { - "-": [ "_", "–" ], - "/": [ "?" ], - "|": [ "&", "!" ], - "\\": [ "`", "^" ], - "~": [ "$", "#", "*" ] + "-": [ "~", "_", "–" ], + "/": [ "\\", "?" ], + "|": [ "&", "!", "`", "^", "$", "#", "*" ] } } diff --git a/keyboards/es.json b/keyboards/es.json index 90db166..ada1aff 100644 --- a/keyboards/es.json +++ b/keyboards/es.json @@ -11,7 +11,7 @@ "symbols": [ [ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ], [ "@", "#", "€", "%", "&", "-", "+", "(", ")", "/" ], - [ { "action": "layer", "layer": "more", "label": "=\\<", "width": 1.5 }, "*", "\"", "'", ":", ";", "¡", "!", "¿", "?", { "action": "backspace", "width": 1.5 } ], + [ { "action": "layer", "layer": "more", "label": "=\\<", "width": 1.5 }, "*", "\"", "'", ":", ";", "!", "?", { "action": "backspace", "width": 1.5 } ], [ { "action": "layer", "layer": "base", "label": "ABC", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ] ], "more": [ @@ -34,6 +34,8 @@ "-": [ "–", "—", "_" ], "\"": [ "«", "»", "“", "”" ], "'": [ "‘", "’" ], - "€": [ "$", "£", "¥" ] + "€": [ "$", "£", "¥" ], + "!": [ "¡" ], + "?": [ "¿" ] } } diff --git a/src/event_loop/osk.rs b/src/event_loop/osk.rs index 5867825..47ab184 100644 --- a/src/event_loop/osk.rs +++ b/src/event_loop/osk.rs @@ -19,7 +19,7 @@ use crate::types::{ Point, Rect }; pub( crate ) fn place_osk( osk: &mut Option, pw: u32, ph: u32, sf: f32 ) -> f32 { let Some( panel ) = osk.as_mut() else { return 0.0 }; - let h = panel.height( sf ).min( ph as f32 ); + let h = panel.height( pw as f32, sf ).min( ph as f32 ); panel.set_rect( Rect { x: 0.0, y: ph as f32 - h, width: pw as f32, height: h }, sf ); h } diff --git a/src/osk/draw.rs b/src/osk/draw.rs index 82c9bdf..2ac0040 100644 --- a/src/osk/draw.rs +++ b/src/osk/draw.rs @@ -11,6 +11,7 @@ use super::{ Action, Modifier, Panel }; const TEXT_SIZE: f32 = 20.0; const ACTION_SIZE: f32 = 14.0; const ICON_SIZE: f32 = 22.0; +const ARROW_SIZE: f32 = 14.0; const RADIUS: f32 = 6.0; /// The theme icon a key shows; its label is the fallback when the theme lacks it. @@ -54,7 +55,8 @@ impl Panel let bg = if lit { palette.accent } else if action { palette.divider } else { palette.surface }; let ink = if lit { palette.bg } else { palette.text_primary }; canvas.fill_rect( rect, bg, RADIUS * scale ); - let icon = icon_for( &key ).and_then( |name| self.icon( name, ( ICON_SIZE * scale ) as u32, ink ) ); + let size = if key.keysym.is_some() { ARROW_SIZE } else { ICON_SIZE }; + let icon = icon_for( &key ).and_then( |name| self.icon( name, ( size * scale ) as u32, ink ) ); match icon { Some( ( rgba, w, h ) ) => @@ -64,7 +66,7 @@ impl Panel } None => { - let size = if action { ACTION_SIZE } else { TEXT_SIZE } * scale; + let size = if action { ACTION_SIZE } else { TEXT_SIZE }; draw_centred( canvas, &key.label, rect, size, ink ); } } @@ -79,7 +81,7 @@ impl Panel let bg = if lit { palette.accent } else { palette.surface }; let ink = if lit { palette.bg } else { palette.text_primary }; canvas.fill_rect( *rect, bg, RADIUS * scale ); - draw_centred( canvas, text, *rect, TEXT_SIZE * scale, ink ); + draw_centred( canvas, text, *rect, TEXT_SIZE, ink ); } } } @@ -99,9 +101,11 @@ fn channel( v: f32 ) -> u8 ( v.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8 } +/// `size` is in font space: the canvas multiplies it by its `dpi_scale`, as it does for every widget's text. fn draw_centred( canvas: &mut Canvas, text: &str, rect: Rect, size: f32, color: Color ) { if text.is_empty() { return; } let w = canvas.measure_text( text, size ); - canvas.draw_text( text, rect.x + ( rect.width - w ) / 2.0, rect.y + rect.height / 2.0 + size * 0.35, size, color ); + let glyph_h = size * canvas.dpi_scale(); + canvas.draw_text( text, rect.x + ( rect.width - w ) / 2.0, rect.y + rect.height / 2.0 + glyph_h * 0.35, size, color ); } diff --git a/src/osk/layout.rs b/src/osk/layout.rs index e5c5dcf..8fd1343 100644 --- a/src/osk/layout.rs +++ b/src/osk/layout.rs @@ -352,6 +352,28 @@ mod tests assert!( row.iter().any( |k| k.keysym == Some( Keysym::Escape ) ) ); assert!( row.iter().any( |k| k.modifier == Some( Modifier::Ctrl ) ) ); assert!( row.iter().any( |k| k.text.as_deref() == Some( "|" ) ) ); + assert!( bar.variants( "/" ).iter().any( |v| v == "\\" ) ); + assert!( bar.variants( "-" ).iter().any( |v| v == "~" ) ); + } + + /// Rows share the key unit of the widest one, so a layer wider than the + /// base narrows every key in it and leaves gaps at the sides. + #[ test ] + fn shipped_layers_are_no_wider_than_their_base() + { + let units = |row: &Vec| row.iter().map( |k| k.width ).sum::(); + for json in [ include_str!( "../../keyboards/en.json" ), include_str!( "../../keyboards/es.json" ) ] + { + let layout = Layout::from_json( json ).unwrap(); + let base = layout.rows( BASE_LAYER ).iter().map( units ).fold( 0.0_f32, f32::max ); + for ( name, rows ) in &layout.layers + { + for row in rows + { + assert!( units( row ) <= base, "{}: layer {name} has a row of {} units, the base {base}", layout.name, units( row ) ); + } + } + } } #[ test ] diff --git a/src/osk/mod.rs b/src/osk/mod.rs index 944d747..02ebac0 100644 --- a/src/osk/mod.rs +++ b/src/osk/mod.rs @@ -21,7 +21,6 @@ pub const LONG_PRESS: Duration = Duration::from_millis( 450 ); const REPEAT_INTERVAL: Duration = Duration::from_millis( 60 ); const SHIFT_DOUBLE_TAP: Duration = Duration::from_millis( 350 ); /// Design sizes in logical pixels; the panel multiplies them by its scale. -const KEY_HEIGHT: f32 = 46.0; const GAP: f32 = 6.0; const PADDING: f32 = 6.0; @@ -221,11 +220,24 @@ impl Panel self.bar.as_ref().map( |b| b.rows( layout::BASE_LAYER ) ).unwrap_or( &[] ) } - /// Height the panel needs at `scale` for its current layer and purpose. - pub fn height( &self, scale: f32 ) -> f32 + /// Height the panel needs `width` pixels wide at `scale`, for its current layer and purpose. + pub fn height( &self, width: f32, scale: f32 ) -> f32 { let rows = ( self.bar_rows().len() + self.layout().rows( &self.layer ).len() ) as f32; - ( rows * KEY_HEIGHT + ( rows - 1.0 ).max( 0.0 ) * GAP + 2.0 * PADDING ) * scale + rows * self.key_unit( width, scale ) + ( ( rows - 1.0 ).max( 0.0 ) * GAP + 2.0 * PADDING ) * scale + } + + /// Width of one key unit of the layout's rows, `width` pixels wide: the + /// widest row fills the panel. Keys are as tall as one unit is wide. + fn key_unit( &self, width: f32, scale: f32 ) -> f32 + { + let rows = self.layout().rows( &self.layer ); + let widest = rows.iter() + .map( |row| row.iter().map( |k| k.width ).sum::() ) + .fold( 0.0_f32, f32::max ) + .max( 1.0 ); + let most = rows.iter().map( Vec::len ).max().unwrap_or( 1 ).saturating_sub( 1 ) as f32; + ( width - 2.0 * PADDING * scale - GAP * scale * most ) / widest } /// Place the keys inside `rect`: the purpose's rows first, each spanning the width, then the layout's, which share one key unit and are centred when narrower than the widest. @@ -236,7 +248,7 @@ impl Panel self.keys.clear(); let gap = GAP * scale; let pad = PADDING * scale; - let key_h = KEY_HEIGHT * scale; + let key_h = self.key_unit( rect.width, scale ); let avail = rect.width - 2.0 * pad; let mut y = rect.y + pad; @@ -252,11 +264,7 @@ impl Panel let rows: Vec> = self.layout().rows( &self.layer ).iter() .map( |row| row.iter().map( |k| self.labelled( k ) ).collect() ) .collect(); - let widest = rows.iter() - .map( |row| row.iter().map( |k| k.width ).sum::() ) - .fold( 0.0_f32, f32::max ) - .max( 1.0 ); - let unit = ( avail - gap * ( rows.iter().map( Vec::len ).max().unwrap_or( 1 ).saturating_sub( 1 ) ) as f32 ) / widest; + let unit = key_h; for row in &rows { let units: f32 = row.iter().map( |k| k.width ).sum(); @@ -377,7 +385,7 @@ impl Panel { let key = self.keys[ idx ].rect; let gap = GAP * self.scale; - let w = key.width.max( KEY_HEIGHT * self.scale ); + let w = key.width.max( key.height ); let total = variants.len() as f32 * w + ( variants.len().saturating_sub( 1 ) ) as f32 * gap; let x0 = ( key.x + key.width / 2.0 - total / 2.0 ).max( self.rect.x ).min( self.rect.x + self.rect.width - total ); let y = key.y - key.height - gap; @@ -486,7 +494,7 @@ mod tests fn panel() -> Panel { let mut p = Panel::new( Vec::new(), "en" ); - p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 1.0 ) }, 1.0 ); + p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 400.0, 1.0 ) }, 1.0 ); p } @@ -591,10 +599,10 @@ mod tests { let mut p = panel(); let now = Instant::now(); - let plain = p.height( 1.0 ); + let plain = p.height( 400.0, 1.0 ); p.set_purpose( Purpose::Terminal ); - assert!( p.height( 1.0 ) > plain ); - p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 1.0 ) }, 1.0 ); + assert!( p.height( 400.0, 1.0 ) > plain ); + p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 400.0, 1.0 ) }, 1.0 ); let esc = p.keys().iter().find( |k| k.key.keysym == Some( Keysym::Escape ) ).unwrap().rect; let at = Point { x: esc.x + 1.0, y: esc.y + 1.0 }; @@ -611,7 +619,7 @@ mod tests assert!( !p.modifiers().ctrl ); p.set_purpose( Purpose::Normal ); - assert_eq!( p.height( 1.0 ), plain ); + assert_eq!( p.height( 400.0, 1.0 ), plain ); } #[ test ] diff --git a/src/wallpaper.rs b/src/wallpaper.rs index a7fae01..2a896b1 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -101,6 +101,24 @@ impl WallpaperBundle ( Arc::clone( &self.landscape.0 ), self.landscape.1, self.landscape.2 ) } + /// A bundle `divisor` times smaller on each side and blurred by `sigma` + /// pixels of that size, for a backdrop the renderer scales back up: a + /// fraction of the memory, and the blur hides what the downscale lost. + pub fn blurred( &self, divisor: u32, sigma: f32 ) -> Self + { + let ( ref rgba, w, h ) = self.landscape; + let divisor = divisor.max( 1 ); + let nw = ( w / divisor ).max( 1 ); + let nh = ( h / divisor ).max( 1 ); + let Some( view ) = image::ImageBuffer::, &[u8]>::from_raw( w, h, rgba.as_slice() ) else + { + return Self::from_decoded( self.landscape() ); + }; + let small = image::imageops::resize( &view, nw, nh, image::imageops::FilterType::Triangle ); + let blurred = image::imageops::fast_blur( &small, sigma ); + Self::from_decoded( ( Arc::new( blurred.into_raw() ), nw, nh ) ) + } + /// Return the wallpaper variant appropriate for a surface of `(sw, sh)`. /// /// - Landscape surface (`sw >= sh`): returns the full landscape image. @@ -279,4 +297,23 @@ mod tests let ( _, w, h ) = bundle.for_size( 0, 0 ); assert_eq!( ( w, h ), ( 4, 4 ) ); } + + #[ test ] + fn blurred_is_divided_and_never_empty() + { + let bundle = WallpaperBundle::from_decoded( xgrad( 64, 32 ) ); + let ( rgba, w, h ) = bundle.blurred( 4, 1.0 ).landscape(); + assert_eq!( ( w, h ), ( 16, 8 ) ); + assert_eq!( rgba.len(), 16 * 8 * 4 ); + let ( _, w, h ) = WallpaperBundle::from_decoded( xgrad( 2, 2 ) ).blurred( 8, 1.0 ).landscape(); + assert_eq!( ( w, h ), ( 1, 1 ) ); + } + + #[ test ] + fn blurred_keeps_a_flat_colour() + { + let flat = ( Arc::new( [ 10u8, 20, 30, 255 ].repeat( 8 * 8 ) ), 8, 8 ); + let ( rgba, _, _ ) = WallpaperBundle::from_decoded( flat ).blurred( 2, 2.0 ).landscape(); + assert!( rgba.chunks( 4 ).all( |px| px == [ 10, 20, 30, 255 ] ) ); + } }