added scrolling to widgets

This commit is contained in:
emilis 2023-01-24 12:54:39 +00:00
parent 3a5d8e929d
commit dafd1493fe
7 changed files with 454 additions and 154 deletions

3
Cargo.lock generated
View File

@ -248,8 +248,7 @@ dependencies = [
[[package]] [[package]]
name = "termion" name = "termion"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://sectorinf.com/emilis/termion#ce611b828393cf1bad1667089af9b41a7d99a768"
checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
dependencies = [ dependencies = [
"libc", "libc",
"numtoa", "numtoa",

View File

@ -7,8 +7,11 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.68" anyhow = "1.0.68"
termion = "2.0.1" # termion = "2.0.1"
[dependencies.serde] [dependencies.serde]
version = "1" version = "1"
features = ["std", "derive"] features = ["std", "derive"]
[dependencies.termion]
git = "https://sectorinf.com/emilis/termion"

View File

@ -85,21 +85,16 @@ impl PartialEq for Component {
} }
} }
#[derive(Debug, Clone)]
pub struct Line {
color: ColorSet,
components: Vec<Component>,
}
#[derive(Debug, Clone, Eq)] #[derive(Debug, Clone, Eq)]
pub struct Widget { pub struct Widget {
want_width: u8, width_pct: u8,
per_line: Vec<Token>, per_line: Vec<Token>,
scroll_offset: Option<usize>,
} }
impl PartialEq for Widget { impl PartialEq for Widget {
fn eq(&self, other: &Self) -> bool { 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.len() == other.per_line.len()
&& (&self.per_line) && (&self.per_line)
.into_iter() .into_iter()
@ -111,13 +106,64 @@ impl PartialEq for Widget {
impl Widget { impl Widget {
pub fn new(width_pct: u8, tokens_per_line: Vec<Token>) -> Self { pub fn new(width_pct: u8, tokens_per_line: Vec<Token>) -> Self {
Self { Self {
want_width: width_pct, width_pct,
per_line: tokens_per_line, per_line: tokens_per_line,
scroll_offset: None,
} }
} }
fn get_line(&self, line: usize) -> Option<&Token> { pub fn scrolling(
self.per_line.get(line) width_pct: u8,
tokens_per_line: Vec<Token>,
) -> 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![]; return vec![];
} }
match self { 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| { .map(|line| {
let mut offset = 0; let mut offset = 0;
(&wdg) (&mut wdg)
.into_iter() .into_iter()
.map(|w| { .map(|w| {
let (width, token) = ( let (width, token) = (
(w.want_width as usize * line_width) w.abs_width(line_width),
/ 100 w.get_line(line, lines)
+ 1, // Feels wrong? .map(|t| t.clone()),
w.get_line(line).map(|t| t.clone()),
); );
let mut result = match token { let mut result = match token {
Some(tok) => tok Some(tok) => tok
@ -248,13 +298,12 @@ impl Plan {
} }
fn fit_check(widgets: &Vec<Widget>) { fn fit_check(widgets: &Vec<Widget>) {
if widgets let sum = widgets
.into_iter() .into_iter()
.map(|wd| wd.want_width as usize) .map(|wd| wd.abs_width(100))
.sum::<usize>() .sum::<usize>();
> 100 if sum > 100 {
{ panic!("widgets do not fit screen: {}/100", sum)
panic!("widgets do not fit screen")
} }
} }
@ -321,6 +370,49 @@ impl Plan {
Self::Fill(Box::new(self), widgets) 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( pub fn make(
self, self,
base_colors: ColorSet, base_colors: ColorSet,
@ -364,6 +456,18 @@ mod tests {
bg: Color::BLACK, bg: Color::BLACK,
}; };
let (w1, w2, w3) = test_widgets(); 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![ vec![
( (
"all the widgets, 30 lines", "all the widgets, 30 lines",
@ -372,7 +476,7 @@ mod tests {
vec![w1.clone(), w2.clone(), w3.clone()], vec![w1.clone(), w2.clone(), w3.clone()],
), ),
w1.clone() w1.clone()
.get_line(0) .get_line(0, 30)
.unwrap() .unwrap()
.clone() .clone()
.with_width(WIDTH / 3) .with_width(WIDTH / 3)
@ -384,7 +488,7 @@ mod tests {
]) ])
.chain( .chain(
w2.clone() w2.clone()
.get_line(0) .get_line(0, 30)
.unwrap() .unwrap()
.clone() .clone()
.with_width(WIDTH / 3), .with_width(WIDTH / 3),
@ -396,7 +500,7 @@ mod tests {
]) ])
.chain( .chain(
w3.clone() w3.clone()
.get_line(0) .get_line(0, 30)
.unwrap() .unwrap()
.clone() .clone()
.with_width(WIDTH / 3), .with_width(WIDTH / 3),
@ -433,7 +537,7 @@ mod tests {
"Single widget, single 10 lines section", "Single widget, single 10 lines section",
Instruction::start().fixed(10, vec![w1.clone()]), Instruction::start().fixed(10, vec![w1.clone()]),
w1.clone() w1.clone()
.get_line(0) .get_line(0, 10)
.unwrap() .unwrap()
.clone() .clone()
.with_width(WIDTH / 3) .with_width(WIDTH / 3)
@ -456,6 +560,53 @@ mod tests {
) )
.collect(), .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() .into_iter()
.for_each(|(name, instruction, expected)| { .for_each(|(name, instruction, expected)| {
@ -466,24 +617,22 @@ mod tests {
.into_iter() .into_iter()
.zip(expected.clone()) .zip(expected.clone())
.enumerate() .enumerate()
// .filter(|(i, (left, right))| left != right)
.map(|(i, (act, exp))| { .map(|(i, (act, exp))| {
format!( format!(
"{}{}: {} || {}", "{: <3}{: <3}: [exp|act] {: <25}|{}\n",
if act != exp { "## " } else { "" }, if act != exp { "## " } else { "" },
i, i,
exp.debug(), exp.debug(),
act.debug(), act.debug(),
) )
.replace('\n', " ")
}) })
.collect::<Vec<String>>(); .collect::<String>();
assert!( assert!(
expected == actual, expected == actual,
"<{}>: "<{}>:
expected({}) expected({})
actual({}) actual({})
diff:\n{:#?}", diff:\n{}",
name, name,
expected.len(), expected.len(),
actual.len(), actual.len(),

View File

@ -1,17 +1,16 @@
use component::{Component, LineArea, Plan, Widget}; use component::{Component, LineArea, Plan, Widget};
use std::{ use std::{
io::{Stdout, Write}, io::{Stdout, Write},
time::Duration, time::{Duration, Instant},
}; };
use termion::{ use termion::{
clear, cursor, clear, cursor,
event::{Key, MouseButton, MouseEvent},
input::{MouseTerminal, TermRead}, input::{MouseTerminal, TermRead},
raw::{IntoRawMode, RawTerminal}, raw::{IntoRawMode, RawTerminal},
screen::{AlternateScreen, IntoAlternateScreen}, screen::{AlternateScreen, IntoAlternateScreen},
AsyncReader,
}; };
use theme::{Color, ColorSet}; use theme::{Color, ColorSet};
use token::Token;
use view::Event; use view::Event;
type Result<T> = std::result::Result<T, anyhow::Error>; type Result<T> = std::result::Result<T, anyhow::Error>;
@ -23,117 +22,238 @@ pub mod theme;
pub mod token; pub mod token;
pub mod view; pub mod view;
struct PlanState {
plans: Vec<Plan>,
layers: Vec<Layer>,
last_term_size: (u16, u16),
base_colorset: ColorSet,
last_render: Option<Instant>,
}
impl PlanState {
fn new(
plans: PlanLayers,
base_colorset: ColorSet,
(term_w, term_h): (u16, u16),
last_render: Option<Instant>,
) -> 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<String> {
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<Self> {
Ok(Self::new(
plans,
base_colorset,
termion::terminal_size()?,
None,
))
}
fn resized(self) -> Result<Self> {
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<Self> {
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<Self> {
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::<String>()
}) {
write!(scr, "{}", layer)?;
}
scr.flush()?;
self.last_render = Some(Instant::now());
Ok(())
}
}
pub async fn run<V>(view: V) -> Result<()> pub async fn run<V>(view: V) -> Result<()>
where where
V: View, V: View,
{ {
let base_colorset = ColorSet {
fg: Color::WHITE,
bg: Color::BLACK,
};
let mut view = view; let mut view = view;
let mut last_ctrlc: Option<Instant> = None;
let mut screen: Screen = MouseTerminal::from( let mut screen: Screen = MouseTerminal::from(
std::io::stdout().into_alternate_screen()?.into_raw_mode()?, std::io::stdout().into_alternate_screen()?.into_raw_mode()?,
); );
let mut events_iter = termion::async_stdin().events(); let mut events_iter = termion::async_stdin().events();
let mut plans = view.init()?; let mut plans =
let (term_w, term_h) = termion::terminal_size()?; PlanState::from_plans(view.init()?, base_colorset)?;
let mut layers = make_layers( plans.render(&mut screen)?;
plans, let mut skip_resize = false;
ColorSet {
fg: Color::WHITE,
bg: Color::BLACK,
},
(term_w as usize, term_h as usize),
);
render_layers(&layers, &mut screen)?;
screen.flush()?;
loop { loop {
let event = match events_iter.next() { let event = match events_iter.next() {
Some(e) => e, Some(e) => e,
None => { None => {
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(1));
continue; 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 { let event = match event {
termion::event::Event::Mouse(mus) => match mus { termion::event::Event::Mouse(mus) => match mus {
termion::event::MouseEvent::Press(_, x, y) => { MouseEvent::Press(press, x, y) => match press {
// TODO: scrolling later MouseButton::WheelUp => {
match match_click(&layers, (x, y)) { plans = plans.scroll_up()?;
Some(yay) => Event::Link(yay), skip_resize = true;
None => continue, None
} }
} MouseButton::WheelDown => {
_ => Event::Input(event), 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)?; if let Some(event) = event {
let (term_w, term_h) = termion::terminal_size()?; let (_, update) = view.update(event)?;
layers = make_layers( if let Some(layers) = update {
plans, plans = PlanState::from_plans(layers, base_colorset)?;
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<Layer>,
click: (u16, u16),
) -> Option<String> {
for layer in layers.into_iter().rev() {
for link in &layer.links {
if link.1.matches(click) {
return Some(link.0.clone());
} }
} else if !skip_resize {
plans = plans.resized()?;
} }
} plans.render(&mut screen)?;
None skip_resize = false;
}
fn make_layers(
plans: PlanLayers,
base_colorset: ColorSet,
term_dimensions: (usize, usize),
) -> Vec<Layer> {
plans
.into_iter()
.map(|plan| Layer::make(plan, base_colorset, term_dimensions))
.collect()
}
fn render_layers(
layered: &Vec<Layer>,
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::<String>()
}) {
write!(scr, "{}", layer)?;
} }
Ok(()) Ok(())
} }
@ -190,11 +310,16 @@ impl Layer {
pub type PlanLayers = Vec<Plan>; pub type PlanLayers = Vec<Plan>;
pub trait View { pub trait View {
type Message;
fn init( fn init(
&mut self, &mut self,
) -> std::result::Result<PlanLayers, anyhow::Error>; ) -> std::result::Result<PlanLayers, anyhow::Error>;
fn update( fn update(
&mut self, &mut self,
event: Event, event: Event,
) -> std::result::Result<PlanLayers, anyhow::Error>; ) -> std::result::Result<
(Self::Message, Option<PlanLayers>),
anyhow::Error,
>;
} }

View File

@ -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<S>(self, name: S) -> Self pub fn link<S>(self, name: S) -> Self
where where
S: Into<String>, S: Into<String>,
{ {
#[cfg(debug_assertions)]
{
if self.has_centered() {
panic!("FIXME: Token::link called after centered");
}
}
Self::Link(Box::new(self), name.into()) Self::Link(Box::new(self), name.into())
} }
@ -252,11 +273,11 @@ mod tests {
( (
"contains a link", "contains a link",
Token::text("learn more") Token::text("learn more")
.centered() .link("string_link")
.link("string_link"), .centered(),
vec![ vec![
Component::Link("string_link".into(), 10),
Component::X((WIDTH - 10) / 2), Component::X((WIDTH - 10) / 2),
Component::Link("string_link".into(), 10),
Component::String("learn more".into()), Component::String("learn more".into()),
], ],
), ),

View File

@ -1,8 +1,4 @@
use crate::{ #[derive(Clone, Debug)]
component::{Component, LineArea, Plan},
theme::ColorSet,
};
pub enum Event { pub enum Event {
Link(String), Link(String),
Input(termion::event::Event), Input(termion::event::Event),

View File

@ -33,26 +33,33 @@ impl Default for App {
], ],
)], )],
), ),
Plan::start().fill(vec![]).fixed( Plan::start()
4, .fixed(4, vec![])
vec![Widget::new( .fill(vec![Widget::scrolling(
100, 100,
vec![ (1..=100)
Token::End, .into_iter()
Token::text( .map(|num| {
"you did it! now undo it u fuck", Token::text(format!(
) "this is {}",
.bg(Color::RED) num
.link("click"), ))
], .padded(30)
)], .bg("#330033".try_into().unwrap())
), .link(format!("num{}", num))
.centered()
})
.collect(),
)])
.fixed(4, vec![]),
], ],
} }
} }
} }
impl View for App { impl View for App {
type Message = ();
fn init( fn init(
&mut self, &mut self,
) -> std::result::Result<kkdisp::PlanLayers, anyhow::Error> { ) -> std::result::Result<kkdisp::PlanLayers, anyhow::Error> {
@ -62,17 +69,17 @@ impl View for App {
fn update( fn update(
&mut self, &mut self,
event: Event, event: Event,
) -> std::result::Result<kkdisp::PlanLayers, anyhow::Error> { ) -> std::result::Result<
(Self::Message, Option<kkdisp::PlanLayers>),
anyhow::Error,
> {
match event { match event {
Event::Link(lnk) => { Event::Link(lnk) => {
if lnk == "click" { eprintln!("recieved link: {}", lnk);
self.index ^= 1; self.index ^= 1;
Ok(vec![self.states[self.index].clone()]) Ok(((), Some(vec![self.states[self.index].clone()])))
} else {
panic!("bad link");
}
} }
_ => Ok(vec![self.states[self.index].clone()]), _ => Ok(((), None)),
} }
} }
} }