chiark / gitweb /
Start of the main TUI code.
authorSimon Tatham <anakin@pobox.com>
Wed, 27 Dec 2023 17:17:58 +0000 (17:17 +0000)
committerSimon Tatham <anakin@pobox.com>
Wed, 27 Dec 2023 17:38:20 +0000 (17:38 +0000)
So far, I've made a struct that encapsulates the Ratatui state and
some subthread comms machinery, i.e. does the physical work. Then
there's a sub-struct to contain the logical UI state (so it can be
borrowed mutably without having to re-borrow the main struct), which
gets to draw things on the screen, update itself in response to
keypresses, and tell the physical side what to do after the
keypress (options currently limited to 'beep', 'exit', or 'just redraw
and go round the loop again').

Currently the UI itself is just "hello, world". I think menus are
next.

src/lib.rs
src/main.rs
src/tui.rs [new file with mode: 0644]

index f87dbfef98548155debeb675941f30751666c034..f29e146084bf8b5d3f4cb2a31def1fdf34f820d0 100644 (file)
@@ -9,3 +9,4 @@ pub mod coloured_string;
 pub mod text;
 pub mod client;
 pub mod activity_stack;
+pub mod tui;
index 4bc57be866d554309d61790fed7d95e538b2de3a..3c2e5cb5138130078772645e27b853f934bcc961 100644 (file)
@@ -1,23 +1,8 @@
 use std::io::Write;
-use unicode_width::UnicodeWidthStr;
+use std::io::stderr;
+use std::process::ExitCode;
 
-use mastodonochrome::client::Client;
-use mastodonochrome::coloured_string::ColouredStringSlice;
-use mastodonochrome::text::{parse_html, Paragraph, TextFragment};
-
-use crossterm::{
-    event::{self, Event, KeyCode, KeyEventKind},
-    terminal::{
-        disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
-        LeaveAlternateScreen,
-    },
-    ExecutableCommand,
-};
-use ratatui::{
-    prelude::{Buffer, CrosstermBackend, Terminal},
-    style::{Style, Color, Modifier},
-};
-use std::io::stdout;
+use mastodonochrome::tui::Tui;
 
 /*
 #[allow(unused)]
@@ -64,103 +49,7 @@ fn streaming() -> Result<(), mastodonochrome::OurError> {
 }
 */
 
-fn ratatui_style_from_colour(colour: char) -> Style {
-    match colour {
-        // default
-        ' ' => Style::default(),
-
-        // message separator line, other than the date
-        'S' => Style::default().fg(Color::White).bg(Color::Blue)
-            .add_modifier(Modifier::REVERSED | Modifier::BOLD),
-
-        // date on a message separator line
-        'D' => Style::default().fg(Color::White).bg(Color::Blue)
-            .add_modifier(Modifier::REVERSED),
-
-        // username in a From line
-        'F' => Style::default().fg(Color::Green)
-            .add_modifier(Modifier::BOLD),
-
-        // username in other headers like Via
-        'f' => Style::default().fg(Color::Green),
-
-        // <code> tags
-        'c' => Style::default().fg(Color::Yellow),
-
-        // #hashtags
-        '#' => Style::default().fg(Color::Cyan),
-
-        // @mentions of a user
-        '@' => Style::default().fg(Color::Green),
-
-        // <em> tags
-        '_' => Style::default().add_modifier(Modifier::UNDERLINED),
-
-        // <strong> tags
-        's' => Style::default().add_modifier(Modifier::BOLD),
-
-        // URL
-        'u' => Style::default().fg(Color::Blue)
-            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
-
-        // media URL
-        'M' => Style::default().fg(Color::Magenta)
-            .add_modifier(Modifier::BOLD),
-
-        // media description
-        'm' => Style::default().fg(Color::Magenta),
-
-        // Mastodonochrome logo in file headers
-        'J' => Style::default().fg(Color::Blue).bg(Color::White)
-            .add_modifier(Modifier::REVERSED | Modifier::BOLD),
-
-        // ~~~~~ underline in file headers
-        '~' => Style::default().fg(Color::Blue),
-
-        // actual header text in file headers
-        'H' => Style::default().fg(Color::Cyan),
-
-        // keypress / keypath names in file headers
-        'K' => Style::default().fg(Color::Cyan)
-            .add_modifier(Modifier::BOLD),
-
-        // keypresses in file status lines
-        'k' => Style::default().add_modifier(Modifier::BOLD),
-
-        // separator line between editor header and content
-        '-' => Style::default().fg(Color::Cyan).bg(Color::Black)
-            .add_modifier(Modifier::REVERSED),
-
-        // something really boring, like 'none' in place of data
-        '0' => Style::default().fg(Color::Blue),
-
-        // red nastinesses like blocking/muting in Examine User
-        'r' => Style::default().fg(Color::Red),
-
-        // # reverse-video > indicating a truncated too-long line
-        '>' => Style::default().add_modifier(Modifier::REVERSED),
-
-        // # error report, or by default any unrecognised colour character
-        '!' | _ => Style::default().fg(Color::Red).bg(Color::Yellow).
-            add_modifier(Modifier::REVERSED | Modifier::BOLD)
-    }
-}
-
-fn ratatui_set_string(buf: &mut Buffer, y: usize, x: usize,
-                      text: &ColouredStringSlice<'_>) {
-    let mut x = x;
-    if let Ok(y) = y.try_into() {
-        for (frag, colour) in text.frags() {
-            if let Ok(x) = x.try_into() {
-                buf.set_string(x, y, frag, ratatui_style_from_colour(colour));
-            } else {
-                break;
-            }
-            x += UnicodeWidthStr::width(frag);
-        }
-    }
-}
-
+/*
 #[allow(unused)]
 fn tui(paras: Vec<Paragraph>) -> std::io::Result<()> {
     stdout().execute(EnterAlternateScreen)?;
@@ -168,16 +57,6 @@ fn tui(paras: Vec<Paragraph>) -> std::io::Result<()> {
     let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
     terminal.clear()?;
 
-    let (sender, receiver) = std::sync::mpsc::sync_channel(1);
-
-    let _term_input_thread = std::thread::spawn(move || {
-        while let Ok(ev) = event::read() {
-            if let Err(_) = sender.send(ev) {
-                break;
-            }
-        }
-    });
-
     loop {
         terminal.draw(|frame| {
                 let area = frame.size();
@@ -217,11 +96,22 @@ fn tui(paras: Vec<Paragraph>) -> std::io::Result<()> {
     disable_raw_mode()?;
     Ok(())
 }
+*/
 
-fn main() {
+fn main() -> ExitCode {
+    /*
     let mut client = Client::new().unwrap();
     if let Some(st) = client.status_by_id("111602135142646031") {
         let paras = parse_html(&st.content);
         tui(paras).unwrap();
     }
+    */
+
+    match Tui::run() {
+        Ok(_) => ExitCode::from(0),
+        Err(e) => {
+            let _ = writeln!(&mut stderr(), "mastodonochrome: error: {}", e);
+            ExitCode::from(1)
+        }
+    }
 }
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644 (file)
index 0000000..8878792
--- /dev/null
@@ -0,0 +1,232 @@
+use crossterm::{
+    event::{self, Event, KeyEvent, KeyCode, KeyEventKind},
+    terminal::{
+        disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
+        LeaveAlternateScreen,
+    },
+    ExecutableCommand,
+};
+use ratatui::{
+    prelude::{Buffer, CrosstermBackend, Rect, Terminal},
+    style::{Style, Color, Modifier},
+};
+use std::io::{Stdout, Write, stdout};
+use unicode_width::UnicodeWidthStr;
+
+use super::activity_stack::*;
+use super::client::Client;
+use super::coloured_string::{ColouredString, ColouredStringSlice};
+use super::text::{parse_html, Paragraph, TextFragment};
+
+fn ratatui_style_from_colour(colour: char) -> Style {
+    match colour {
+        // default
+        ' ' => Style::default(),
+
+        // message separator line, other than the date
+        'S' => Style::default().fg(Color::White).bg(Color::Blue)
+            .add_modifier(Modifier::REVERSED | Modifier::BOLD),
+
+        // date on a message separator line
+        'D' => Style::default().fg(Color::White).bg(Color::Blue)
+            .add_modifier(Modifier::REVERSED),
+
+        // username in a From line
+        'F' => Style::default().fg(Color::Green)
+            .add_modifier(Modifier::BOLD),
+
+        // username in other headers like Via
+        'f' => Style::default().fg(Color::Green),
+
+        // <code> tags
+        'c' => Style::default().fg(Color::Yellow),
+
+        // #hashtags
+        '#' => Style::default().fg(Color::Cyan),
+
+        // @mentions of a user
+        '@' => Style::default().fg(Color::Green),
+
+        // <em> tags
+        '_' => Style::default().add_modifier(Modifier::UNDERLINED),
+
+        // <strong> tags
+        's' => Style::default().add_modifier(Modifier::BOLD),
+
+        // URL
+        'u' => Style::default().fg(Color::Blue)
+            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
+
+        // media URL
+        'M' => Style::default().fg(Color::Magenta)
+            .add_modifier(Modifier::BOLD),
+
+        // media description
+        'm' => Style::default().fg(Color::Magenta),
+
+        // Mastodonochrome logo in file headers
+        'J' => Style::default().fg(Color::Blue).bg(Color::White)
+            .add_modifier(Modifier::REVERSED | Modifier::BOLD),
+
+        // ~~~~~ underline in file headers
+        '~' => Style::default().fg(Color::Blue),
+
+        // actual header text in file headers
+        'H' => Style::default().fg(Color::Cyan),
+
+        // keypress / keypath names in file headers
+        'K' => Style::default().fg(Color::Cyan)
+            .add_modifier(Modifier::BOLD),
+
+        // keypresses in file status lines
+        'k' => Style::default().add_modifier(Modifier::BOLD),
+
+        // separator line between editor header and content
+        '-' => Style::default().fg(Color::Cyan).bg(Color::Black)
+            .add_modifier(Modifier::REVERSED),
+
+        // something really boring, like 'none' in place of data
+        '0' => Style::default().fg(Color::Blue),
+
+        // red nastinesses like blocking/muting in Examine User
+        'r' => Style::default().fg(Color::Red),
+
+        // # reverse-video > indicating a truncated too-long line
+        '>' => Style::default().add_modifier(Modifier::REVERSED),
+
+        // # error report, or by default any unrecognised colour character
+        '!' | _ => Style::default().fg(Color::Red).bg(Color::Yellow).
+            add_modifier(Modifier::REVERSED | Modifier::BOLD)
+    }
+}
+
+fn ratatui_set_string(buf: &mut Buffer, y: usize, x: usize,
+                      text: &ColouredStringSlice<'_>) {
+    let mut x = x;
+    if let Ok(y) = y.try_into() {
+        for (frag, colour) in text.frags() {
+            if let Ok(x) = x.try_into() {
+                buf.set_string(x, y, frag, ratatui_style_from_colour(colour));
+            } else {
+                break;
+            }
+            x += UnicodeWidthStr::width(frag);
+        }
+    }
+}
+
+enum SubthreadEvent {
+    TermEv(Event),
+}
+
+enum HandleEventResult {
+    Nothing,
+    Beep,
+    Exit,
+}
+
+pub struct Tui {
+    terminal: Terminal<CrosstermBackend<Stdout>>,
+    subthread_sender: std::sync::mpsc::SyncSender<SubthreadEvent>,
+    subthread_receiver: std::sync::mpsc::Receiver<SubthreadEvent>,
+    state: TuiLogicalState,
+}
+
+impl Tui {
+    pub fn run() -> std::io::Result<()> {
+        stdout().execute(EnterAlternateScreen)?;
+        enable_raw_mode()?;
+        let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
+        terminal.clear()?;
+
+        let (sender, receiver) = std::sync::mpsc::sync_channel(1);
+
+        let input_sender = sender.clone();
+
+        // I don't think we have any need to join subthreads like this
+        let _joinhandle = std::thread::spawn(move || {
+            while let Ok(ev) = event::read() {
+                if let Err(_) = input_sender.send(SubthreadEvent::TermEv(ev)) {
+                    break;
+                }
+            }
+        });
+
+        let mut tui = Tui {
+            terminal: terminal,
+            subthread_sender: sender,
+            subthread_receiver: receiver,
+            state: TuiLogicalState::new(),
+        };
+        let result = tui.main_loop();
+
+        disable_raw_mode()?;
+        stdout().execute(LeaveAlternateScreen)?;
+
+        result
+    }
+
+    fn main_loop(&mut self) -> std::io::Result<()> {
+        loop {
+            self.terminal.draw(|frame| {
+                let area = frame.size();
+                let buf = frame.buffer_mut();
+                self.state.draw_frame(area, buf);
+            })?;
+
+            match self.subthread_receiver.recv() {
+                e @ Err(_) => break Ok(()), // FIXME FIXME FIXME: not ok!
+                Ok(SubthreadEvent::TermEv(ev)) => {
+                    match ev {
+                        Event::Key(key) => {
+                            if key.kind == KeyEventKind::Press {
+                                match self.state.handle_keypress(key) {
+                                    HandleEventResult::Beep => Self::beep()?,
+
+                                    // FIXME: errors?
+                                    HandleEventResult::Exit => break Ok(()),
+
+                                    HandleEventResult::Nothing => (),
+                                }
+                            }
+                        },
+                        _ => (),
+                    }
+                },
+            }
+        }
+    }
+
+    fn beep() -> std::io::Result<()> {
+        stdout().write(b"\x07")?;
+        Ok(())
+    }
+}
+
+struct TuiLogicalState {
+    activity_stack: ActivityStack,
+}
+
+impl TuiLogicalState {
+    fn new() -> Self {
+        TuiLogicalState {
+            activity_stack: ActivityStack::new(),
+        }
+    }
+
+    fn draw_frame(&self, _area: Rect, buf: &mut Buffer) {
+        buf.reset();
+        ratatui_set_string(buf, 2, 4, &ColouredString::general(
+            "#HelloWorld from Mastodonochrome",
+            "###########                     ",
+        ).slice());
+    }
+
+    fn handle_keypress(&mut self, key: KeyEvent) -> HandleEventResult {
+        match key.code {
+            KeyCode::Char('q') => HandleEventResult::Exit,
+            KeyCode::Char('b') => HandleEventResult::Beep,
+            _ => HandleEventResult::Nothing,
+        }
+    }
+}