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]]
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",

View File

@ -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"

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)]
pub struct Widget {
want_width: u8,
width_pct: u8,
per_line: Vec<Token>,
scroll_offset: Option<usize>,
}
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<Token>) -> 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<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![];
}
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<Widget>) {
if widgets
let sum = widgets
.into_iter()
.map(|wd| wd.want_width as usize)
.sum::<usize>()
> 100
{
panic!("widgets do not fit screen")
.map(|wd| wd.abs_width(100))
.sum::<usize>();
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::<Vec<String>>();
.collect::<String>();
assert!(
expected == actual,
"<{}>:
expected({})
actual({})
diff:\n{:#?}",
diff:\n{}",
name,
expected.len(),
actual.len(),

View File

@ -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<T> = std::result::Result<T, anyhow::Error>;
@ -23,71 +22,44 @@ pub mod theme;
pub mod token;
pub mod view;
pub async fn run<V>(view: V) -> Result<()>
where
V: View,
{
let mut view = view;
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()?;
loop {
let event = match events_iter.next() {
Some(e) => e,
None => {
std::thread::sleep(Duration::from_millis(50));
continue;
}
}?;
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,
}
}
_ => Event::Input(event),
},
_ => 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()?;
}
struct PlanState {
plans: Vec<Plan>,
layers: Vec<Layer>,
last_term_size: (u16, u16),
base_colorset: ColorSet,
last_render: Option<Instant>,
}
fn match_click(
layers: &Vec<Layer>,
click: (u16, u16),
) -> Option<String> {
for layer in layers.into_iter().rev() {
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());
@ -95,24 +67,72 @@ fn match_click(
}
}
None
}
}
fn make_layers(
fn from_plans(
plans: PlanLayers,
base_colorset: ColorSet,
term_dimensions: (usize, usize),
) -> Vec<Layer> {
plans
.into_iter()
.map(|plan| Layer::make(plan, base_colorset, term_dimensions))
.collect()
}
) -> Result<Self> {
Ok(Self::new(
plans,
base_colorset,
termion::terminal_size()?,
None,
))
}
fn render_layers(
layered: &Vec<Layer>,
scr: &mut Screen,
) -> Result<()> {
for layer in layered.into_iter().map(|layer| {
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()
@ -135,6 +155,106 @@ fn render_layers(
}) {
write!(scr, "{}", layer)?;
}
scr.flush()?;
self.last_render = Some(Instant::now());
Ok(())
}
}
pub async fn run<V>(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<Instant> = 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 =
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(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 {
MouseEvent::Press(press, x, y) => match press {
MouseButton::WheelUp => {
plans = plans.scroll_up()?;
skip_resize = true;
None
}
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,
},
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)),
};
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()?;
}
plans.render(&mut screen)?;
skip_resize = false;
}
Ok(())
}
@ -190,11 +310,16 @@ impl Layer {
pub type PlanLayers = Vec<Plan>;
pub trait View {
type Message;
fn init(
&mut self,
) -> std::result::Result<PlanLayers, anyhow::Error>;
fn update(
&mut self,
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
where
S: Into<String>,
{
#[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()),
],
),

View File

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

View File

@ -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<kkdisp::PlanLayers, anyhow::Error> {
@ -62,17 +69,17 @@ impl View for App {
fn update(
&mut self,
event: Event,
) -> std::result::Result<kkdisp::PlanLayers, anyhow::Error> {
) -> std::result::Result<
(Self::Message, Option<kkdisp::PlanLayers>),
anyhow::Error,
> {
match event {
Event::Link(lnk) => {
if lnk == "click" {
eprintln!("recieved link: {}", lnk);
self.index ^= 1;
Ok(vec![self.states[self.index].clone()])
} else {
panic!("bad link");
Ok(((), Some(vec![self.states[self.index].clone()])))
}
}
_ => Ok(vec![self.states[self.index].clone()]),
_ => Ok(((), None)),
}
}
}