From dafd1493fe088a7730da1363625d26dcd73f3052 Mon Sep 17 00:00:00 2001 From: emilis Date: Tue, 24 Jan 2023 12:54:39 +0000 Subject: [PATCH] added scrolling to widgets --- Cargo.lock | 3 +- kkdisp/Cargo.toml | 5 +- kkdisp/src/component.rs | 213 +++++++++++++++++++++++----- kkdisp/src/lib.rs | 305 ++++++++++++++++++++++++++++------------ kkdisp/src/token.rs | 27 +++- kkdisp/src/view.rs | 6 +- kkx/src/main.rs | 49 ++++--- 7 files changed, 454 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a0daf0..9759cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,8 +248,7 @@ dependencies = [ [[package]] name = "termion" version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90" +source = "git+https://sectorinf.com/emilis/termion#ce611b828393cf1bad1667089af9b41a7d99a768" dependencies = [ "libc", "numtoa", diff --git a/kkdisp/Cargo.toml b/kkdisp/Cargo.toml index 3b9eeb4..532a6c5 100644 --- a/kkdisp/Cargo.toml +++ b/kkdisp/Cargo.toml @@ -7,8 +7,11 @@ edition = "2021" [dependencies] anyhow = "1.0.68" -termion = "2.0.1" +# termion = "2.0.1" [dependencies.serde] version = "1" features = ["std", "derive"] + +[dependencies.termion] +git = "https://sectorinf.com/emilis/termion" \ No newline at end of file diff --git a/kkdisp/src/component.rs b/kkdisp/src/component.rs index 86ef006..d765a29 100644 --- a/kkdisp/src/component.rs +++ b/kkdisp/src/component.rs @@ -85,21 +85,16 @@ impl PartialEq for Component { } } -#[derive(Debug, Clone)] -pub struct Line { - color: ColorSet, - components: Vec, -} - #[derive(Debug, Clone, Eq)] pub struct Widget { - want_width: u8, + width_pct: u8, per_line: Vec, + scroll_offset: Option, } impl PartialEq for Widget { fn eq(&self, other: &Self) -> bool { - self.want_width == other.want_width + self.width_pct == other.width_pct && self.per_line.len() == other.per_line.len() && (&self.per_line) .into_iter() @@ -111,13 +106,64 @@ impl PartialEq for Widget { impl Widget { pub fn new(width_pct: u8, tokens_per_line: Vec) -> Self { Self { - want_width: width_pct, + width_pct, per_line: tokens_per_line, + scroll_offset: None, } } - fn get_line(&self, line: usize) -> Option<&Token> { - self.per_line.get(line) + pub fn scrolling( + width_pct: u8, + tokens_per_line: Vec, + ) -> Self { + Self { + width_pct, + per_line: tokens_per_line, + scroll_offset: Some(0), + } + } + + fn scroll_up(mut self) -> Self { + self.scroll_offset = self.scroll_offset.map(|offset| { + if offset == 0 { + 0 + } else { + offset - 1 + } + }); + self + } + + fn scroll_down(mut self) -> Self { + // FIXME: since we don't know what the screen size is + // when this is rendered, the scroll can become "stuck" + // for a bit by scrolling further than the screen can allow. + // In this case, perhaps doing a scroll-check would be useful? + // Or, since the scroll should result in re-rendering, maybe + // the renderer should call a check on the widgets with + // the height at the time of the render? An extra roll + // through the list, woo! But tbh, I should probably look at + // optimizing later, not now. + self.scroll_offset = self + .scroll_offset + .map(|offset| self.per_line.len().min(offset + 1)); + self + } + + fn get_line(&self, line: usize, height: usize) -> Option<&Token> { + let len = self.per_line.len(); + if self.scroll_offset.is_none() || len < height { + return self.per_line.get(line); + } + let offset = len + - height + - (len - height).min(self.scroll_offset.unwrap_or(0)); + self.per_line.get(offset + line) + } + + fn abs_width(&self, line_width: usize) -> usize { + line_width + .min((self.width_pct as usize * line_width) / 100 + 1) } } @@ -172,17 +218,21 @@ impl Instruction { return vec![]; } match self { - Instruction::FixedHeight(next, lines, wdg) => (0..lines) + // Widget has to be taken mutably cause I can't think + // of a simpler way to "fix" the scrolling offset + // at the point of rendering when it's scrolled past + // what fits on screen. + Instruction::FixedHeight(next, lines, mut wdg) => (0 + ..lines) .map(|line| { let mut offset = 0; - (&wdg) + (&mut wdg) .into_iter() .map(|w| { let (width, token) = ( - (w.want_width as usize * line_width) - / 100 - + 1, // Feels wrong? - w.get_line(line).map(|t| t.clone()), + w.abs_width(line_width), + w.get_line(line, lines) + .map(|t| t.clone()), ); let mut result = match token { Some(tok) => tok @@ -248,13 +298,12 @@ impl Plan { } fn fit_check(widgets: &Vec) { - if widgets + let sum = widgets .into_iter() - .map(|wd| wd.want_width as usize) - .sum::() - > 100 - { - panic!("widgets do not fit screen") + .map(|wd| wd.abs_width(100)) + .sum::(); + if sum > 100 { + panic!("widgets do not fit screen: {}/100", sum) } } @@ -321,6 +370,49 @@ impl Plan { Self::Fill(Box::new(self), widgets) } + pub fn scroll_up(self) -> Self { + match self { + Plan::FixedHeight(next, height, widgets) => { + Plan::FixedHeight( + Box::new(next.scroll_up()), + height, + widgets + .into_iter() + .map(|w| w.scroll_up()) + .collect(), + ) + } + Plan::Fill(next, widgets) => Plan::Fill( + Box::new(next.scroll_up()), + widgets.into_iter().map(|w| w.scroll_up()).collect(), + ), + Plan::End => Plan::End, + } + } + + pub fn scroll_down(self) -> Self { + match self { + Plan::FixedHeight(next, height, widgets) => { + Plan::FixedHeight( + Box::new(next.scroll_down()), + height, + widgets + .into_iter() + .map(|w| w.scroll_down()) + .collect(), + ) + } + Plan::Fill(next, widgets) => Plan::Fill( + Box::new(next.scroll_down()), + widgets + .into_iter() + .map(|w| w.scroll_down()) + .collect(), + ), + Plan::End => Plan::End, + } + } + pub fn make( self, base_colors: ColorSet, @@ -364,6 +456,18 @@ mod tests { bg: Color::BLACK, }; let (w1, w2, w3) = test_widgets(); + let scrolling_widget = Widget::scrolling( + 100, + (0..100) + .into_iter() + .map(|num| Token::text(format!("line {}", num))) + .collect(), + ); + let scrolled_by_3 = scrolling_widget + .clone() + .scroll_up() + .scroll_up() + .scroll_up(); vec![ ( "all the widgets, 30 lines", @@ -372,7 +476,7 @@ mod tests { vec![w1.clone(), w2.clone(), w3.clone()], ), w1.clone() - .get_line(0) + .get_line(0, 30) .unwrap() .clone() .with_width(WIDTH / 3) @@ -384,7 +488,7 @@ mod tests { ]) .chain( w2.clone() - .get_line(0) + .get_line(0, 30) .unwrap() .clone() .with_width(WIDTH / 3), @@ -396,7 +500,7 @@ mod tests { ]) .chain( w3.clone() - .get_line(0) + .get_line(0, 30) .unwrap() .clone() .with_width(WIDTH / 3), @@ -433,7 +537,7 @@ mod tests { "Single widget, single 10 lines section", Instruction::start().fixed(10, vec![w1.clone()]), w1.clone() - .get_line(0) + .get_line(0, 10) .unwrap() .clone() .with_width(WIDTH / 3) @@ -456,6 +560,53 @@ mod tests { ) .collect(), ), + ( + "Scrollable widget: no scrolling", + Instruction::start() + .fixed(10, vec![scrolling_widget]), + (0..10) + .into_iter() + .map(|num| { + vec![ + Component::String(format!( + "line {}", + num + )), + Component::Fg(BASE_COLOR.fg), + Component::Bg(BASE_COLOR.bg), + Component::NextLine, + ] + }) + .flatten() + .chain( + (0..(HEIGHT - 10)) + .map(|_| Component::NextLine), + ) + .collect(), + ), + ( + "Scrollable widget: scrolled by 3", + Instruction::start().fixed(10, vec![scrolled_by_3]), + (0..10) + .into_iter() + .map(|num| { + vec![ + Component::String(format!( + "line {}", + num + 3, + )), + Component::Fg(BASE_COLOR.fg), + Component::Bg(BASE_COLOR.bg), + Component::NextLine, + ] + }) + .flatten() + .chain( + (0..(HEIGHT - 10)) + .map(|_| Component::NextLine), + ) + .collect(), + ), ] .into_iter() .for_each(|(name, instruction, expected)| { @@ -466,24 +617,22 @@ mod tests { .into_iter() .zip(expected.clone()) .enumerate() - // .filter(|(i, (left, right))| left != right) .map(|(i, (act, exp))| { format!( - "{}{}: {} || {}", + "{: <3}{: <3}: [exp|act] {: <25}|{}\n", if act != exp { "## " } else { "" }, i, exp.debug(), act.debug(), ) - .replace('\n', " ") }) - .collect::>(); + .collect::(); assert!( expected == actual, "<{}>: expected({}) actual({}) - diff:\n{:#?}", + diff:\n{}", name, expected.len(), actual.len(), diff --git a/kkdisp/src/lib.rs b/kkdisp/src/lib.rs index 2a11503..eae9e70 100644 --- a/kkdisp/src/lib.rs +++ b/kkdisp/src/lib.rs @@ -1,17 +1,16 @@ use component::{Component, LineArea, Plan, Widget}; use std::{ io::{Stdout, Write}, - time::Duration, + time::{Duration, Instant}, }; use termion::{ clear, cursor, + event::{Key, MouseButton, MouseEvent}, input::{MouseTerminal, TermRead}, raw::{IntoRawMode, RawTerminal}, screen::{AlternateScreen, IntoAlternateScreen}, - AsyncReader, }; use theme::{Color, ColorSet}; -use token::Token; use view::Event; type Result = std::result::Result; @@ -23,117 +22,238 @@ pub mod theme; pub mod token; pub mod view; +struct PlanState { + plans: Vec, + layers: Vec, + last_term_size: (u16, u16), + base_colorset: ColorSet, + last_render: Option, +} + +impl PlanState { + fn new( + plans: PlanLayers, + base_colorset: ColorSet, + (term_w, term_h): (u16, u16), + last_render: Option, + ) -> Self { + let layers = plans + .clone() + .into_iter() + .map(|plan| { + Layer::make( + plan, + base_colorset, + (term_w as usize, term_h as usize), + ) + }) + .collect(); + Self { + plans, + layers, + base_colorset, + last_render, + last_term_size: (term_w, term_h), + } + } + + fn match_click(&self, click: (u16, u16)) -> Option { + for layer in (&self.layers).into_iter().rev() { + let (_, h) = termion::terminal_size().unwrap(); + for link in &layer.links { + if link.1.matches(click) { + return Some(link.0.clone()); + } + } + } + None + } + + fn from_plans( + plans: PlanLayers, + base_colorset: ColorSet, + ) -> Result { + Ok(Self::new( + plans, + base_colorset, + termion::terminal_size()?, + None, + )) + } + + fn resized(self) -> Result { + let term_size = termion::terminal_size()?; + if term_size == self.last_term_size { + return Ok(self); + } + Ok(Self::new( + self.plans, + self.base_colorset, + term_size, + self.last_render, + )) + } + + fn scroll_up(self) -> Result { + Ok(Self::new( + self.plans + .into_iter() + .map(|layer| layer.scroll_up()) + .collect(), + self.base_colorset, + termion::terminal_size()?, + self.last_render, + )) + } + + fn scroll_down(self) -> Result { + Ok(Self::new( + self.plans + .into_iter() + .map(|layer| layer.scroll_down()) + .collect(), + self.base_colorset, + termion::terminal_size()?, + self.last_render, + )) + } + + fn render(&mut self, scr: &mut Screen) -> Result<()> { + const RENDER_COOLDOWN: u128 = 1000 / 50; + if let Some(last) = self.last_render { + if last.elapsed().as_millis() < RENDER_COOLDOWN { + return Ok(()); + } + } + write!( + scr, + "{color}{clear}{goto}", + color = self.base_colorset.to_string(), + clear = clear::All, + goto = cursor::Goto(1, 1) + )?; + for layer in (&self.layers).into_iter().map(|layer| { + let mut line = 1; + (&layer.components) + .into_iter() + .map(|component| match component { + Component::NextLine => { + line += 1; + "\n\r".into() + } + Component::Link(_, _) => panic!( + "links should be filtered out at the layer" + ), + Component::X(x) => { + cursor::Goto((x + 1) as u16, line).into() + } + Component::String(s) => s.clone(), + Component::Fg(c) => c.fg(), + Component::Bg(c) => c.bg(), + }) + .collect::() + }) { + write!(scr, "{}", layer)?; + } + scr.flush()?; + self.last_render = Some(Instant::now()); + Ok(()) + } +} + pub async fn run(view: V) -> Result<()> where V: View, { + let base_colorset = ColorSet { + fg: Color::WHITE, + bg: Color::BLACK, + }; let mut view = view; + let mut last_ctrlc: Option = None; let mut screen: Screen = MouseTerminal::from( std::io::stdout().into_alternate_screen()?.into_raw_mode()?, ); let mut events_iter = termion::async_stdin().events(); - let mut plans = view.init()?; - let (term_w, term_h) = termion::terminal_size()?; - let mut layers = make_layers( - plans, - ColorSet { - fg: Color::WHITE, - bg: Color::BLACK, - }, - (term_w as usize, term_h as usize), - ); - render_layers(&layers, &mut screen)?; - screen.flush()?; + let mut plans = + PlanState::from_plans(view.init()?, base_colorset)?; + plans.render(&mut screen)?; + let mut skip_resize = false; loop { let event = match events_iter.next() { Some(e) => e, None => { - std::thread::sleep(Duration::from_millis(50)); + std::thread::sleep(Duration::from_millis(1)); continue; } }?; + // if let termion::event::Event::Mouse(m) = &event { + // match m { + // MouseEvent::Press(btn, x, y) => { + // eprintln!("got {:#?} at {} {}", btn, x, y); + // } + // _ => {} + // } + // } else { + // eprintln!("got event {:#?}", &event); + // } let event = match event { termion::event::Event::Mouse(mus) => match mus { - termion::event::MouseEvent::Press(_, x, y) => { - // TODO: scrolling later - match match_click(&layers, (x, y)) { - Some(yay) => Event::Link(yay), - None => continue, + MouseEvent::Press(press, x, y) => match press { + MouseButton::WheelUp => { + plans = plans.scroll_up()?; + skip_resize = true; + None } - } - _ => Event::Input(event), + MouseButton::WheelDown => { + plans = plans.scroll_down()?; + skip_resize = true; + None + } + _ => match plans.match_click((x, y)) { + Some(name) => Some(Event::Link(name)), + None => { + skip_resize = true; + None + } + }, + }, + // Not doing release/whatever mouse events rn + _ => None, }, - _ => Event::Input(event), + termion::event::Event::Key(key) => { + if let Key::Ctrl(c) = key { + if c == 'c' { + match last_ctrlc { + Some(last) => { + if last.elapsed().as_millis() < 300 { + break; + } + } + None => {} + }; + last_ctrlc = Some(Instant::now()); + continue; + } + Some(Event::Input(event)) + } else { + Some(Event::Input(event)) + } + } + _ => Some(Event::Input(event)), }; - plans = view.update(event)?; - let (term_w, term_h) = termion::terminal_size()?; - layers = make_layers( - plans, - ColorSet { - fg: Color::WHITE, - bg: Color::BLACK, - }, - (term_w as usize, term_h as usize), - ); - write!(screen, "{}", clear::All)?; - render_layers(&layers, &mut screen)?; - screen.flush()?; - } -} - -fn match_click( - layers: &Vec, - click: (u16, u16), -) -> Option { - for layer in layers.into_iter().rev() { - for link in &layer.links { - if link.1.matches(click) { - return Some(link.0.clone()); + if let Some(event) = event { + let (_, update) = view.update(event)?; + if let Some(layers) = update { + plans = PlanState::from_plans(layers, base_colorset)?; } + } else if !skip_resize { + plans = plans.resized()?; } - } - None -} - -fn make_layers( - plans: PlanLayers, - base_colorset: ColorSet, - term_dimensions: (usize, usize), -) -> Vec { - plans - .into_iter() - .map(|plan| Layer::make(plan, base_colorset, term_dimensions)) - .collect() -} - -fn render_layers( - layered: &Vec, - scr: &mut Screen, -) -> Result<()> { - for layer in layered.into_iter().map(|layer| { - let mut line = 1; - (&layer.components) - .into_iter() - .map(|component| match component { - Component::NextLine => { - line += 1; - "\n\r".into() - } - Component::Link(_, _) => panic!( - "links should be filtered out at the layer" - ), - Component::X(x) => { - cursor::Goto((x + 1) as u16, line).into() - } - Component::String(s) => s.clone(), - Component::Fg(c) => c.fg(), - Component::Bg(c) => c.bg(), - }) - .collect::() - }) { - write!(scr, "{}", layer)?; + plans.render(&mut screen)?; + skip_resize = false; } Ok(()) } @@ -190,11 +310,16 @@ impl Layer { pub type PlanLayers = Vec; pub trait View { + type Message; + fn init( &mut self, ) -> std::result::Result; fn update( &mut self, event: Event, - ) -> std::result::Result; + ) -> std::result::Result< + (Self::Message, Option), + anyhow::Error, + >; } diff --git a/kkdisp/src/token.rs b/kkdisp/src/token.rs index 406a1aa..685222f 100644 --- a/kkdisp/src/token.rs +++ b/kkdisp/src/token.rs @@ -66,10 +66,31 @@ impl Token { } } + // Checks if any children are Token::Centered + // Used for links so they do not get set incorrectly + fn has_centered(&self) -> bool { + match self { + Token::Centered(_) => true, + Token::Limited(next, _) => next.has_centered(), + Token::CharPad(next, _, _) => next.has_centered(), + Token::Fg(next, _) => next.has_centered(), + Token::Bg(next, _) => next.has_centered(), + Token::Link(next, _) => next.has_centered(), + Token::String(next, _) => next.has_centered(), + Token::End => false, + } + } + pub fn link(self, name: S) -> Self where S: Into, { + #[cfg(debug_assertions)] + { + if self.has_centered() { + panic!("FIXME: Token::link called after centered"); + } + } Self::Link(Box::new(self), name.into()) } @@ -252,11 +273,11 @@ mod tests { ( "contains a link", Token::text("learn more") - .centered() - .link("string_link"), + .link("string_link") + .centered(), vec![ - Component::Link("string_link".into(), 10), Component::X((WIDTH - 10) / 2), + Component::Link("string_link".into(), 10), Component::String("learn more".into()), ], ), diff --git a/kkdisp/src/view.rs b/kkdisp/src/view.rs index 968bb8a..201b8af 100644 --- a/kkdisp/src/view.rs +++ b/kkdisp/src/view.rs @@ -1,8 +1,4 @@ -use crate::{ - component::{Component, LineArea, Plan}, - theme::ColorSet, -}; - +#[derive(Clone, Debug)] pub enum Event { Link(String), Input(termion::event::Event), diff --git a/kkx/src/main.rs b/kkx/src/main.rs index a067998..75762dc 100644 --- a/kkx/src/main.rs +++ b/kkx/src/main.rs @@ -33,26 +33,33 @@ impl Default for App { ], )], ), - Plan::start().fill(vec![]).fixed( - 4, - vec![Widget::new( + Plan::start() + .fixed(4, vec![]) + .fill(vec![Widget::scrolling( 100, - vec![ - Token::End, - Token::text( - "you did it! now undo it u fuck", - ) - .bg(Color::RED) - .link("click"), - ], - )], - ), + (1..=100) + .into_iter() + .map(|num| { + Token::text(format!( + "this is {}", + num + )) + .padded(30) + .bg("#330033".try_into().unwrap()) + .link(format!("num{}", num)) + .centered() + }) + .collect(), + )]) + .fixed(4, vec![]), ], } } } impl View for App { + type Message = (); + fn init( &mut self, ) -> std::result::Result { @@ -62,17 +69,17 @@ impl View for App { fn update( &mut self, event: Event, - ) -> std::result::Result { + ) -> std::result::Result< + (Self::Message, Option), + anyhow::Error, + > { match event { Event::Link(lnk) => { - if lnk == "click" { - self.index ^= 1; - Ok(vec![self.states[self.index].clone()]) - } else { - panic!("bad link"); - } + eprintln!("recieved link: {}", lnk); + self.index ^= 1; + Ok(((), Some(vec![self.states[self.index].clone()]))) } - _ => Ok(vec![self.states[self.index].clone()]), + _ => Ok(((), None)), } } }