Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add editor module

RivoLink 198581d0 b12ee0ed

+343 -2
+13 -2
src/cli.rs
··· 12 12 pub(crate) print_version: bool, 13 13 pub(crate) file_arg: Option<String>, 14 14 pub(crate) theme: ThemePreset, 15 + pub(crate) editor: Option<String>, 15 16 } 16 17 17 18 pub(crate) fn usage_text() -> &'static str { 18 - "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [file.md]\n leaf [--watch] --picker\n leaf --update\n echo '# Hello' | leaf" 19 + "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [--editor <name>] [file.md]\n leaf [--watch] --picker\n leaf --update\n echo '# Hello' | leaf" 19 20 } 20 21 21 22 pub(crate) fn version_text() -> &'static str { ··· 64 65 options.theme = parse_theme_preset(name) 65 66 .ok_or_else(|| anyhow::anyhow!("Unknown theme: {name}"))?; 66 67 } 68 + "--editor" | "-e" => { 69 + let Some(value) = iter.next() else { 70 + anyhow::bail!("Missing value for --editor"); 71 + }; 72 + options.editor = Some(value.clone()); 73 + } 74 + _ if arg.starts_with("--editor=") => { 75 + options.editor = Some(arg["--editor=".len()..].to_string()); 76 + } 67 77 "--" => positional_only = true, 68 78 _ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"), 69 79 _ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()), ··· 76 86 || options.picker 77 87 || options.debug_input 78 88 || options.file_arg.is_some() 79 - || options.theme != ThemePreset::default(); 89 + || options.theme != ThemePreset::default() 90 + || options.editor.is_some(); 80 91 if has_non_update_flags { 81 92 anyhow::bail!("--update must be used on its own"); 82 93 }
+224
src/editor.rs
··· 1 + use std::path::Path; 2 + use std::process::Command; 3 + 4 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 5 + pub(crate) enum EditorKind { 6 + Terminal, 7 + Gui, 8 + } 9 + 10 + pub(crate) fn binary_name(editor_cmd: &str) -> &str { 11 + Path::new(editor_cmd.split_whitespace().next().unwrap_or(editor_cmd)) 12 + .file_name() 13 + .and_then(|n| n.to_str()) 14 + .unwrap_or(editor_cmd) 15 + } 16 + 17 + pub(crate) fn classify(editor_cmd: &str) -> EditorKind { 18 + match binary_name(editor_cmd) { 19 + "code" | "codium" | "subl" | "gedit" | "kate" | "mousepad" | "notepad.exe" 20 + | "notepad++" | "zed" => EditorKind::Gui, 21 + _ => EditorKind::Terminal, 22 + } 23 + } 24 + 25 + pub(crate) fn split_editor_cmd(cmd: &str) -> (&str, Vec<&str>) { 26 + let mut parts = cmd.split_whitespace(); 27 + let bin = parts.next().unwrap_or(cmd); 28 + let args: Vec<&str> = parts.collect(); 29 + (bin, args) 30 + } 31 + 32 + #[derive(Clone, Debug, PartialEq, Eq)] 33 + pub(crate) enum TerminalEmulator { 34 + Kitty, 35 + GnomeTerminal, 36 + MacTerminal(String), 37 + WindowsTerminal, 38 + Termux, 39 + Unknown, 40 + } 41 + 42 + pub(crate) fn detect_terminal_emulator() -> TerminalEmulator { 43 + if std::env::var("KITTY_PID").is_ok() { 44 + return TerminalEmulator::Kitty; 45 + } 46 + if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() { 47 + return TerminalEmulator::GnomeTerminal; 48 + } 49 + if std::env::var("WT_SESSION").is_ok() { 50 + return TerminalEmulator::WindowsTerminal; 51 + } 52 + if std::env::var("TERMUX_VERSION").is_ok() { 53 + return TerminalEmulator::Termux; 54 + } 55 + 56 + if let Ok(tp) = std::env::var("TERM_PROGRAM") { 57 + match tp.as_str() { 58 + "iTerm.app" | "iTerm2" | "Apple_Terminal" => { 59 + return TerminalEmulator::MacTerminal(tp); 60 + } 61 + _ => {} 62 + } 63 + } 64 + 65 + TerminalEmulator::Unknown 66 + } 67 + 68 + pub(crate) fn resolve_editor(cli_editor: Option<&str>) -> String { 69 + if let Some(e) = cli_editor { 70 + return e.to_string(); 71 + } 72 + if let Ok(e) = std::env::var("LEAF_EDITOR") { 73 + if !e.is_empty() { 74 + return e; 75 + } 76 + } 77 + if let Ok(e) = std::env::var("VISUAL") { 78 + if !e.is_empty() { 79 + return e; 80 + } 81 + } 82 + if let Ok(e) = std::env::var("EDITOR") { 83 + if !e.is_empty() { 84 + return e; 85 + } 86 + } 87 + platform_fallback_editor().to_string() 88 + } 89 + 90 + fn platform_fallback_editor() -> &'static str { 91 + if cfg!(target_os = "windows") { 92 + "notepad.exe" 93 + } else { 94 + "nano" 95 + } 96 + } 97 + 98 + pub(crate) fn try_new_tab_command( 99 + editor: &str, 100 + file: &Path, 101 + emulator: &TerminalEmulator, 102 + ) -> Option<Command> { 103 + let (bin, args) = split_editor_cmd(editor); 104 + let file_str = file.display().to_string(); 105 + 106 + match emulator { 107 + TerminalEmulator::Kitty => { 108 + let mut cmd = Command::new("kitty"); 109 + cmd.arg("@").arg("launch").arg("--type=tab").arg(bin); 110 + for a in &args { 111 + cmd.arg(a); 112 + } 113 + cmd.arg(&file_str); 114 + Some(cmd) 115 + } 116 + TerminalEmulator::GnomeTerminal => { 117 + let mut cmd = Command::new("gnome-terminal"); 118 + cmd.arg("--tab").arg("--").arg(bin); 119 + for a in &args { 120 + cmd.arg(a); 121 + } 122 + cmd.arg(&file_str); 123 + Some(cmd) 124 + } 125 + TerminalEmulator::MacTerminal(ref tp) => { 126 + let app_name = if tp == "Apple_Terminal" { 127 + "Terminal" 128 + } else { 129 + "iTerm" 130 + }; 131 + let escaped_editor = editor.replace('\\', "\\\\").replace('"', "\\\""); 132 + let escaped_file = file_str.replace('\\', "\\\\").replace('"', "\\\""); 133 + let script = if tp == "Apple_Terminal" { 134 + format!( 135 + "tell application \"{app_name}\" to do script \"{escaped_editor} {escaped_file}\"" 136 + ) 137 + } else { 138 + format!( 139 + "tell application \"{app_name}\" to tell current window to \ 140 + create tab with default profile command \"{escaped_editor} {escaped_file}\"" 141 + ) 142 + }; 143 + let mut cmd = Command::new("osascript"); 144 + cmd.arg("-e").arg(script); 145 + Some(cmd) 146 + } 147 + TerminalEmulator::WindowsTerminal => { 148 + let mut cmd = Command::new("wt"); 149 + cmd.arg("new-tab").arg(bin); 150 + for a in &args { 151 + cmd.arg(a); 152 + } 153 + cmd.arg(&file_str); 154 + Some(cmd) 155 + } 156 + TerminalEmulator::Termux => { 157 + let full_cmd = if args.is_empty() { 158 + format!("{bin} {file_str}") 159 + } else { 160 + format!("{bin} {} {file_str}", args.join(" ")) 161 + }; 162 + let mut cmd = Command::new("am"); 163 + cmd.args([ 164 + "startservice", 165 + "-n", 166 + "com.termux/.app.TermuxService", 167 + "-a", 168 + "com.termux.service_execute", 169 + "-e", 170 + "com.termux.execute.command", 171 + ]); 172 + cmd.arg(full_cmd); 173 + Some(cmd) 174 + } 175 + TerminalEmulator::Unknown => None, 176 + } 177 + } 178 + 179 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 180 + pub(crate) enum EditorResult { 181 + Opened, 182 + NeedsSameTerminal, 183 + } 184 + 185 + pub(crate) fn open_in_editor( 186 + editor: &str, 187 + file: &Path, 188 + kind: EditorKind, 189 + emulator: &TerminalEmulator, 190 + ) -> Result<EditorResult, String> { 191 + let (bin, args) = split_editor_cmd(editor); 192 + match kind { 193 + EditorKind::Gui => { 194 + Command::new(bin) 195 + .args(&args) 196 + .arg(file) 197 + .spawn() 198 + .map_err(|e| format!("{bin}: {e}"))?; 199 + Ok(EditorResult::Opened) 200 + } 201 + EditorKind::Terminal => { 202 + if let Some(mut cmd) = try_new_tab_command(editor, file, emulator) { 203 + if cmd.spawn().is_ok() { 204 + return Ok(EditorResult::Opened); 205 + } 206 + } 207 + Ok(EditorResult::NeedsSameTerminal) 208 + } 209 + } 210 + } 211 + 212 + pub(crate) fn check_termux_external_apps() -> bool { 213 + let home = std::env::var("HOME").unwrap_or_default(); 214 + let path = std::path::Path::new(&home).join(".termux/termux.properties"); 215 + match std::fs::read_to_string(path) { 216 + Ok(content) => content.lines().any(|line| { 217 + let trimmed = line.trim(); 218 + !trimmed.starts_with('#') 219 + && trimmed.contains("allow-external-apps") 220 + && trimmed.contains("true") 221 + }), 222 + Err(_) => false, 223 + } 224 + }
+6
src/main.rs
··· 5 5 6 6 mod app; 7 7 mod cli; 8 + #[allow(dead_code)] 9 + mod editor; 8 10 mod markdown; 9 11 mod render; 10 12 mod runtime; ··· 24 26 25 27 const MAX_STDIN_BYTES: usize = 8 * 1024 * 1024; 26 28 29 + #[cfg(test)] 30 + pub(crate) use editor::{binary_name, classify, resolve_editor, split_editor_cmd, EditorKind}; 27 31 #[cfg(test)] 28 32 pub(crate) use markdown::toc::{ 29 33 normalize_toc, should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry, ··· 83 87 debug_input, 84 88 file_arg, 85 89 theme, 90 + editor: cli_editor, 86 91 .. 87 92 } = options; 93 + let _ = cli_editor; 88 94 runtime::debug_log(debug_input, &format!("main start args={args:?}")); 89 95 set_theme_preset(theme); 90 96
+99
src/tests/editor.rs
··· 1 + use crate::*; 2 + 3 + #[test] 4 + fn binary_name_simple() { 5 + assert_eq!(binary_name("nano"), "nano"); 6 + } 7 + 8 + #[test] 9 + fn binary_name_full_path() { 10 + assert_eq!(binary_name("/usr/bin/code"), "code"); 11 + } 12 + 13 + #[test] 14 + fn binary_name_with_args() { 15 + assert_eq!(binary_name("emacs -nw"), "emacs"); 16 + } 17 + 18 + #[test] 19 + fn binary_name_path_with_args() { 20 + assert_eq!(binary_name("/usr/bin/emacs -nw"), "emacs"); 21 + } 22 + 23 + #[test] 24 + fn binary_name_windows() { 25 + assert_eq!(binary_name("notepad.exe"), "notepad.exe"); 26 + } 27 + 28 + #[test] 29 + fn classify_gui_editors() { 30 + assert_eq!(classify("code"), EditorKind::Gui); 31 + assert_eq!(classify("codium"), EditorKind::Gui); 32 + assert_eq!(classify("subl"), EditorKind::Gui); 33 + assert_eq!(classify("gedit"), EditorKind::Gui); 34 + assert_eq!(classify("kate"), EditorKind::Gui); 35 + assert_eq!(classify("mousepad"), EditorKind::Gui); 36 + assert_eq!(classify("notepad.exe"), EditorKind::Gui); 37 + assert_eq!(classify("notepad++"), EditorKind::Gui); 38 + assert_eq!(classify("zed"), EditorKind::Gui); 39 + } 40 + 41 + #[test] 42 + fn classify_terminal_editors() { 43 + assert_eq!(classify("nano"), EditorKind::Terminal); 44 + assert_eq!(classify("vim"), EditorKind::Terminal); 45 + assert_eq!(classify("nvim"), EditorKind::Terminal); 46 + assert_eq!(classify("micro"), EditorKind::Terminal); 47 + assert_eq!(classify("helix"), EditorKind::Terminal); 48 + assert_eq!(classify("emacs"), EditorKind::Terminal); 49 + } 50 + 51 + #[test] 52 + fn classify_unknown_defaults_to_terminal() { 53 + assert_eq!(classify("some-unknown-editor"), EditorKind::Terminal); 54 + } 55 + 56 + #[test] 57 + fn classify_full_path() { 58 + assert_eq!(classify("/usr/bin/code"), EditorKind::Gui); 59 + assert_eq!(classify("/usr/local/bin/nano"), EditorKind::Terminal); 60 + } 61 + 62 + #[test] 63 + fn classify_with_args() { 64 + assert_eq!(classify("emacs -nw"), EditorKind::Terminal); 65 + assert_eq!(classify("/usr/bin/code --new-window"), EditorKind::Gui); 66 + } 67 + 68 + #[test] 69 + fn split_editor_cmd_simple() { 70 + let (bin, args) = split_editor_cmd("nano"); 71 + assert_eq!(bin, "nano"); 72 + assert!(args.is_empty()); 73 + } 74 + 75 + #[test] 76 + fn split_editor_cmd_with_args() { 77 + let (bin, args) = split_editor_cmd("emacs -nw"); 78 + assert_eq!(bin, "emacs"); 79 + assert_eq!(args, vec!["-nw"]); 80 + } 81 + 82 + #[test] 83 + fn split_editor_cmd_path_with_args() { 84 + let (bin, args) = split_editor_cmd("/usr/bin/emacs -nw --no-splash"); 85 + assert_eq!(bin, "/usr/bin/emacs"); 86 + assert_eq!(args, vec!["-nw", "--no-splash"]); 87 + } 88 + 89 + #[test] 90 + fn resolve_editor_cli_takes_priority() { 91 + let result = resolve_editor(Some("vim")); 92 + assert_eq!(result, "vim"); 93 + } 94 + 95 + #[test] 96 + fn resolve_editor_fallback_is_not_empty() { 97 + let result = resolve_editor(None); 98 + assert!(!result.is_empty()); 99 + }
+1
src/tests/mod.rs
··· 12 12 }; 13 13 14 14 mod app; 15 + mod editor; 15 16 mod file_picker; 16 17 mod markdown; 17 18 mod render;