mod app;
mod cli;
mod clipboard;
mod commands;
mod config;
mod contents;
mod daily;
mod dates;
mod editor;
mod fold;
mod history;
mod images;
mod index;
mod keys;
mod links;
mod md;
mod mentions;
mod mermaid;
mod notes;
mod render;
mod search;
mod terminal;
mod theme;
#[cfg(test)]
mod testutil;
mod tree;
mod ui;
use anyhow::Result;
use crossterm::event::{
self, Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use std::time::Duration;
fn keyboard_flags() -> KeyboardEnhancementFlags {
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
}
static ENHANCED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn push_keyboard() -> bool {
if !matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) {
return false;
}
let ok = crossterm::execute!(
std::io::stdout(),
PushKeyboardEnhancementFlags(keyboard_flags())
)
.is_ok();
ENHANCED.store(ok, std::sync::atomic::Ordering::Relaxed);
ok
}
fn pop_keyboard() {
if ENHANCED.swap(false, std::sync::atomic::Ordering::Relaxed) {
let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let launch = match cli::parse(&args, probe) {
cli::Cli::Tui(l) => l,
cli::Cli::Help => {
print!("{}", cli::USAGE);
return Ok(());
}
cli::Cli::PrintPath => {
println!("{}", config::Config::load()?.notes_dir.display());
return Ok(());
}
cli::Cli::Add(text) => return add(text),
cli::Cli::Keys => return keys(),
cli::Cli::Error(msg) => {
eprintln!("catcher: {msg}\n\n{}", cli::USAGE);
std::process::exit(2);
}
};
tui(launch)
}
fn probe(s: &str) -> cli::PathKind {
let path = std::path::Path::new(s);
if path.is_dir() {
cli::PathKind::Dir
} else if path.is_file() {
cli::PathKind::File
} else {
cli::PathKind::Missing
}
}
fn add(text: Option<String>) -> Result<()> {
let text = match text {
Some(t) => t,
None => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
}
};
let text = text.trim();
if text.is_empty() {
anyhow::bail!("nothing to add");
}
let config = config::Config::load()?;
config.ensure_dirs()?;
let note = notes::create_with(&config.notes_dir, format!("{text}\n"))?;
println!("{}", note.path.display());
Ok(())
}
fn keys() -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
let supported = crossterm::terminal::supports_keyboard_enhancement();
let pushed = push_keyboard();
println!("keyboard enhancement: supported={supported:?} pushed={pushed}\r");
println!("press keys — esc to quit\r");
loop {
match event::read()? {
Event::Key(k) => {
println!(
"code={:?} modifiers={:?} kind={:?} state={:?}\r",
k.code, k.modifiers, k.kind, k.state
);
if k.code == event::KeyCode::Esc && k.kind != event::KeyEventKind::Release {
break;
}
}
other => println!("{other:?}\r"),
}
}
pop_keyboard();
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
fn push_title() {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(b"\x1b[22;0t");
let _ = out.flush();
}
fn pop_title() {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(b"\x1b[23;0t");
let _ = crossterm::execute!(out, crossterm::terminal::SetTitle(""));
}
fn detect_background() -> theme::Mode {
use std::io::{IsTerminal, Read, Write};
if !std::io::stdin().is_terminal() || std::env::var_os("TMUX").is_some() {
return from_colorfgbg().unwrap_or_default();
}
if crossterm::terminal::enable_raw_mode().is_err() {
return from_colorfgbg().unwrap_or_default();
}
let mut out = std::io::stdout();
let _ = out.write_all(b"\x1b]11;?\x1b\\\x1b[5n");
let _ = out.flush();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
while std::io::stdin().read(&mut byte).is_ok_and(|n| n == 1) {
buf.push(byte[0]);
if buf.ends_with(b"\x1b[0n") || buf.len() > 256 {
break;
}
}
let _ = tx.send(buf);
});
let reply = rx
.recv_timeout(Duration::from_millis(250))
.unwrap_or_default();
let _ = crossterm::terminal::disable_raw_mode();
parse_osc11(&reply)
.or_else(from_colorfgbg)
.unwrap_or_default()
}
fn parse_osc11(reply: &[u8]) -> Option<theme::Mode> {
let text = String::from_utf8_lossy(reply);
let rest = text.split("]11;").nth(1)?;
let rest = rest.strip_prefix("rgb:")?;
let mut chan = rest.split(['\x1b', '\x07']).next()?.split('/').map(|h| {
let h = h.trim();
let n = u32::from_str_radix(h, 16).ok()?;
let bits = 4 * h.len() as u32;
match bits {
4 => Some((n * 17) as u8),
8 | 12 | 16 => Some((n >> (bits - 8)) as u8),
_ => None,
}
});
let (r, g, b) = (chan.next()??, chan.next()??, chan.next()??);
Some(theme::mode_of_background(r, g, b))
}
fn from_colorfgbg() -> Option<theme::Mode> {
use theme::Mode;
let v = std::env::var("COLORFGBG").ok()?;
let bg: u8 = v.rsplit(';').next()?.trim().parse().ok()?;
Some(match bg {
7 | 9..=15 => Mode::Light,
_ => Mode::Dark,
})
}
fn tui(launch: cli::Launch) -> Result<()> {
push_title();
let detected = detect_background();
theme::set_detected(detected);
theme::set_follows_system(theme::system_mode() == Some(detected));
dates::init();
let mut app = app::App::launch(launch)?;
let mut terminal = ratatui::init();
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
push_keyboard();
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
pop_keyboard();
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
pop_title();
hook(info);
}));
app.images.probe();
let result = run(&mut terminal, &mut app);
pop_keyboard();
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
ratatui::restore();
pop_title();
result
}
fn run(terminal: &mut ratatui::DefaultTerminal, app: &mut app::App) -> Result<()> {
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
while !app.quit {
let _ = crossterm::execute!(std::io::stdout(), BeginSynchronizedUpdate);
let drawn = terminal.draw(|f| ui::draw(f, app));
let _ = crossterm::execute!(std::io::stdout(), EndSynchronizedUpdate);
drawn?;
if event::poll(Duration::from_millis(100))? {
handle(app, event::read()?);
while event::poll(Duration::ZERO)? {
handle(app, event::read()?);
}
}
app.tick();
}
app.save_now();
Ok(())
}
fn handle(app: &mut app::App, ev: Event) {
match ev {
Event::Key(k) if k.kind != event::KeyEventKind::Release => app.on_key(k),
Event::Mouse(m) => app.on_mouse(m),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use theme::Mode;
#[test]
fn osc11_reply_tells_light_from_dark() {
assert_eq!(
parse_osc11(b"\x1b]11;rgb:eeee/eeee/eeee\x1b\\\x1b[0n"),
Some(Mode::Light)
);
assert_eq!(
parse_osc11(b"\x1b]11;rgb:1414/1414/1414\x07\x1b[0n"),
Some(Mode::Dark)
);
assert_eq!(
parse_osc11(b"\x1b]11;rgb:ff/ff/ff\x1b\\"),
Some(Mode::Light)
);
assert_eq!(parse_osc11(b"\x1b[0n"), None);
assert_eq!(parse_osc11(b""), None);
}
}