clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit ccc30d1bbdf9
tarballunverified7 files changed+324-29
@@ -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"@@ -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"@@ -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);+ }+}@@ -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 }));@@ -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) }@@ -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")@@ -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