kkx/kkdisp/src/component.rs

738 lines
24 KiB
Rust

use termion::raw::RawTerminal;
use crate::{
theme::{Color, ColorSet},
token::Token,
};
use std::io::{Stdout, Write};
#[derive(Eq, Clone, Debug)]
pub enum Component {
NextLine,
Link(String, usize),
X(usize),
String(String),
Fg(Color),
Bg(Color),
}
impl Component {
#[cfg(test)]
pub fn debug(&self) -> String {
match self {
Component::NextLine => "NextLine".to_string(),
Component::X(x) => format!("X({})", x),
Component::String(s) => format!("\"{}\"({})", s, s.len()),
Component::Fg(c) => format!("FG({:#?})", c)
.replace('\n', "")
.replace(' ', ""),
Component::Bg(c) => format!("BG({:#?})", c)
.replace('\n', "")
.replace(' ', ""),
Component::Link(name, ln) => {
format!("Clickable[{}:{}]", name, ln)
}
}
}
}
pub struct LineArea {
pos: (u16, u16),
length: u16,
}
impl LineArea {
pub fn new(pos: (u16, u16), length: u16) -> Self {
Self { pos, length }
}
pub fn matches(&self, pos: (u16, u16)) -> bool {
self.pos.1 == pos.1
&& pos.0 >= self.pos.0
&& pos.0 <= (self.pos.0 + self.length - 1)
}
}
impl PartialEq for Component {
fn eq(&self, other: &Self) -> bool {
match self {
Self::X(x) => match other {
Self::X(other_x) => x == other_x,
_ => false,
},
Self::String(s) => match other {
Self::String(other_s) => s == other_s,
_ => false,
},
Self::Fg(c) => match other {
Self::Fg(other_c) => c == other_c,
_ => false,
},
Self::Bg(c) => match other {
Self::Bg(other_c) => c == other_c,
_ => false,
},
Self::NextLine => match other {
Self::NextLine => true,
_ => false,
},
Self::Link(name, length) => match other {
Self::Link(other_name, other_length) => {
name == other_name && length == other_length
}
_ => false,
},
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct Widget {
width_pct: u8,
per_line: Vec<Token>,
scroll_offset: Option<usize>,
}
impl PartialEq for Widget {
fn eq(&self, other: &Self) -> bool {
self.width_pct == other.width_pct
&& self.per_line.len() == other.per_line.len()
&& (&self.per_line)
.into_iter()
.enumerate()
.all(|(i, token)| token.eq(&other.per_line[i]))
}
}
impl Widget {
pub fn new(width_pct: u8, tokens_per_line: Vec<Token>) -> Self {
Self {
width_pct,
per_line: tokens_per_line,
scroll_offset: None,
}
}
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)
}
}
#[derive(Debug, Clone, Eq)]
pub enum Instruction {
FixedHeight(Box<Instruction>, usize, Vec<Widget>),
End,
}
impl PartialEq for Instruction {
fn eq(&self, other: &Self) -> bool {
match self {
Instruction::FixedHeight(next, size, widgets) => {
match other {
Instruction::FixedHeight(
other_next,
other_size,
other_widgets,
) => {
if size == other_size
&& next.eq(other_next)
&& widgets.len() == other_widgets.len()
{
widgets.into_iter().enumerate().all(
|(i, widget)| {
widget.eq(&other_widgets[i])
},
)
} else {
false
}
}
_ => false,
}
}
Instruction::End => match other {
Instruction::End => true,
_ => false,
},
}
}
}
impl Instruction {
pub fn into_components(
self,
line_width: usize,
height_left: usize,
color_reset: ColorSet,
) -> Vec<Component> {
if height_left == 0 {
return vec![];
}
match self {
// 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;
(&mut wdg)
.into_iter()
.map(|w| {
let (width, token) = (
w.abs_width(line_width),
w.get_line(line, lines)
.map(|t| t.clone()),
);
let mut result = match token {
Some(tok) => tok
.with_width(width)
.into_iter()
.map(|comp| {
if let Component::X(x) = comp
{
Component::X(offset + x)
} else {
comp
}
})
.collect(),
None => Vec::new(),
};
offset += width;
if offset < line_width {
result.push(Component::X(offset));
}
result
.push(Component::Fg(color_reset.fg));
result
.push(Component::Bg(color_reset.bg));
result
})
.flatten()
.chain(vec![Component::NextLine])
.collect::<Vec<Component>>()
})
.flatten()
.chain(next.into_components(
line_width,
height_left - lines,
color_reset,
))
.collect(),
Instruction::End => (0..height_left)
.map(|_| Component::NextLine)
.collect(),
}
}
pub fn start() -> Self {
Self::End
}
pub fn fixed(self, height: usize, widgets: Vec<Widget>) -> Self {
Self::FixedHeight(Box::new(self), height, widgets)
}
}
#[derive(Clone, Debug)]
pub enum Plan {
FixedHeight(Box<Plan>, usize, Vec<Widget>),
Fill(Box<Plan>, Vec<Widget>),
End,
}
impl Plan {
pub fn start() -> Self {
Self::End
}
fn fit_check(widgets: &Vec<Widget>) {
let sum = widgets
.into_iter()
.map(|wd| wd.abs_width(100))
.sum::<usize>();
if sum > 100 {
panic!("widgets do not fit screen: {}/100", sum)
}
}
fn to_instruction_set(self, max_height: usize) -> Instruction {
let fill_height = {
let (reserved_lines, fill_count) = self.count();
(max_height - reserved_lines) / 1.max(fill_count)
};
self.to_instruction_fixed_fill_height(fill_height)
}
fn to_instruction_fixed_fill_height(
self,
fill_height: usize,
) -> Instruction {
match self {
Plan::FixedHeight(next, height, widgets) => {
Instruction::FixedHeight(
Box::new(next.to_instruction_fixed_fill_height(
fill_height,
)),
height,
widgets,
)
}
Plan::Fill(next, widgets) => {
Instruction::FixedHeight(
Box::new(next.to_instruction_fixed_fill_height(
fill_height,
)),
fill_height,
widgets,
)
}
Plan::End => Instruction::End,
}
}
// counts how many lines are fixed height, those lines
// are reserved, and the rest may be split between fills.
// the return value is (reserved_lines, fill_count)
// so, fill_height should be reserved_lines/fill_count
fn count(&self) -> (usize, usize) {
match self {
Plan::FixedHeight(next, h, _) => {
let next = next.count();
(next.0 + h, next.1)
}
Plan::Fill(next, _) => {
let next = next.count();
(next.0, next.1 + 1)
}
Plan::End => (0, 0),
}
}
pub fn fixed(self, height: usize, widgets: Vec<Widget>) -> Self {
Self::fit_check(&widgets);
Self::FixedHeight(Box::new(self), height, widgets)
}
pub fn fill(self, widgets: Vec<Widget>) -> Self {
Self::fit_check(&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(
self,
base_colors: ColorSet,
(term_width, term_height): (usize, usize),
) -> Vec<Component> {
self.to_instruction_set(term_height - 1).into_components(
term_width,
term_height - 1,
base_colors,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const THIRD: u8 = 100 / 3;
fn test_widgets() -> (Widget, Widget, Widget) {
(
Widget::new(THIRD, vec![Token::text("hello").centered()]),
Widget::new(
THIRD,
vec![Token::text("hello")
.pad_char('*', 16)
.bg(Color::RED)],
),
Widget::new(
THIRD,
vec![Token::text("hello").limited(16).padded(20)],
),
)
}
#[test]
fn test_instructions_to_components() {
const WIDTH: usize = 120;
const HEIGHT: usize = 40;
const BASE_COLOR: ColorSet = ColorSet {
fg: Color::WHITE,
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",
Instruction::start().fixed(
30,
vec![w1.clone(), w2.clone(), w3.clone()],
),
w1.clone()
.get_line(0, 30)
.unwrap()
.clone()
.with_width(WIDTH / 3)
.into_iter()
.chain(vec![
Component::X(WIDTH / 3),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
])
.chain(
w2.clone()
.get_line(0, 30)
.unwrap()
.clone()
.with_width(WIDTH / 3),
)
.chain(vec![
Component::X((WIDTH / 3) * 2),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
])
.chain(
w3.clone()
.get_line(0, 30)
.unwrap()
.clone()
.with_width(WIDTH / 3),
)
.chain(vec![
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
Component::NextLine,
])
.chain(
(1..30)
.map(|_| {
vec![
Component::X(WIDTH / 3),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
Component::X((WIDTH / 3) * 2),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
Component::NextLine,
]
})
.flatten(),
)
.chain(
(0..(HEIGHT - 30))
.map(|_| Component::NextLine),
)
.collect::<Vec<Component>>(),
),
(
"Single widget, single 10 lines section",
Instruction::start().fixed(10, vec![w1.clone()]),
w1.clone()
.get_line(0, 10)
.unwrap()
.clone()
.with_width(WIDTH / 3)
.into_iter()
.chain(
(0..10)
.map(|_| {
vec![
Component::X(WIDTH / 3),
Component::Fg(BASE_COLOR.fg),
Component::Bg(BASE_COLOR.bg),
Component::NextLine,
]
})
.flatten(),
)
.chain(
(0..(HEIGHT - 10))
.map(|_| Component::NextLine),
)
.collect(),
),
(
"Scrollable widget: no scrolling",
Instruction::start()
.fixed(10, vec![scrolling_widget]),
(90..100)
.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]),
(87..97)
.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)| {
let actual = instruction
.into_components(WIDTH, HEIGHT, BASE_COLOR);
let diff = actual
.clone()
.into_iter()
.zip(expected.clone())
.enumerate()
.map(|(i, (act, exp))| {
format!(
"{: <3}{: <3}: [exp|act] {: <25}|{}\n",
if act != exp { "## " } else { "" },
i,
exp.debug(),
act.debug(),
)
})
.collect::<String>();
assert!(
expected == actual,
"<{}>:
expected({})
actual({})
diff:\n{}",
name,
expected.len(),
actual.len(),
diff,
// expected,
// actual
);
});
}
#[test]
fn test_plan_to_instructions() {
const HEIGHT: usize = 40;
let (widget_1, widget_2, widget_3) = test_widgets();
vec![
("end -> end", Plan::start(), Instruction::End),
(
"fill entire screen",
Plan::start().fill(vec![]),
Instruction::start().fixed(HEIGHT, vec![]),
),
(
"5 | fill | 5 -> 5 | HEIGHT - 5 - 5 | 5",
Plan::start()
.fixed(5, vec![widget_1.clone()])
.fill(vec![widget_3.clone()])
.fixed(5, vec![widget_2.clone()]),
Instruction::start()
.fixed(5, vec![widget_1.clone()])
.fixed(HEIGHT - 5 - 5, vec![widget_3.clone()])
.fixed(5, vec![widget_2.clone()]),
),
(
"5 | 5 | fill -> 5 | 5 | HEIGHT - 5 - 5",
Plan::start()
.fixed(5, vec![widget_3.clone()])
.fixed(5, vec![widget_2.clone()])
.fill(vec![widget_1.clone()]),
Instruction::start()
.fixed(5, vec![widget_3.clone()])
.fixed(5, vec![widget_2.clone()])
.fixed(HEIGHT - 5 - 5, vec![widget_1.clone()]),
),
(
"fill -> HEIGHT",
Plan::start().fill(vec![widget_1.clone()]),
Instruction::start()
.fixed(HEIGHT, vec![widget_1.clone()]),
),
(
"
5 | fill | 5 | fill | 5
->
5 | (HEIGHT - 15) / 2 | 5 | (HEIGHT - 15) / 2 | 5\n",
Plan::start()
.fixed(
5,
vec![widget_1.clone(), widget_2.clone()],
)
.fill(vec![widget_1.clone(), widget_3.clone()])
.fixed(5, vec![])
.fill(vec![widget_2.clone()])
.fixed(
5,
vec![widget_3.clone(), widget_2.clone()],
),
Instruction::start()
.fixed(
5,
vec![widget_1.clone(), widget_2.clone()],
)
.fixed(
(HEIGHT - 15) / 2,
vec![widget_1.clone(), widget_3.clone()],
)
.fixed(5, vec![])
.fixed((HEIGHT - 15) / 2, vec![widget_2.clone()])
.fixed(
5,
vec![widget_3.clone(), widget_2.clone()],
),
),
]
.into_iter()
.for_each(|(name, plan, expected)| {
eprintln!("running test <{}>", &name);
let actual = plan.to_instruction_set(HEIGHT);
assert!(
expected == actual,
"<{}>: not equal!
expected:\n{:#?}
actual:\n{:#?}",
&name,
expected,
actual,
);
})
}
}