From ccc30d1bbdf943a485a4a478f0f80165eaec3571 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 10:23:10 +0300 Subject: [PATCH] config: load beer.toml and apply font, geometry, scrollback, word delimiters Signed-off-by: NotAShelf Change-Id: I5008a74307d856f9df472776cb66c8b06a6a6964 --- Cargo.lock | 121 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 +- src/config.rs | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ src/grid.rs | 39 ++++++++++++--- src/main.rs | 10 +++- src/pty.rs | 6 +-- src/wayland.rs | 42 ++++++++++------ 7 files changed, 324 insertions(+), 29 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index c25689e..69ecaff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,8 +41,10 @@ dependencies = [ "lru", "pound", "rustix", + "serde", "smithay-client-toolkit", "thiserror", + "toml", "tracing", "tracing-subscriber", "unicode-width", @@ -83,6 +85,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags", + "nix", "polling", "rustix", "slab", @@ -117,6 +120,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -229,6 +238,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -308,6 +327,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -426,6 +457,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -523,6 +593,45 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -762,6 +871,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "xcursor" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index 0898d59..81c909d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ readme = true [dependencies] anyhow = "1.0.102" -calloop = "0.14.4" +calloop = { version = "0.14.4", features = ["signals"] } calloop-wayland-source = "0.4.1" fontconfig = "0.11.0" freetype-rs = "0.38.0" @@ -22,8 +22,10 @@ rustix = { version = "1.1.4", features = [ "stdio", "fs", ] } +serde = { version = "1.0.228", features = ["derive"] } smithay-client-toolkit = "0.20.0" thiserror = "2.0.18" +toml = "0.9" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2.2" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4242dfc --- /dev/null +++ b/src/config.rs @@ -0,0 +1,131 @@ +//! User configuration: a TOML file at `$XDG_CONFIG_HOME/beer/beer.toml` +//! deserialized into a typed [`Config`]. A missing file uses defaults; a +//! malformed one warns and falls back to defaults rather than failing to start. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// Top-level configuration. Unknown keys are ignored so a config written for a +/// newer beer still loads. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Config { + pub main: Main, + pub scrollback: Scrollback, +} + +/// `[main]`: fonts, window geometry, padding, and the terminal name. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Main { + /// Primary font family, resolved via fontconfig. + pub font: String, + /// Font size in pixels. + pub font_size: u32, + /// `TERM` value exported to the child shell. + pub term: String, + /// Initial size in character cells. + pub initial_cols: u16, + pub initial_rows: u16, + /// Characters that break a word for double-click selection. Empty/unset + /// keeps the built-in default. + pub word_delimiters: Option, +} + +impl Default for Main { + fn default() -> Self { + Self { + font: "monospace".to_string(), + font_size: 16, + term: "beer".to_string(), + initial_cols: 80, + initial_rows: 24, + word_delimiters: None, + } + } +} + +/// `[scrollback]`: history retention. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Scrollback { + /// Lines of history retained for the main screen. + pub lines: usize, +} + +impl Default for Scrollback { + fn default() -> Self { + Self { lines: 10_000 } + } +} + +impl Config { + /// Load configuration from `explicit` if given, else the default path. + /// Any read/parse failure logs a warning and returns defaults. + pub fn load(explicit: Option<&Path>) -> Self { + let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else { + return Self::default(); + }; + if !path.exists() { + return Self::default(); + } + match std::fs::read_to_string(&path) { + Ok(text) => match toml::from_str(&text) { + Ok(config) => { + tracing::info!("loaded config from {}", path.display()); + config + } + Err(err) => { + tracing::warn!("config {}: {err}; using defaults", path.display()); + Self::default() + } + }, + Err(err) => { + tracing::warn!("read config {}: {err}; using defaults", path.display()); + Self::default() + } + } + } +} + +/// `$XDG_CONFIG_HOME/beer/beer.toml`, or `~/.config/beer/beer.toml`. +fn default_path() -> Option { + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) { + return Some(PathBuf::from(dir).join("beer/beer.toml")); + } + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".config/beer/beer.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + let c = Config::default(); + assert_eq!(c.main.font, "monospace"); + assert_eq!(c.main.font_size, 16); + assert_eq!(c.scrollback.lines, 10_000); + } + + #[test] + fn parses_partial_config_and_ignores_unknown() { + let toml = r##" + [main] + font = "JetBrains Mono" + font-size = 14 + unknown-key = "tolerated" + + [colors] + background = "#000000" + "##; + let c: Config = toml::from_str(toml).unwrap(); + assert_eq!(c.main.font, "JetBrains Mono"); + assert_eq!(c.main.font_size, 14); + // Unset keys keep defaults; unknown tables/keys are ignored. + assert_eq!(c.main.term, "beer"); + assert_eq!(c.scrollback.lines, 10_000); + } +} diff --git a/src/grid.rs b/src/grid.rs index 0f7da92..b59eb61 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -226,6 +226,10 @@ pub struct Grid { focus_events: bool, /// Active incremental scrollback search, if any. search: Option, + /// Characters that break a word for double-click selection. + word_delimiters: String, + /// History retention cap for the main screen. + scrollback_cap: usize, } fn default_tabs(cols: usize) -> Vec { @@ -237,9 +241,9 @@ fn default_tabs(cols: usize) -> Vec { /// flags select as one unit. const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?"; -/// Whether `c` is part of a word (not whitespace, not a delimiter). -fn is_word(c: char) -> bool { - !c.is_whitespace() && !WORD_DELIMITERS.contains(c) +/// Whether `c` is part of a word (not whitespace, not in `delims`). +fn is_word(c: char, delims: &str) -> bool { + !c.is_whitespace() && !delims.contains(c) } impl Grid { @@ -276,9 +280,27 @@ impl Grid { mouse_encoding: MouseEncoding::X10, focus_events: false, search: None, + word_delimiters: WORD_DELIMITERS.to_string(), + scrollback_cap: SCROLLBACK_CAP, } } + /// Override the word-delimiter set; `None` keeps the built-in default. + pub fn set_word_delimiters(&mut self, delims: Option) { + if let Some(d) = delims { + self.word_delimiters = d; + } + } + + /// Set the scrollback retention cap, trimming history if it shrank. + pub fn set_scrollback_cap(&mut self, cap: usize) { + self.scrollback_cap = cap; + while self.scrollback.len() > cap { + self.scrollback.pop_front(); + } + self.view_offset = self.view_offset.min(self.scrollback.len()); + } + pub fn cols(&self) -> usize { self.cols } @@ -403,7 +425,7 @@ impl Grid { while live.len() < rows { live.push(Line::blank(cols)); } - while scrollback.len() > SCROLLBACK_CAP { + while scrollback.len() > self.scrollback_cap { scrollback.pop_front(); } @@ -722,7 +744,7 @@ impl Grid { self.scrollback.push_back(line); } let mut evicted = 0; - while self.scrollback.len() > SCROLLBACK_CAP { + while self.scrollback.len() > self.scrollback_cap { self.scrollback.pop_front(); evicted += 1; } @@ -995,17 +1017,18 @@ impl Grid { /// Select the word at an absolute point, breaking on whitespace and the /// default delimiter set. pub fn select_word(&mut self, row: usize, col: usize) { + let delims = &self.word_delimiters; let line = self.abs_row(row); - if col >= line.len() || !is_word(line[col].c) { + if col >= line.len() || !is_word(line[col].c, delims) { self.start_selection(row, col); return; } let mut lo = col; - while lo > 0 && is_word(line[lo - 1].c) { + while lo > 0 && is_word(line[lo - 1].c, delims) { lo -= 1; } let mut hi = col; - while hi + 1 < line.len() && is_word(line[hi + 1].c) { + while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) { hi += 1; } self.selection = Some((Point { row, col: lo }, Point { row, col: hi })); diff --git a/src/main.rs b/src/main.rs index d87afd6..5257273 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod config; mod font; mod grid; mod input; @@ -8,10 +9,13 @@ mod render; mod vt; mod wayland; +use std::path::PathBuf; use std::process::ExitCode; use pound::Parse; +use crate::config::Config; + /// A fast, software-rendered, Wayland-native terminal emulator. #[derive(Parse)] #[pound(name = "beer", version = "0.0.0")] @@ -19,6 +23,9 @@ struct Cli { /// Run as a daemon hosting multiple windows. #[pound(long)] server: bool, + /// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml). + #[pound(long)] + config: Option, } fn main() -> ExitCode { @@ -51,6 +58,7 @@ fn run(cli: Cli) -> anyhow::Result { anyhow::bail!("server mode is not implemented yet"); } + let config = Config::load(cli.config.as_deref()); tracing::info!("starting beer"); - wayland::run() + wayland::run(config) } diff --git a/src/pty.rs b/src/pty.rs index 122caf0..7bb559e 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -20,8 +20,8 @@ pub struct Pty { impl Pty { /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on - /// the slave end with `TERM=beer`. - pub fn spawn(cols: u16, rows: u16) -> anyhow::Result { + /// the slave end with `TERM=term`. + pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result { let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC) .context("open pty master")?; grantpt(&master).context("grantpt")?; @@ -45,7 +45,7 @@ impl Pty { let mut cmd = Command::new(&shell); cmd.arg0(&argv0) - .env("TERM", "beer") + .env("TERM", term) .env_remove("COLUMNS") .env_remove("LINES") .env_remove("TERMCAP") diff --git a/src/wayland.rs b/src/wayland.rs index 04cbf04..990efd8 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -17,6 +17,7 @@ use calloop::timer::{TimeoutAction, Timer}; use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken}; use calloop_wayland_source::WaylandSource; +use crate::config::Config; use crate::font::Fonts; use crate::grid::{Cell, CursorShape, Grid, MouseProtocol}; use crate::pty::Pty; @@ -161,15 +162,12 @@ fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap { } } -/// Default window size in pixels before the compositor suggests one. +/// Fallback window size in pixels if the configured geometry yields nothing. const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600; -/// Primary font family and pixel size, resolved via fontconfig. -const FONT_FAMILY: &str = "monospace"; -const FONT_SIZE_PX: u32 = 16; /// Run a single window until it is closed, returning the shell's exit code. -pub fn run() -> anyhow::Result { +pub fn run(config: Config) -> anyhow::Result { let conn = Connection::connect_to_env().context("connect to Wayland compositor")?; let (globals, event_queue) = registry_queue_init(&conn).context("initialize Wayland registry")?; @@ -197,12 +195,20 @@ pub fn run() -> anyhow::Result { // First commit with no buffer kicks off the initial configure. window.commit(); - let pool = SlotPool::new(DEFAULT_W as usize * DEFAULT_H as usize * 4, &shm) - .context("create shm slot pool")?; - - let fonts = Fonts::new(FONT_FAMILY, FONT_SIZE_PX).context("load font")?; + let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?; let renderer = Renderer::new(fonts); + // Start at the configured cell geometry; the compositor may override it on + // the first configure. + let m = renderer.metrics(); + let width = (u32::from(config.main.initial_cols) * m.width).max(1); + let height = (u32::from(config.main.initial_rows) * m.height).max(1); + let pool = SlotPool::new( + (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize, + &shm, + ) + .context("create shm slot pool")?; + let mut app = App { registry_state: RegistryState::new(&globals), output_state: OutputState::new(&globals, &qh), @@ -239,8 +245,9 @@ pub fn run() -> anyhow::Result { // startup SIGWINCH storm that makes it reprint its prompt. session: None, title: None, - width: DEFAULT_W, - height: DEFAULT_H, + config, + width, + height, needs_draw: false, frame_pending: false, frames: Vec::new(), @@ -352,6 +359,8 @@ struct App { session: Option, /// Last title applied to the toplevel, to avoid redundant requests. title: Option, + /// The active user configuration. + config: Config, width: u32, height: u32, /// The grid changed and the window wants repainting on the next frame. @@ -379,7 +388,7 @@ impl App { /// Spawn the shell at the current window size and start reading its output. fn spawn_session(&mut self) { let (cols, rows) = grid_size(self.renderer.metrics(), self.width, self.height); - let pty = match Pty::spawn(cols, rows) { + let pty = match Pty::spawn(cols, rows, &self.config.main.term) { Ok(pty) => pty, Err(err) => { tracing::error!("spawn shell: {err:#}"); @@ -428,10 +437,11 @@ impl App { return; } - self.session = Some(Session { - pty, - term: Term::new(cols as usize, rows as usize), - }); + let mut term = Term::new(cols as usize, rows as usize); + let grid = term.grid_mut(); + grid.set_word_delimiters(self.config.main.word_delimiters.clone()); + grid.set_scrollback_cap(self.config.scrollback.lines); + self.session = Some(Session { pty, term }); } /// Handle a key (initial press or repeat): Shift+PageUp/PageDown scroll the