1#![forbid(unsafe_code)]
16#![warn(missing_docs)]
17#![warn(clippy::pedantic, clippy::nursery)]
18
19pub mod app;
20pub mod graph;
21pub mod keymap;
22pub mod markdown;
23pub mod search;
24pub mod snapshot;
25pub mod theme;
26pub mod ui;
27pub mod watch;
28pub mod worker;
29
30use app::{App, Command, Msg};
31use crossterm::event::{self, Event};
32use okf_core::Date;
33use std::io::Write as _;
34use std::path::PathBuf;
35use std::process::ExitCode;
36use std::sync::mpsc::channel;
37use std::time::{Duration, Instant};
38
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum Tab {
42 #[default]
44 Explorer,
45 Graph,
47 Trust,
49 Computations,
51}
52
53impl std::str::FromStr for Tab {
54 type Err = String;
55 fn from_str(s: &str) -> Result<Self, Self::Err> {
56 match s.trim().to_ascii_lowercase().as_str() {
57 "explorer" => Ok(Self::Explorer),
58 "graph" => Ok(Self::Graph),
59 "trust" => Ok(Self::Trust),
60 "computations" | "compute" => Ok(Self::Computations),
61 other => Err(format!(
62 "unknown tab {other:?} (expected explorer|graph|trust|computations)"
63 )),
64 }
65 }
66}
67
68#[derive(Clone, Debug)]
70pub struct StudioOptions {
71 pub root: PathBuf,
73 pub today: Option<Date>,
75 pub no_watch: bool,
77 pub initial_tab: Option<Tab>,
79 pub author: Option<String>,
82}
83
84impl Default for StudioOptions {
85 fn default() -> Self {
86 Self {
87 root: PathBuf::from("."),
88 today: None,
89 no_watch: false,
90 initial_tab: None,
91 author: None,
92 }
93 }
94}
95
96pub fn run(options: StudioOptions) -> std::io::Result<ExitCode> {
105 let (msg_tx, msg_rx) = channel::<Msg>();
106
107 let mut app = App::new(&options);
108 let _watcher = if options.no_watch {
109 None
110 } else {
111 Some(watch::spawn(
112 options.root.clone(),
113 Duration::from_millis(500),
114 msg_tx.clone(),
115 ))
116 };
117 let worker_tx = worker::spawn(
118 worker::WorkerConfig {
119 root: options.root,
120 today: options.today,
121 author: app.author.clone(),
122 },
123 msg_tx,
124 );
125
126 let default_hook = std::panic::take_hook();
129 std::panic::set_hook(Box::new(move |info| {
130 ratatui::restore();
131 default_hook(info);
132 }));
133 let mut terminal = ratatui::try_init()?;
134
135 let tick = Duration::from_millis(250);
136 let mut last_tick = Instant::now();
137 let mut dirty = true;
140 let result = loop {
141 while let Ok(msg) = msg_rx.try_recv() {
143 app.update(msg);
144 dirty = true;
145 }
146 if last_tick.elapsed() >= tick {
147 last_tick = Instant::now();
148 app.update(Msg::Tick);
149 dirty = true;
150 }
151
152 for command in app.pending_commands.drain(..) {
154 let _ = worker_tx.send(command);
155 }
156 if let Some(path) = app.editor_request.take() {
157 open_in_editor(&mut terminal, &path)?;
158 app.update(Msg::FilesChanged);
159 dirty = true;
160 }
161 if let Some(text) = app.copy_request.take() {
162 osc52_copy(&text);
163 }
164 if app.should_quit {
165 break Ok(ExitCode::SUCCESS);
166 }
167
168 if dirty {
169 terminal.draw(|frame| ui::shell::draw(frame, &app))?;
170 dirty = false;
171 }
172
173 if event::poll(Duration::from_millis(100))? {
176 match event::read()? {
177 Event::Key(key) if key.is_press() => app.update(Msg::Key(key)),
178 Event::Mouse(mouse) => app.update(Msg::Mouse(mouse)),
179 Event::Resize(_, _) => app.update(Msg::Resize),
180 _ => {}
181 }
182 dirty = true;
183 }
184 };
185 let _ = worker_tx.send(Command::Shutdown);
186 ratatui::restore();
187 result
188}
189
190fn open_in_editor(
193 terminal: &mut ratatui::DefaultTerminal,
194 path: &std::path::Path,
195) -> std::io::Result<()> {
196 let editor = std::env::var("VISUAL")
197 .or_else(|_| std::env::var("EDITOR"))
198 .unwrap_or_else(|_| "vi".to_string());
199 ratatui::restore();
200 let status = std::process::Command::new(&editor).arg(path).status();
201 crossterm::terminal::enable_raw_mode()?;
203 crossterm::execute!(std::io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
204 terminal.clear()?;
205 if let Err(e) = status {
206 return Err(std::io::Error::other(format!(
208 "could not launch editor {editor:?}: {e}"
209 )));
210 }
211 Ok(())
212}
213
214fn osc52_copy(text: &str) {
217 let encoded = base64(text.as_bytes());
218 let mut stdout = std::io::stdout();
219 let _ = write!(stdout, "\x1b]52;c;{encoded}\x07");
220 let _ = stdout.flush();
221}
222
223fn base64(input: &[u8]) -> String {
225 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
227 for chunk in input.chunks(3) {
228 let b = [
229 chunk[0],
230 chunk.get(1).copied().unwrap_or(0),
231 chunk.get(2).copied().unwrap_or(0),
232 ];
233 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
234 out.push(ALPHABET[(n >> 18) as usize & 63] as char);
235 out.push(ALPHABET[(n >> 12) as usize & 63] as char);
236 out.push(if chunk.len() > 1 {
237 ALPHABET[(n >> 6) as usize & 63] as char
238 } else {
239 '='
240 });
241 out.push(if chunk.len() > 2 {
242 ALPHABET[n as usize & 63] as char
243 } else {
244 '='
245 });
246 }
247 out
248}