config: load beer.toml and apply font, geometry, scrollback, word delimiters

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5008a74307d856f9df472776cb66c8b06a6a6964
This commit is contained in:
raf 2026-06-25 10:23:10 +03:00
commit ccc30d1bbd
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
7 changed files with 324 additions and 29 deletions

131
src/config.rs Normal file
View file

@ -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);
}
}

View file

@ -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 }));

View file

@ -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)
}

View file

@ -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")

View file

@ -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