Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = [".", "xkb-core", "xkbcommon-compat"]

[package]
name = "wayland-keyboard"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.70"
description = "Lightweight keyboard handling library for Wayland — a pure Rust alternative to xkbcommon"
Expand All @@ -29,7 +29,7 @@ compose = []
testing = ["xkb"]

[dependencies]
xkb-core = { version = "0.1.0", path = "xkb-core", optional = true }
xkb-core = { version = "0.2.0", path = "xkb-core", optional = true }

[dev-dependencies]
wkb = { package = "wayland-keyboard", path = ".", features = ["testing"] }
Expand Down
78 changes: 50 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ dependencies.
- **Full modifier support** — Shift, Ctrl, Alt, AltGr, Caps Lock, Num Lock,
Scroll Lock, and multi-level keys.
- **Compose sequences** — built-in compose key and automatic compose handling.
- **Multi-layout keymaps** — supports multiple layouts with group switching.
- **LED state** — query Caps/Num/Scroll Lock indicator state.
- **Repeat info** — query whether a key repeats.
- **Lightweight** — no C FFI, no `unsafe`, minimal dependencies.
- **Lightweight** — no C FFI, no `unsafe` beyond `Send`/`Sync` impls, minimal
dependencies.

## Quick Start

Expand All @@ -30,16 +32,50 @@ dependencies.
wayland-keyboard = "0.1"
```

```rust
use wkb::{WKB, KeyDirection};
```rust,no_run
use wkb::WKB;

// Build from an XKB keymap string (e.g. received from a Wayland compositor)
let keymap_string = std::fs::read_to_string("/path/to/keymap").unwrap();
let mut wkb = WKB::new_from_string(keymap_string);
let mut wkb = WKB::new_from_string(&keymap_string).unwrap();

// Process a key press (evdev code 38 = 'a' on US layout)
let (ch, is_modifier) = wkb.key(38, KeyDirection::Down);
assert_eq!(ch, Some('a'));
let result = wkb.press_key(38);
println!("keysym: {:#x}, compose: {:?}", result.keysym, result.compose);

// Release the key
let result = wkb.release_key(38);

// Query current modifier state
let mods = wkb.modifiers_state();
println!("ctrl={} alt={} shift={}", mods.ctrl, mods.alt, mods.shift);
```

### Key Event API

| Method | Mutates state | Use case |
|--------|--------------|----------|
| `press_key(evdev)` | yes | Key down — updates modifiers, advances compose |
| `release_key(evdev)` | yes | Key up — updates modifiers |
| `repeat_key(evdev)` | yes | Key repeat — advances compose |
| `key_char(evdev)` | no | Raw character under current modifiers (no compose) |

All three event methods return a [`KeyResult`](https://docs.rs/wayland-keyboard/latest/wkb/struct.KeyResult.html)
containing the keysym, compose state, and whether the key is a modifier.

### Compositor Usage

```rust,no_run
use wkb::WKB;

// Build from RMLVO names (compositor side)
let wkb = WKB::new_from_names("evdev", "pc105", "us,de", "dvorak,", None).unwrap();

// Serialize to XKB string for wl_keyboard.keymap
let xkb_string = wkb.as_xkb_string().unwrap();

// Switch layouts via group index (no re-parsing needed)
// wkb.set_layout(1).unwrap(); // switch to German
```

## Feature Flags
Expand All @@ -53,40 +89,26 @@ assert_eq!(ch, Some('a'));
## Benchmarks

<!-- BENCHMARK_START -->
*Last updated: 2026-04-24 (automated via CI)*
*Last updated: 2026-04-25 (automated via CI)*

### Speed

| Benchmark | wkb | xkbcommon | xkbcommon-dl | vs xkbcommon |
|-----------|-----|-----------|--------------|-------------|
| full setup | 6.35 ms | 4.25 ms | 4.28 ms | 1.5x slower |

### Memory

| Library | Peak RSS |
|---------|----------|
| wkb | 4.9 MB |
| xkbcommon | 5.0 MB |
| xkbcommon-dl | 5.0 MB |

### Binary Size

Sizes for xkbcommon and xkbcommon-dl include the dynamically-linked `libxkbcommon.so`.

| Binary | Size (stripped) |
|--------|----------------|
| wkb | 1151 KB |
| xkbcommon | 701 KB |
| xkbcommon-dl | 735 KB |
| Full setup | 14.43 ms | 4.99 ms | 4.99 ms | 2.9x slower |
| Key update | 38 ns | 200 ns | 203 ns | **5.3x faster** |
| Get UTF-8 | 125 ns | 388 ns | 309 ns | **3.1x faster** |
| Get keysym | 120 ns | 244 ns | 248 ns | **2.0x faster** |
| Compose setup | 7.72 ms | 3.53 ms | 2.33 ms | 2.2x slower |
| Compose feed | 23 ns | 45 ns | 46 ns | **2.0x faster** |

<!-- BENCHMARK_END -->

## Scope and Limitations

WKB targets the subset of XKB used by Wayland clients and compositors.
Geometry descriptions and other X11-only features are intentionally out of
scope. A future native keyboard format (TOML/RON) is planned but not yet
available.
scope.

## License

Expand Down
4 changes: 2 additions & 2 deletions benches/bench_compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use common::*;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use std::ffi::CString;
use std::time::Duration;
use wkb::testing::ListComposerTestExt;
use wkb::testing::composer_feed;

fn cfg() -> Criterion {
Criterion::default()
Expand Down Expand Up @@ -162,7 +162,7 @@ fn bench_compose_feed(c: &mut Criterion) {
group.bench_with_input(BenchmarkId::new("wkb", seq.name), &tokens, |b, tokens| {
b.iter(|| {
for token in tokens {
black_box(composer.feed(*token));
black_box(composer_feed(&mut composer, *token));
}
});
});
Expand Down
9 changes: 4 additions & 5 deletions benches/bench_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ use std::ffi::CString;
use std::os::raw::c_char;
use std::ptr;
use std::time::Duration;
use wkb::testing::WKBTestExt;
use wkb::KeyDirection;
use wkb::testing::{KeyDirection, WKBTestExt};

fn cfg() -> Criterion {
Criterion::default()
Expand All @@ -19,7 +18,7 @@ fn cfg() -> Criterion {
// ── Setup helpers ──────────────────────────────────────────────────────

fn wkb_setup(locale: &str, variant: Option<&str>) -> wkb::WKB {
wkb::WKB::new_from_names(locale.to_string(), variant.map(String::from))
wkb::WKB::new_from_names("", "", locale, variant.unwrap_or(""), None).unwrap()
}

fn xkbcommon_setup(
Expand Down Expand Up @@ -232,7 +231,7 @@ fn bench_key_get_utf8(c: &mut Criterion) {
|wb: &mut wkb::WKB, code: u32, down: bool, dir: KeyDirection| {
wb.update_key(code, dir);
if down {
black_box(wb.utf8(black_box(code)));
black_box(wb.key_char(black_box(code)));
}
}
);
Expand Down Expand Up @@ -310,7 +309,7 @@ fn bench_key_get_sym(c: &mut Criterion) {
|wb: &mut wkb::WKB, code: u32, down: bool, dir: KeyDirection| {
wb.update_key(code, dir);
if down {
black_box(wb.utf8(black_box(code)));
black_box(wb.key_char(black_box(code)));
}
}
);
Expand Down
21 changes: 2 additions & 19 deletions benches/bench_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,14 @@ fn cfg() -> Criterion {
.sample_size(10)
}

fn compat_setup(
locale: &str,
variant: Option<&str>,
) -> (xkb_core::rust_types::Keymap, xkb_core::rust_types::State) {
use xkb_core::rust_types::{Context, RuleNames};
let ctx = Context::new().expect("xkb-core context");
let rmlvo = RuleNames {
rules: "evdev".to_string(),
model: String::new(),
layout: locale.to_string(),
variant: variant.unwrap_or("").to_string(),
options: String::new(),
};
let km = ctx.keymap_from_names(&rmlvo).expect("xkb-core keymap");
let st = km.new_state().expect("xkb-core state");
(km, st)
}

fn bench_full_setup(c: &mut Criterion) {
let mut group = c.benchmark_group("full_setup");
let locale = "us";

group.bench_function("wkb", |b| {
b.iter(|| {
let wkb: wkb::WKB = wkb::WKB::new_from_names(black_box(locale).to_string(), None);
let wkb: wkb::WKB =
wkb::WKB::new_from_names("", "", black_box(locale), "", None).unwrap();
black_box(wkb);
});
});
Expand Down
21 changes: 9 additions & 12 deletions examples/bench_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
//! Or for quick RSS measurement:
//! /usr/bin/time -v ./target/release/examples/bench_memory 2>&1 | grep "Maximum resident"

use wkb::testing::{ListComposerTestExt, WKBTestExt};

#[path = "../benches/common.rs"]
mod common;

Expand All @@ -18,6 +16,7 @@ use std::ffi::CString;
use std::hint::black_box;
use std::os::raw::c_char;
use std::ptr;
use wkb::testing::composer_feed;

fn get_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
Expand All @@ -40,22 +39,20 @@ fn run_workload_wkb() -> u64 {
print_rss("wkb/before_setup");

for &(locale, variant) in LAYOUTS {
let layout = variant.map(String::from);
let mut wb = wkb::WKB::new_from_names(locale.to_string(), layout);
let mut wb = wkb::WKB::new_from_names("", "", locale, variant.unwrap_or(""), None).unwrap();

for case in KEY_CASES {
for _ in 0..HOT_PATH_ITERATIONS {
for &(code, down) in case.keys {
let dir = if down {
wkb::KeyDirection::Down
} else {
wkb::KeyDirection::Up
};
wb.update_key(code, dir);
if down {
if let Some(ch) = wb.utf8(code) {
let result = wb.press_key(code);
if let Some(ch) = wb.key_char(code) {
checksum = checksum.wrapping_add(ch as u64);
}
black_box(result);
} else {
let result = wb.release_key(code);
black_box(result);
}
}
}
Expand All @@ -70,7 +67,7 @@ fn run_workload_wkb() -> u64 {
for _ in 0..HOT_PATH_ITERATIONS {
for &ks in seq.keysyms {
if let Some(ch) = xkb_core::keysym_utf::keysym_to_char(ks) {
let st = composer.feed(wkb::testing::Token::Char(ch));
let st = composer_feed(&mut composer, wkb::testing::Token::Char(ch));
checksum = checksum.wrapping_add(format!("{st:?}").len() as u64);
}
}
Expand Down
20 changes: 9 additions & 11 deletions examples/bench_size_wkb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,25 @@
mod common;
use common::*;
use std::hint::black_box;
use wkb::testing::{ListComposerTestExt, WKBTestExt};
use wkb::testing::composer_feed;

fn main() {
let mut checksum: u64 = 0;

for &(locale, variant) in LAYOUTS {
let layout = variant.map(String::from);
let mut wb = wkb::WKB::new_from_names(locale.to_string(), layout);
let mut wb = wkb::WKB::new_from_names("", "", locale, variant.unwrap_or(""), None).unwrap();

for case in KEY_CASES {
for &(code, down) in case.keys {
let dir = if down {
wkb::KeyDirection::Down
} else {
wkb::KeyDirection::Up
};
wb.update_key(code, dir);
if down {
if let Some(ch) = wb.utf8(code) {
let result = wb.press_key(code);
if let Some(ch) = wb.key_char(code) {
checksum = checksum.wrapping_add(ch as u64);
}
black_box(result);
} else {
let result = wb.release_key(code);
black_box(result);
}
}
}
Expand All @@ -39,7 +37,7 @@ fn main() {
for seq in COMPOSE_SEQUENCES {
for &ks in seq.keysyms {
if let Some(ch) = xkb_core::keysym_utf::keysym_to_char(ks) {
let _ = composer.feed(wkb::testing::Token::Char(ch));
let _ = composer_feed(&mut composer, wkb::testing::Token::Char(ch));
checksum = checksum.wrapping_add(1);
}
}
Expand Down
4 changes: 2 additions & 2 deletions examples/profile_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ fn main() {

// Full WKB setup for comparison
let t_wkb = Instant::now();
let _wkb: wkb::WKB = wkb::WKB::new_from_names("us".to_string(), None);
let _wkb: wkb::WKB = wkb::WKB::new_from_names("", "", "us", "", None).unwrap();
eprintln!("\nFull WKB new_from_names: {:?}", t_wkb.elapsed());

// With explicit layout (skip get_all_layouts)
let t_wkb2 = Instant::now();
let _wkb2: wkb::WKB = wkb::WKB::new_from_names("us".to_string(), Some(String::new()));
let _wkb2: wkb::WKB = wkb::WKB::new_from_names("", "", "us", "", None).unwrap();
eprintln!("WKB with explicit layout: {:?}", t_wkb2.elapsed());

eprintln!("Total profiling time: {:?}", t0.elapsed());
Expand Down
40 changes: 30 additions & 10 deletions scripts/generate_benchmark_table.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,43 @@ function generateSpeedTable() {
const groups = fs.readdirSync(CRITERION_DIR)
.filter(d => !d.startsWith('.') && d !== 'report');

// Focus on key benchmark groups (criterion uses '/' as dir separator)
const interesting = ['full_setup', 'key/update', 'key/get_utf8', 'key/get_sym',
'compose/table_creation', 'compose/state_creation', 'compose/feed'];
// Criterion stores groups with '_' separators in directory names
const interesting = [
{ dir: 'full_setup', label: 'Full setup' },
{ dir: 'key_update', label: 'Key update' },
{ dir: 'key_get_utf8', label: 'Get UTF-8' },
{ dir: 'key_get_sym', label: 'Get keysym' },
{ dir: 'compose_setup', label: 'Compose setup', combine: ['compose_table_creation', 'compose_state_creation'] },
{ dir: 'compose_feed', label: 'Compose feed' },
];

const rows = [];
for (const group of interesting) {
const gpath = path.join(CRITERION_DIR, group);
if (!fs.existsSync(gpath)) continue;

for (const { dir, label, combine } of interesting) {
const vals = {};
for (const impl_ of IMPLS) {
vals[impl_] = readEstimates(gpath, impl_);

if (combine) {
// Sum estimates from multiple benchmark groups
for (const impl_ of IMPLS) {
let total = 0;
let found = false;
for (const subDir of combine) {
const gpath = path.join(CRITERION_DIR, subDir);
const v = fs.existsSync(gpath) ? readEstimates(gpath, impl_) : null;
if (v != null) { total += v; found = true; }
}
vals[impl_] = found ? total : null;
}
} else {
const gpath = path.join(CRITERION_DIR, dir);
if (!fs.existsSync(gpath)) continue;
for (const impl_ of IMPLS) {
vals[impl_] = readEstimates(gpath, impl_);
}
}
if (!vals['wkb']) continue;

const row = [
group.replace(/_/g, ' '),
label,
vals['wkb'] ? fmt_ns(vals['wkb']) : '-',
vals['xkbcommon'] ? fmt_ns(vals['xkbcommon']) : '-',
vals['xkbcommon-dl'] ? fmt_ns(vals['xkbcommon-dl']) : '-',
Expand Down
Loading