brewery
notashelf /
ccc30d1bbdf943a485a4a478f0f80165eaec3571

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit ccc30d1bbdf9

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified7 files changed+324-29
config: load beer.toml and apply font, geometry, scrollback, word delimiters

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5008a74307d856f9df472776cb66c8b06a6a6964
diff --git a/Cargo.lock b/Cargo.lockindex 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",@@ -118,6 +121,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -230,6 +239,16 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -309,6 +328,18 @@ dependencies = [ ]  [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -427,6 +458,45 @@ dependencies = [ ]  [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -524,6 +594,45 @@ dependencies = [ ]  [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -763,6 +872,18 @@ dependencies = [ ]  [[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" source = "registry+https://github.com/rust-lang/crates.io-index"diff --git a/Cargo.toml b/Cargo.tomlindex 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.rsnew file mode 100644index 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<String>,+}++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<PathBuf> {+    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.rsindex 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<SearchState>,+    /// 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<bool> {@@ -237,9 +241,9 @@ fn default_tabs(cols: usize) -> Vec<bool> { /// 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<String>) {+        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.rsindex 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<PathBuf>, }  fn main() -> ExitCode {@@ -51,6 +58,7 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> {         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.rsindex 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<Self> {+    /// the slave end with `TERM=term`.+    pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {         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.rsindex 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<ExitCode> {+pub fn run(config: Config) -> anyhow::Result<ExitCode> {     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<ExitCode> {     // 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<ExitCode> {         // 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<Session>,     /// Last title applied to the toplevel, to avoid redundant requests.     title: Option<String>,+    /// 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