Skip to content

Commit 2da3837

Browse files
committed
feat: markdown viewer
1 parent 8deebd2 commit 2da3837

File tree

14 files changed

+1108
-138
lines changed

14 files changed

+1108
-138
lines changed

Cargo.lock

Lines changed: 429 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ maud = "0.27.0"
2525
markdown = "1.0.0"
2626
xshell = "0.2.7"
2727
dirs = "6.0.0"
28+
tui-markdown = "0.3.7"

src/docs.rs

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/main.rs

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
mod chat;
2-
mod docs;
2+
mod markdown;
33
mod meetups;
44
mod remote;
55

66
use argh::FromArgs;
7-
use inquire::{InquireError, Select};
87

98
#[derive(FromArgs, PartialEq, Debug)]
109
/// The Rust-Basel cli.
@@ -18,52 +17,20 @@ struct Basel {
1817
enum Command {
1918
Job(JobCommand),
2019
Mtp(MeetupCommand),
21-
Doc(DocCommand),
22-
Init(InitCommand),
2320
Website(WebsiteCommand),
2421
Chat(ChatCommand),
2522
}
2623

27-
impl From<String> for Command {
28-
fn from(s: String) -> Self {
29-
match s.as_str() {
30-
"job" => Command::Job(JobCommand {}),
31-
"meetup" => Command::Mtp(MeetupCommand {}),
32-
"doc" => Command::Doc(DocCommand {}),
33-
_ => panic!("Unknown command"),
34-
}
35-
}
36-
}
37-
38-
fn command_as_vec() -> Vec<&'static str> {
39-
vec!["job", "meetup", "doc"]
40-
}
41-
4224
#[derive(FromArgs, PartialEq, Debug)]
4325
/// Find the featured jobs.
4426
#[argh(subcommand, name = "job")]
4527
struct JobCommand {}
4628

47-
#[derive(FromArgs, PartialEq, Debug)]
48-
/// Go to the documentation for this application.
49-
#[argh(subcommand, name = "doc")]
50-
struct DocCommand {}
51-
5229
#[derive(FromArgs, PartialEq, Debug)]
5330
/// Explore meetups
5431
#[argh(subcommand, name = "meetup")]
5532
struct MeetupCommand {}
5633

57-
#[derive(FromArgs, PartialEq, Debug)]
58-
/// Inits the repository
59-
#[argh(subcommand, name = "init")]
60-
struct InitCommand {}
61-
62-
#[derive(FromArgs, PartialEq, Debug)]
63-
/// Builds the website
64-
#[argh(subcommand, name = "website")]
65-
struct WebsiteCommand {}
66-
6734
#[derive(FromArgs, PartialEq, Debug)]
6835
/// Runs the chat
6936
#[argh(subcommand, name = "chat")]
@@ -77,7 +44,7 @@ fn main() {
7744
let basel: Basel = argh::from_env();
7845
match basel.commands {
7946
Some(c) => match_command(c),
80-
None => inquire(),
47+
None => println!("Use basel --help for usage"),
8148
}
8249
}
8350

@@ -87,22 +54,13 @@ fn match_command(c: Command) {
8754
println!("Help needed to implement a nice job ui and systemy");
8855
}
8956
Command::Mtp(_mtp) => meetups::meetup_ui(),
90-
91-
Command::Doc(_doc) => docs::docs_ui(),
92-
Command::Init(_init_command) => meetups::init::init(),
9357
Command::Website(_website_command) => meetups::website::build(),
9458

9559
Command::Chat(_chat_command) => chat::connect(_chat_command.code),
9660
}
9761
}
9862

99-
fn inquire() {
100-
let ans: Result<&str, InquireError> = Select::new("commands", command_as_vec()).prompt();
101-
102-
let Ok(ans) = ans else {
103-
println!("No selection made");
104-
return;
105-
};
106-
107-
match_command(ans.to_owned().into());
108-
}
63+
#[derive(FromArgs, PartialEq, Debug)]
64+
/// Builds the website [internal]
65+
#[argh(subcommand, name = "website")]
66+
struct WebsiteCommand {}

src/markdown/app.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
use std::io;
2+
use std::time::{Duration, Instant};
3+
4+
use ratatui::{DefaultTerminal, text::Text};
5+
6+
use crate::markdown::{
7+
commands::Command,
8+
events::{self, Action, CommandAction},
9+
navigation::Navigation,
10+
search::Search,
11+
ui::{self, Mode},
12+
};
13+
14+
/// Main application state and logic
15+
pub struct App {
16+
text: Text<'static>,
17+
original_text: Text<'static>,
18+
navigation: Navigation,
19+
search: Search,
20+
mode: Mode,
21+
command_input: String,
22+
status_message: Option<String>,
23+
highlight_time: Option<Instant>,
24+
exit: bool,
25+
}
26+
27+
impl App {
28+
pub fn new(text: Text<'static>) -> Self {
29+
Self {
30+
original_text: text.clone(),
31+
text,
32+
navigation: Navigation::new(),
33+
search: Search::new(),
34+
mode: Mode::Normal,
35+
command_input: String::new(),
36+
status_message: None,
37+
highlight_time: None,
38+
exit: false,
39+
}
40+
}
41+
42+
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
43+
while !self.exit {
44+
terminal.draw(|frame| {
45+
ui::draw(
46+
frame,
47+
&self.text,
48+
self.navigation.scroll,
49+
&self.mode,
50+
&self.command_input,
51+
&self.status_message,
52+
)
53+
})?;
54+
55+
// Check if highlight time has expired
56+
if let Some(highlight_time) = self.highlight_time
57+
&& highlight_time.elapsed() >= Duration::from_secs(2)
58+
{
59+
self.clear_highlights();
60+
self.highlight_time = None;
61+
}
62+
63+
self.handle_events()?;
64+
}
65+
Ok(())
66+
}
67+
68+
fn handle_events(&mut self) -> io::Result<()> {
69+
// Use a timeout to check for highlight expiration
70+
if let Some(key_event) = events::poll_events(Duration::from_millis(100))? {
71+
match self.mode {
72+
Mode::Normal => self.handle_normal_mode(key_event),
73+
Mode::Command => self.handle_command_mode(key_event),
74+
}
75+
}
76+
Ok(())
77+
}
78+
79+
fn handle_normal_mode(&mut self, key_event: crossterm::event::KeyEvent) {
80+
match events::handle_normal_mode(key_event) {
81+
Action::Quit => self.exit = true,
82+
Action::EnterCommandMode => {
83+
self.mode = Mode::Command;
84+
self.command_input.clear();
85+
self.status_message = None;
86+
}
87+
Action::ScrollDown(amount) => self.navigation.scroll_down(amount, &self.text),
88+
Action::ScrollUp(amount) => self.navigation.scroll_up(amount),
89+
Action::NextMatch => self.next_search_result(),
90+
Action::PrevMatch => self.prev_search_result(),
91+
Action::None => {}
92+
}
93+
}
94+
95+
fn handle_command_mode(&mut self, key_event: crossterm::event::KeyEvent) {
96+
match events::handle_command_mode(key_event) {
97+
CommandAction::Exit => {
98+
self.mode = Mode::Normal;
99+
self.command_input.clear();
100+
}
101+
CommandAction::Execute => {
102+
self.execute_command();
103+
self.mode = Mode::Normal;
104+
}
105+
CommandAction::AppendChar(c) => {
106+
self.command_input.push(c);
107+
}
108+
CommandAction::Backspace => {
109+
self.command_input.pop();
110+
}
111+
CommandAction::None => {}
112+
}
113+
}
114+
115+
fn execute_command(&mut self) {
116+
let command = Command::parse(&self.command_input);
117+
118+
match command {
119+
Command::Quit => {
120+
self.exit = true;
121+
}
122+
Command::Search(query) => {
123+
self.perform_search(&query);
124+
}
125+
Command::Jump(line_num) => match self.navigation.jump_to_line(line_num, &self.text) {
126+
Ok(()) => {
127+
self.status_message = Some(format!("Jumped to line {}", line_num));
128+
}
129+
Err(msg) => {
130+
self.status_message = Some(msg);
131+
}
132+
},
133+
Command::Help => {
134+
self.status_message = Some(Command::help_text());
135+
}
136+
Command::Unknown(msg) => {
137+
self.status_message = Some(msg);
138+
}
139+
}
140+
141+
self.command_input.clear();
142+
}
143+
144+
fn perform_search(&mut self, query: &str) {
145+
if query.is_empty() {
146+
self.status_message = Some("Empty search query".to_string());
147+
return;
148+
}
149+
150+
let count = self.search.perform_search(query, &self.original_text);
151+
152+
if count > 0 {
153+
// Apply highlighting and start timer
154+
self.text = self.search.highlight_matches(&self.original_text);
155+
self.highlight_time = Some(Instant::now());
156+
157+
// Jump to first result
158+
if let Some(line) = self.search.current_match() {
159+
self.navigation.scroll_to_line(line as u16);
160+
}
161+
162+
self.status_message = Some(format!("Found {} matches for '{}'", count, query));
163+
} else {
164+
self.status_message = Some(format!("No matches found for '{}'", query));
165+
}
166+
}
167+
168+
fn next_search_result(&mut self) {
169+
if !self.search.has_results() {
170+
self.status_message = Some("No search results. Use :s <query> to search".to_string());
171+
return;
172+
}
173+
174+
if let Some(line) = self.search.next_match() {
175+
// Re-highlight to update the current match indicator and reset timer
176+
self.text = self.search.highlight_matches(&self.original_text);
177+
self.highlight_time = Some(Instant::now());
178+
179+
self.navigation.scroll_to_line(line as u16);
180+
181+
self.status_message = Some(format!(
182+
"Match {}/{}",
183+
self.search.current_index + 1,
184+
self.search.results.len()
185+
));
186+
}
187+
}
188+
189+
fn prev_search_result(&mut self) {
190+
if !self.search.has_results() {
191+
self.status_message = Some("No search results. Use :s <query> to search".to_string());
192+
return;
193+
}
194+
195+
if let Some(line) = self.search.prev_match() {
196+
// Re-highlight to update the current match indicator and reset timer
197+
self.text = self.search.highlight_matches(&self.original_text);
198+
self.highlight_time = Some(Instant::now());
199+
200+
self.navigation.scroll_to_line(line as u16);
201+
202+
self.status_message = Some(format!(
203+
"Match {}/{}",
204+
self.search.current_index + 1,
205+
self.search.results.len()
206+
));
207+
}
208+
}
209+
210+
fn clear_highlights(&mut self) {
211+
// Reset to original text without highlights
212+
self.text = self.original_text.clone();
213+
self.search.clear();
214+
}
215+
}

src/markdown/commands.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/// Command execution and parsing for the markdown viewer
2+
pub enum Command {
3+
Quit,
4+
Search(String),
5+
Jump(usize),
6+
Help,
7+
Unknown(String),
8+
}
9+
10+
impl Command {
11+
pub fn parse(input: &str) -> Self {
12+
let parts: Vec<&str> = input.split_whitespace().collect();
13+
14+
if parts.is_empty() {
15+
return Command::Unknown(String::new());
16+
}
17+
18+
match parts[0] {
19+
"q" | "quit" => Command::Quit,
20+
"s" | "search" => {
21+
if parts.len() > 1 {
22+
let query = parts[1..].join(" ");
23+
Command::Search(query)
24+
} else {
25+
Command::Unknown("Usage: :s <query>".to_string())
26+
}
27+
}
28+
"jump" => {
29+
if parts.len() > 1 {
30+
if let Ok(line_num) = parts[1].parse::<usize>() {
31+
Command::Jump(line_num)
32+
} else {
33+
Command::Unknown("Usage: :jump <line_number>".to_string())
34+
}
35+
} else {
36+
Command::Unknown("Usage: :jump <line_number>".to_string())
37+
}
38+
}
39+
"help" => Command::Help,
40+
_ => Command::Unknown(format!(
41+
"Unknown command: {}. Type :help for help",
42+
parts[0]
43+
)),
44+
}
45+
}
46+
47+
pub fn help_text() -> String {
48+
"Commands: :q (quit) | :s <query> (search) | :jump <line> | :help | j/k (scroll) | n/N (next/prev match)".to_string()
49+
}
50+
}

0 commit comments

Comments
 (0)