diff --git a/CHANGELOG.md b/CHANGELOG.md index e5409e2..b7bf752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Vim-style edge bell: scrolling past the top or bottom now emits a + terminal BEL. The visible effect (audible beep, title-bar 🔔, dock + bounce) is up to the terminal emulator — e.g. Ghostty surfaces a 🔔 + in the window title via its default `bell-features`. Triggers on + `j`/`k`, `d`/`u`, `f`/`b`/`Space`/`PgUp`/`PgDn`; explicit jumps + (`gg`, `G`, `]`, `[`) stay silent. Disable with `--no-bell` or + `bell = false` in `~/.termdown/config.toml`. + ### Changed - Removed the 4-column outer margin that cat mode and TUI body rows shared, plus the additional 2-column inset on cat-mode table rows. diff --git a/README.md b/README.md index 94287d0..81699a4 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ termdown README.md | less # Use a specific theme instead of auto-detect termdown --theme light README.md +# Disable the edge-scroll bell (also configurable via `bell = false`) +termdown --no-bell README.md + # View help termdown --help termdown --version @@ -143,6 +146,12 @@ termdown reads configuration from `~/.termdown/config.toml`. # Auto-detection queries the terminal background color via OSC 11. theme = "auto" +# Vim-style edge bell: emit a terminal BEL when you scroll past the +# top/bottom of the document. The terminal emulator decides the visible +# effect (audible beep, title-bar 🔔, dock bounce, …). Default true. +# CLI: `--no-bell`. +bell = true + [font.heading] # English heading font (sans-serif recommended) latin = "Inter" diff --git a/README_CN.md b/README_CN.md index 4bef0bf..4e6a533 100644 --- a/README_CN.md +++ b/README_CN.md @@ -92,6 +92,9 @@ termdown README.md | less # 指定主题(不使用终端亮色、暗色主题自动检测) termdown --theme light README.md +# 关闭文档到顶/到底时的提示铃声(也可在配置中设 `bell = false`) +termdown --no-bell README.md + # 查看帮助 termdown --help termdown --version @@ -135,6 +138,10 @@ TUI 模式需要指定文件路径,不支持从 stdin 读取。 # 自动检测通过 OSC 11 查询终端背景色。 theme = "auto" +# 文档到顶/到底时向终端发一次 BEL。具体表现(响铃、标题栏 🔔、 +# dock 弹跳等)由终端模拟器决定。默认 true,命令行可用 `--no-bell` 关闭。 +bell = true + [font.heading] # 英文标题字体(推荐无衬线字体) latin = "Inter" diff --git a/TODO.md b/TODO.md index 22a4268..22724e7 100644 --- a/TODO.md +++ b/TODO.md @@ -7,3 +7,5 @@ - 在 `.github/workflows/ci.yml` 加一个 `msrv` job(`cargo check --all-targets` on pinned toolchain),防止以后 PR 悄悄抬高 MSRV - [ ] 测试 markdown metadata 支持 - [ ] 检测文件变化 +- [x] 文件到顶、末尾时,播放声音提示,增加喇叭icon +- [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示 diff --git a/src/config.rs b/src/config.rs index 03e3374..394d350 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,13 @@ pub struct Config { /// Theme override: "dark", "light", or "auto" (default). pub theme: Option, + + /// Vim-style edge bell: emit a terminal BEL when the user tries to scroll + /// past the top or bottom of the document. The terminal emulator decides + /// the visible effect (audible beep, title-bar 🔔, dock bounce, …) — see + /// e.g. Ghostty's `bell-features`. `None` means default (on). CLI + /// `--no-bell` overrides to `Some(false)`. + pub bell: Option, } #[derive(Deserialize, Default, Clone)] diff --git a/src/main.rs b/src/main.rs index 460cf64..8ee6337 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ fn main() { println!(" -V, --version Show version"); println!(" --theme Color theme (default: auto-detect)"); println!(" --cat Force non-interactive cat-style output"); + println!(" --no-bell Disable the edge-scroll terminal bell"); println!(); println!("By default, passing FILE opens it in the interactive TUI."); println!("Piped/redirected stdout and stdin input automatically use cat mode."); @@ -45,10 +46,14 @@ fn main() { } let cat_flag = args.iter().any(|a| a == "--cat"); + let no_bell_flag = args.iter().any(|a| a == "--no-bell"); check_terminal_support(); - let config = config::load(); + let mut config = config::load(); + if no_bell_flag { + config.bell = Some(false); + } // Parse --theme flag (takes precedence over config). let cli_theme = args diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0aef458..78b60e9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -357,6 +357,34 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu } } +/// Apply a scroll delta and ring the edge bell if the viewport didn't budge. +/// Detection lives here (not in `Viewport`) so the data layer stays free of +/// `App`/`Config`/audio coupling and `gg`/`G`/`]`/`[` — which bypass this +/// helper — silently stay non-belling, matching the chosen scope. +fn perform_scroll(app: &mut App, delta: i32) { + if delta == 0 { + return; + } + let before = app.active().viewport.top; + app.active_mut().viewport.scroll_by(delta); + if app.active().viewport.top == before { + ring_bell(&app.config); + } +} + +/// Emit a terminal BEL on blocked edge-scroll. No-op when the user has +/// disabled bells via config or `--no-bell`. Writes to stderr (which is +/// unbuffered, so no manual flush) so the byte does not enter the +/// alternate-screen buffer. The visible "🔔 in the title bar" effect is the +/// terminal emulator's own response to BEL (e.g. Ghostty's `bell-features` +/// defaults include `title`), not something termdown paints. +fn ring_bell(config: &Config) { + if !config.bell.unwrap_or(true) { + return; + } + let _ = io::stderr().write_all(b"\x07"); +} + fn handle_normal_key(app: &mut App, ev: &Event) -> io::Result<()> { if let Event::Key(key) = ev { if key.kind != event::KeyEventKind::Press { @@ -381,16 +409,14 @@ fn handle_normal_key(app: &mut App, ev: &Event) -> io::Result<()> { input::Action::Quit => { app.should_quit = true; } - input::Action::ScrollLines(d) => app.active_mut().viewport.scroll_by(d), + input::Action::ScrollLines(d) => perform_scroll(app, d), input::Action::ScrollHalfPage(s) => { - let active = app.active_mut(); - let delta = (active.viewport.height as i32 / 2) * s; - active.viewport.scroll_by(delta); + let delta = (app.active().viewport.height as i32 / 2) * s; + perform_scroll(app, delta); } input::Action::ScrollPage(s) => { - let active = app.active_mut(); - let delta = active.viewport.height as i32 * s; - active.viewport.scroll_by(delta); + let delta = app.active().viewport.height as i32 * s; + perform_scroll(app, delta); } input::Action::JumpStart => app.active_mut().viewport.top = 0, input::Action::JumpEnd => {