Current section
Files
Jump to
Current section
Files
src/ui/keyboard.gleam
//// Keyboard event decoding and view helpers for Glizzy components.
////
//// Provides a shared KeyEvent type with modifier keys and a helper
//// that decodes full DOM keyboard events for component keymaps.
// MIT License
// Copyright (c) 2026 Koncreate
// See LICENSE for details
import gleam/dynamic/decode
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import lustre/attribute.{type Attribute}
import lustre/event
// ============================================================================
// Types
// ============================================================================
/// A decoded keyboard event with modifier key state.
pub type KeyEvent {
KeyEvent(key: String, shift: Bool, ctrl: Bool, meta: Bool, alt: Bool)
}
// ============================================================================
// Key Type for Pattern Matching
// ============================================================================
/// A normalized key type for easier pattern matching in keymaps.
pub type Key {
Space
Enter
Escape
Tab
Shift
Control
Alt
Meta
ArrowUp
ArrowDown
ArrowLeft
ArrowRight
Home
End
PageUp
PageDown
Delete
Backspace
F1
F2
F3
F4
F5
F6
F7
F8
F9
F10
F11
F12
Character(String)
Unknown
}
/// Decode a key string from a KeyEvent into a normalized Key type.
pub fn decode_key(key_string: String) -> Key {
case key_string {
" " -> Space
"Enter" -> Enter
"Escape" -> Escape
"Tab" -> Tab
"Shift" -> Shift
"Control" -> Control
"Alt" -> Alt
"Meta" -> Meta
"ArrowUp" -> ArrowUp
"ArrowDown" -> ArrowDown
"ArrowLeft" -> ArrowLeft
"ArrowRight" -> ArrowRight
"Home" -> Home
"End" -> End
"PageUp" -> PageUp
"PageDown" -> PageDown
"Delete" -> Delete
"Backspace" -> Backspace
"F1" -> F1
"F2" -> F2
"F3" -> F3
"F4" -> F4
"F5" -> F5
"F6" -> F6
"F7" -> F7
"F8" -> F8
"F9" -> F9
"F10" -> F10
"F11" -> F11
"F12" -> F12
_ -> Character(key_string)
}
}
// ============================================================================
// Navigation Helpers for Roving Tabindex
// ============================================================================
/// Calculate the next index in a circular list.
pub fn next_index(current: Int, item_count: Int, wrap: Bool) -> Int {
case item_count {
0 -> 0
_ -> {
let next = current + 1
case next >= item_count {
True if wrap -> 0
True -> current
False -> next
}
}
}
}
/// Calculate the previous index in a circular list.
pub fn prev_index(current: Int, item_count: Int, wrap: Bool) -> Int {
case item_count {
0 -> 0
_ -> {
case current <= 0 {
True if wrap -> item_count - 1
True -> current
False -> current - 1
}
}
}
}
/// Get the first index (always 0).
pub fn first_index(item_count: Int) -> Int {
case item_count {
0 -> 0
_ -> 0
}
}
/// Get the last index.
pub fn last_index(item_count: Int) -> Int {
case item_count {
0 -> 0
_ -> item_count - 1
}
}
// ============================================================================
// Type-Ahead Helper
// ============================================================================
/// Default timeout for type-ahead buffer in milliseconds.
pub const default_typeahead_timeout_ms = 500
/// Update type-ahead buffer with new character.
pub fn update_typeahead_buffer(
current_buffer: String,
last_keypress_time: Int,
current_time: Int,
new_char: String,
timeout_ms: Int,
) -> #(String, Bool) {
let timeout_expired = current_time - last_keypress_time > timeout_ms
case timeout_expired {
True -> #(new_char, True)
False -> #(current_buffer <> new_char, False)
}
}
/// Find the next item that starts with the given buffer string.
pub fn typeahead_match(items: List(String), current: Int, buffer: String) -> Int {
let normalized_buffer = string.lowercase(buffer)
let items_from_next = items |> list.drop(current + 1)
let items_wrapped = items |> list.take(current + 1)
case
find_matching_buffer_index(items_from_next, normalized_buffer, current + 1)
{
Some(index) -> index
None ->
find_matching_buffer_index(items_wrapped, normalized_buffer, 0)
|> option.unwrap(current)
}
}
fn find_matching_buffer_index(
items: List(String),
buffer: String,
offset: Int,
) -> Option(Int) {
case items {
[] -> None
[item, ..rest] -> {
case string.starts_with(string.lowercase(item), buffer) {
True -> Some(offset)
False -> find_matching_buffer_index(rest, buffer, offset + 1)
}
}
}
}
/// Find the next item that starts with the given character.
pub fn type_ahead_match(items: List(String), current: Int, char: String) -> Int {
let normalized_char = string.lowercase(char)
let items_from_next = items |> list.drop(current + 1)
let items_wrapped = items |> list.take(current + 1)
case find_matching_index(items_from_next, normalized_char, current + 1) {
Some(index) -> index
None ->
find_matching_index(items_wrapped, normalized_char, 0)
|> option.unwrap(current)
}
}
fn find_matching_index(
items: List(String),
char: String,
offset: Int,
) -> Option(Int) {
case items {
[] -> None
[item, ..rest] -> {
case string.starts_with(string.lowercase(item), char) {
True -> Some(offset)
False -> find_matching_index(rest, char, offset + 1)
}
}
}
}
// ============================================================================
// Key Classification Helpers
// ============================================================================
/// Check if a key is a navigation key.
pub fn is_navigation_key(key: Key) -> Bool {
case key {
ArrowUp
| ArrowDown
| ArrowLeft
| ArrowRight
| Home
| End
| PageUp
| PageDown -> True
_ -> False
}
}
/// Check if a key is an activation key.
pub fn is_activation_key(key: Key) -> Bool {
case key {
Enter | Space -> True
_ -> False
}
}
/// Check if a key is a character key.
pub fn is_character_key(key: Key) -> Bool {
case key {
Character(_) -> True
_ -> False
}
}
// ============================================================================
// View Helpers
// ============================================================================
/// Attach a keydown handler that decodes the full KeyEvent with modifier keys.
pub fn on_keydown(handler: fn(KeyEvent) -> msg) -> Attribute(msg) {
event.on("keydown", {
use key <- decode.field("key", decode.string)
use shift <- decode.field("shiftKey", decode.bool)
use ctrl <- decode.field("ctrlKey", decode.bool)
use meta <- decode.field("metaKey", decode.bool)
use alt <- decode.field("altKey", decode.bool)
decode.success(handler(KeyEvent(key:, shift:, ctrl:, meta:, alt:)))
})
}
/// Attach a keydown handler with preventDefault for all dispatched keys.
pub fn on_keydown_prevent(handler: fn(KeyEvent) -> msg) -> Attribute(msg) {
event.prevent_default(on_keydown(handler))
}