forked from NotAShelf/beer
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:
parent
9df4e8fb8a
commit
ccc30d1bbd
7 changed files with 324 additions and 29 deletions
131
src/config.rs
Normal file
131
src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/grid.rs
39
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 }));
|
||||
|
|
|
|||
10
src/main.rs
10
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue