ltk ran on Wayland and Android only. Running crustace and its apps in a browser needs, first, ltk apps running there; a window manager for the page is the next step. The runtime was already split from Wayland by the platform layer, so what kept it out of a browser was what the core and the runtime took from the operating system: calloop's epoll loop, EGL, a monotonic clock, a file system, threads and environment variables. The core builds for wasm32-unknown-unknown. `ltk::ChannelSender` is ltk's own type instead of a re-export of calloop's `Sender`, with the same `send` and `Clone`, so apps that only send and clone it build unchanged. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`: calloop's natively, and in a browser a queue the backend runs, which takes the due timers out, runs them on `AppData` and hands them back, and does not arm again one removed meanwhile. calloop, khronos-egl and raw-window-handle are dependencies off wasm32 only, where `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out; `sub_canvas` clones the uniform locations, which are `Copy` natively and only `Clone` on wasm. The monotonic clock is `crate::time::Instant`, `web_time`'s on wasm32, where `chrono` takes its `wasmbind` feature; the one subtraction that could go below the clock's origin, a surface's first `last_draw`, saturates. Themes, fonts, keyboard layouts and images are read through `vfs.rs`, the file system natively and on wasm32 files held in memory, so a wallpaper or an `Image::from_path` is now decoded from the file's bytes and its format told from its contents rather than its extension. The text-scale watcher, which runs gsettings on a thread, is left out on wasm32, and the built-in keyboard takes its language from `platform::language()`, `LANG` or the page's. criterion is a dev-dependency off wasm32 only, since rayon does not build there. The browser backend is the `web` feature, `platform/web/`, with wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys. `build.rs` refuses it with another backend or for another target. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local loop holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Event listeners and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off runs inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its frame kept in its FBO, or without WebGL2 through `putImageData`. A lost context drops the GPU canvases and draws nothing until it is restored on a new `glow::Context`, so the glyph atlas and the caches of the old one are not reused. The main surface's logical size is the canvas's CSS size; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets. Input comes from Pointer Events, a mouse as the pointer and fingers and pens as touches, the wheel, in detents when it scrolls lines, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports; keys repeat on the browser's clock, AltGr types its character, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen those `navigator.keyboard.lock()` adds in Chromium. Copies go to `navigator.clipboard.writeText`; a page reads the clipboard only from a `paste` event, so Ctrl+V is let through to the browser and held until its `paste` has put the text in `AppData::clipboard`, and a paste from the browser's menu comes with a Ctrl+V of its own. The cursor's shape is the canvas's CSS `cursor`. The built-in keyboard serves text where the page is used by touch. Panics go to the browser's console. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. The new `RunError::NoDocument` is returned where there is no page, as in a worker. Resources are the tree an Android package carries. `scripts/web-assets.sh` builds it through `android-assets.sh`, and `ltk::web::load_resources( base ).await` fetches its `MANIFEST` and every file it lists, with the manifest's hash in their URLs so a changed tree is not served from the browser's cache, into memory under `/ltk`, which becomes the resource directory. The per-script fallback fonts are read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, where a page bundles them. A surface's scale may be fractional. `SurfaceState::scale_factor` is an `f32`, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once, and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`, so the canvas holds the display's own pixels at any zoom. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. The fallback-theme banner placed its text as if `draw_text` took the top of the line rather than its baseline, so most of the text fell above the banner, out of the surface; it is now centred as the title bar's is. Building. `make web-check` typechecks the backend. `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version must be the one of the wasm-bindgen crate in `Cargo.lock`: the crate is pinned to 0.2.117, the last whose CLI builds with Debian's rustc 1.85, and since the lock the CLI was published with needs a newer rustc and `cargo install` ignores rust-version when it resolves, the CLI is built from its sources with a lock resolved for the rust-version it declares, into `target/tools`, where the Makefile finds it. `make web-assets` builds the resources with the in-tree default theme and the fallback fonts `WEB_FONTS` names. `examples/web_hello.rs`, a counter and a text field, and `web/index.html` are the smoke test: `make web-example` builds them into `target/web/` and `make web-serve` serves them. `make install` now copies `web/` into the registry. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the backend, and `docs/cookbook.md` points to `ChannelSender`'s new home. Not done. In a browser there are no overlays, secondary surfaces or subsurfaces, no dead keys or input methods, no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. Pasting relies on the browser sending `paste` to a focused canvas, which Chrome and Firefox do. The page fetches its whole resource tree, about 7 MB, before the app starts. There are no tests for the browser backend, `Sources`' queue or `vfs.rs`'s in-memory files, and fractional scales may leave one-pixel artefacts where edges fall between pixels.
385 lines
13 KiB
Rust
385 lines
13 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! A keyboard layout as a data file: layers of rows of keys, and the variants a long press offers.
|
|
//!
|
|
//! Files are JSON, one per layout, named by their language tag (`es.json`). A layer is a list of rows; a key is a bare string for the text it types, or an object with an `action`, a named `key` (`Escape`, `Left`…), a `modifier` (`ctrl`, `alt`), a `width` in key units (default 1) and a `label`. The `shift` layer is derived from `base` by uppercasing when a file has none. `variants` maps a key's text to the alternatives a long press offers. Files named `bar-*.json` are rows laid above any language's layout, not layouts of their own: `bar-terminal.json` is the row of keys a terminal needs.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::Keysym;
|
|
|
|
pub const BASE_LAYER: &str = "base";
|
|
pub const SHIFT_LAYER: &str = "shift";
|
|
pub const TERMINAL_BAR: &str = "bar-terminal";
|
|
|
|
const FALLBACK_LAYOUT: &str = include_str!( "../../keyboards/en.json" );
|
|
const FALLBACK_TERMINAL_BAR: &str = include_str!( "../../keyboards/bar-terminal.json" );
|
|
|
|
/// What a key does besides typing.
|
|
#[ derive( Clone, Debug, PartialEq, Eq, Deserialize ) ]
|
|
#[ serde( rename_all = "lowercase" ) ]
|
|
pub enum Action
|
|
{
|
|
Shift,
|
|
Backspace,
|
|
Enter,
|
|
Space,
|
|
/// Switch to the named layer.
|
|
Layer,
|
|
/// Switch to the next installed layout.
|
|
Layout,
|
|
Hide,
|
|
}
|
|
|
|
/// A sticky modifier: held for the next key, then released.
|
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Deserialize ) ]
|
|
#[ serde( rename_all = "lowercase" ) ]
|
|
pub enum Modifier
|
|
{
|
|
Ctrl,
|
|
Alt,
|
|
}
|
|
|
|
#[ derive( Clone, Debug, Deserialize ) ]
|
|
#[ serde( untagged ) ]
|
|
enum KeySpec
|
|
{
|
|
Text( String ),
|
|
Full
|
|
{
|
|
#[ serde( default ) ]
|
|
text: Option<String>,
|
|
#[ serde( default ) ]
|
|
action: Option<Action>,
|
|
#[ serde( default ) ]
|
|
key: Option<String>,
|
|
#[ serde( default ) ]
|
|
modifier: Option<Modifier>,
|
|
#[ serde( default ) ]
|
|
layer: Option<String>,
|
|
#[ serde( default ) ]
|
|
label: Option<String>,
|
|
#[ serde( default = "one" ) ]
|
|
width: f32,
|
|
},
|
|
}
|
|
|
|
fn one() -> f32 { 1.0 }
|
|
|
|
#[ derive( Clone, Debug, Deserialize ) ]
|
|
struct LayoutFile
|
|
{
|
|
name: String,
|
|
#[ serde( default ) ]
|
|
label: Option<String>,
|
|
layers: HashMap<String, Vec<Vec<KeySpec>>>,
|
|
#[ serde( default ) ]
|
|
variants: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
#[ derive( Clone, Debug, PartialEq ) ]
|
|
pub struct Key
|
|
{
|
|
/// Typed when pressed, `None` for a key with an action only.
|
|
pub text: Option<String>,
|
|
pub action: Option<Action>,
|
|
/// A key that is not text: an arrow, Escape, Tab.
|
|
pub keysym: Option<Keysym>,
|
|
pub modifier: Option<Modifier>,
|
|
/// The layer an `Action::Layer` key switches to.
|
|
pub layer: Option<String>,
|
|
pub label: String,
|
|
/// In key units; a row's units share its width.
|
|
pub width: f32,
|
|
}
|
|
|
|
#[ derive( Clone, Debug ) ]
|
|
pub struct Layout
|
|
{
|
|
pub name: String,
|
|
/// Shown on the layout-switch key.
|
|
pub label: String,
|
|
layers: HashMap<String, Vec<Vec<Key>>>,
|
|
variants: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
impl Layout
|
|
{
|
|
pub fn from_json( json: &str ) -> Result<Self, String>
|
|
{
|
|
let file: LayoutFile = serde_json::from_str( json ).map_err( |e| e.to_string() )?;
|
|
if !file.layers.contains_key( BASE_LAYER ) { return Err( format!( "layout {} has no `{BASE_LAYER}` layer", file.name ) ); }
|
|
let mut layers: HashMap<String, Vec<Vec<Key>>> = file.layers.iter()
|
|
.map( |( id, rows )| ( id.clone(), rows.iter().map( |row| row.iter().map( |k| Key::from_spec( k, id ) ).collect() ).collect() ) )
|
|
.collect();
|
|
if !layers.contains_key( SHIFT_LAYER )
|
|
{
|
|
let shifted = layers[ BASE_LAYER ].iter()
|
|
.map( |row| row.iter().map( Key::uppercased ).collect() )
|
|
.collect();
|
|
layers.insert( SHIFT_LAYER.to_string(), shifted );
|
|
}
|
|
let label = file.label.unwrap_or_else( || file.name.to_uppercase() );
|
|
Ok( Self { name: file.name, label, layers, variants: file.variants } )
|
|
}
|
|
|
|
pub fn rows( &self, layer: &str ) -> &[ Vec<Key> ]
|
|
{
|
|
self.layers.get( layer ).or_else( || self.layers.get( BASE_LAYER ) ).map( Vec::as_slice ).unwrap_or( &[] )
|
|
}
|
|
|
|
pub fn has_layer( &self, layer: &str ) -> bool
|
|
{
|
|
self.layers.contains_key( layer )
|
|
}
|
|
|
|
/// Alternatives a long press on `text` offers; the shifted form of a key uses its base's, uppercased.
|
|
pub fn variants( &self, text: &str ) -> Vec<String>
|
|
{
|
|
if let Some( v ) = self.variants.get( text ) { return v.clone(); }
|
|
let lower = text.to_lowercase();
|
|
if lower != text
|
|
{
|
|
if let Some( v ) = self.variants.get( &lower ) { return v.iter().map( |s| s.to_uppercase() ).collect(); }
|
|
}
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
impl Key
|
|
{
|
|
fn from_spec( spec: &KeySpec, layer: &str ) -> Self
|
|
{
|
|
match spec
|
|
{
|
|
KeySpec::Text( text ) => Self { text: Some( text.clone() ), action: None, keysym: None, modifier: None, layer: None, label: text.clone(), width: 1.0 },
|
|
KeySpec::Full { text, action, key, modifier, layer: target, label, width } =>
|
|
{
|
|
let keysym = key.as_deref().and_then( named_keysym );
|
|
let label = label.clone()
|
|
.or_else( || text.clone() )
|
|
.or_else( || key.clone().filter( |_| keysym.is_some() ) )
|
|
.or_else( || modifier.map( |m| match m { Modifier::Ctrl => "Ctrl", Modifier::Alt => "Alt" }.to_string() ) )
|
|
.unwrap_or_else( || default_label( action.as_ref(), target.as_deref(), layer ) );
|
|
Self { text: text.clone(), action: action.clone(), keysym, modifier: *modifier, layer: target.clone(), label, width: *width }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn uppercased( &self ) -> Self
|
|
{
|
|
match &self.text
|
|
{
|
|
Some( t ) if self.action.is_none() =>
|
|
{
|
|
let up = t.to_uppercase();
|
|
Self { text: Some( up.clone() ), label: up, ..self.clone() }
|
|
}
|
|
_ => self.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_label( action: Option<&Action>, target: Option<&str>, layer: &str ) -> String
|
|
{
|
|
match action
|
|
{
|
|
Some( Action::Shift ) => if layer == SHIFT_LAYER { "⇪" } else { "⇧" }.to_string(),
|
|
Some( Action::Backspace ) => "⌫".to_string(),
|
|
Some( Action::Enter ) => "↵".to_string(),
|
|
Some( Action::Space ) => String::new(),
|
|
Some( Action::Layer ) => target.map( |l| if l == BASE_LAYER { "ABC".to_string() } else { l.to_uppercase() } ).unwrap_or_default(),
|
|
Some( Action::Layout ) => String::new(),
|
|
Some( Action::Hide ) => "⌄".to_string(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// The keys a layout file may name by their X keysym name.
|
|
fn named_keysym( name: &str ) -> Option<Keysym>
|
|
{
|
|
Some( match name
|
|
{
|
|
"Escape" => Keysym::Escape,
|
|
"Tab" => Keysym::Tab,
|
|
"Left" => Keysym::Left,
|
|
"Right" => Keysym::Right,
|
|
"Up" => Keysym::Up,
|
|
"Down" => Keysym::Down,
|
|
"Home" => Keysym::Home,
|
|
"End" => Keysym::End,
|
|
"Page_Up" => Keysym::Page_Up,
|
|
"Page_Down" => Keysym::Page_Down,
|
|
"Delete" => Keysym::Delete,
|
|
"Insert" => Keysym::Insert,
|
|
"F1" => Keysym::F1, "F2" => Keysym::F2, "F3" => Keysym::F3, "F4" => Keysym::F4,
|
|
"F5" => Keysym::F5, "F6" => Keysym::F6, "F7" => Keysym::F7, "F8" => Keysym::F8,
|
|
"F9" => Keysym::F9, "F10" => Keysym::F10, "F11" => Keysym::F11, "F12" => Keysym::F12,
|
|
_ => return None,
|
|
} )
|
|
}
|
|
|
|
/// Directories searched for `<name>.json`, highest priority first.
|
|
pub fn search_paths() -> Vec<PathBuf>
|
|
{
|
|
let mut out = Vec::new();
|
|
if let Some( dev ) = std::env::var_os( "LTK_KEYBOARDS_DIR" ) { out.push( PathBuf::from( dev ) ); }
|
|
if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" )
|
|
{
|
|
out.push( PathBuf::from( home ).join( "ltk/keyboards" ) );
|
|
} else if let Some( home ) = std::env::var_os( "HOME" ) {
|
|
out.push( PathBuf::from( home ).join( ".local/share/ltk/keyboards" ) );
|
|
}
|
|
if let Some( bundle ) = crate::resources::resource_dir() { out.push( bundle.join( "keyboards" ) ); }
|
|
out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) );
|
|
out
|
|
}
|
|
|
|
/// The installed layout `name`, or `None` when no directory has it or it does not parse.
|
|
pub fn load( name: &str ) -> Option<Layout>
|
|
{
|
|
search_paths().into_iter()
|
|
.map( |dir| dir.join( format!( "{name}.json" ) ) )
|
|
.find_map( |path| crate::vfs::read_to_string( &path ).ok() )
|
|
.and_then( |json| Layout::from_json( &json ).map_err( |e| eprintln!( "[ltk] keyboard layout {name}: {e}" ) ).ok() )
|
|
}
|
|
|
|
/// Names of every installed language layout, without duplicates, sorted; the `bar-*` files are rows, not layouts.
|
|
pub fn available() -> Vec<String>
|
|
{
|
|
let mut names: Vec<String> = search_paths().into_iter()
|
|
.filter_map( |dir| crate::vfs::read_dir( dir ).ok() )
|
|
.flatten()
|
|
.filter_map( |path| path.file_stem().and_then( |s| s.to_str() ).map( str::to_string ).filter( |_| path.extension().is_some_and( |e| e == "json" ) ) )
|
|
.filter( |name| !name.starts_with( "bar-" ) )
|
|
.collect();
|
|
names.sort();
|
|
names.dedup();
|
|
names
|
|
}
|
|
|
|
/// The English layout compiled in, for a system with none installed.
|
|
pub fn fallback() -> Layout
|
|
{
|
|
Layout::from_json( FALLBACK_LAYOUT ).expect( "the built-in layout parses" )
|
|
}
|
|
|
|
/// The terminal row: the installed `bar-terminal.json`, or the one compiled in.
|
|
pub fn terminal_bar() -> Layout
|
|
{
|
|
load( TERMINAL_BAR ).unwrap_or_else( || Layout::from_json( FALLBACK_TERMINAL_BAR ).expect( "the built-in terminal bar parses" ) )
|
|
}
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
|
|
#[ test ]
|
|
fn built_in_layout_parses_with_a_derived_shift_layer()
|
|
{
|
|
let layout = fallback();
|
|
assert_eq!( layout.name, "en" );
|
|
let base = layout.rows( BASE_LAYER );
|
|
let shift = layout.rows( SHIFT_LAYER );
|
|
assert_eq!( base.len(), shift.len() );
|
|
assert_eq!( base[ 0 ][ 0 ].text.as_deref(), Some( "q" ) );
|
|
assert_eq!( shift[ 0 ][ 0 ].text.as_deref(), Some( "Q" ) );
|
|
assert!( layout.has_layer( "symbols" ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn key_objects_take_action_width_and_default_labels()
|
|
{
|
|
let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ "a", { "action": "backspace", "width": 1.5 }, { "action": "layer", "layer": "symbols", "label": "?123" } ] ], "symbols": [ [ "1" ] ] } }"# ).unwrap();
|
|
let row = &layout.rows( BASE_LAYER )[ 0 ];
|
|
assert_eq!( row[ 1 ].action, Some( Action::Backspace ) );
|
|
assert_eq!( row[ 1 ].width, 1.5 );
|
|
assert_eq!( row[ 1 ].label, "⌫" );
|
|
assert_eq!( row[ 2 ].label, "?123" );
|
|
assert_eq!( row[ 2 ].layer.as_deref(), Some( "symbols" ) );
|
|
assert_eq!( layout.label, "T" );
|
|
}
|
|
|
|
#[ test ]
|
|
fn variants_follow_case()
|
|
{
|
|
let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ "a" ] ] }, "variants": { "a": [ "á", "à" ] } }"# ).unwrap();
|
|
assert_eq!( layout.variants( "a" ), [ "á", "à" ] );
|
|
assert_eq!( layout.variants( "A" ), [ "Á", "À" ] );
|
|
assert!( layout.variants( "b" ).is_empty() );
|
|
}
|
|
|
|
#[ test ]
|
|
fn named_keys_and_modifiers_parse_with_their_labels()
|
|
{
|
|
let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ { "key": "Escape", "label": "Esc" }, { "key": "Left" }, { "modifier": "ctrl" }, { "key": "NoSuchKey" } ] ] } }"# ).unwrap();
|
|
let row = &layout.rows( BASE_LAYER )[ 0 ];
|
|
assert_eq!( row[ 0 ].keysym, Some( Keysym::Escape ) );
|
|
assert_eq!( row[ 0 ].label, "Esc" );
|
|
assert_eq!( row[ 1 ].keysym, Some( Keysym::Left ) );
|
|
assert_eq!( row[ 1 ].label, "Left" );
|
|
assert_eq!( row[ 2 ].modifier, Some( Modifier::Ctrl ) );
|
|
assert_eq!( row[ 2 ].label, "Ctrl" );
|
|
assert_eq!( row[ 3 ].keysym, None );
|
|
}
|
|
|
|
#[ test ]
|
|
fn bars_are_not_listed_as_layouts()
|
|
{
|
|
let _globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
|
let dir = std::env::temp_dir().join( format!( "ltk-osk-test-{}", std::process::id() ) );
|
|
std::fs::create_dir_all( &dir ).unwrap();
|
|
std::fs::write( dir.join( "xx.json" ), "{}" ).unwrap();
|
|
std::fs::write( dir.join( "bar-xx.json" ), "{}" ).unwrap();
|
|
std::env::set_var( "LTK_KEYBOARDS_DIR", &dir );
|
|
let names = available();
|
|
std::env::remove_var( "LTK_KEYBOARDS_DIR" );
|
|
let _ = std::fs::remove_dir_all( &dir );
|
|
assert!( names.contains( &"xx".to_string() ) );
|
|
assert!( !names.iter().any( |n| n.starts_with( "bar-" ) ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn the_built_in_terminal_bar_parses()
|
|
{
|
|
let bar = Layout::from_json( FALLBACK_TERMINAL_BAR ).unwrap();
|
|
let row = &bar.rows( BASE_LAYER )[ 0 ];
|
|
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<Key>| row.iter().map( |k| k.width ).sum::<f32>();
|
|
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 ]
|
|
fn a_layout_without_base_is_rejected()
|
|
{
|
|
assert!( Layout::from_json( r#"{ "name": "t", "layers": { "symbols": [ [ "1" ] ] } }"# ).is_err() );
|
|
}
|
|
}
|