pub(crate) mod extras;
pub(crate) mod focus;
pub(crate) mod layout;
pub(crate) mod measure;
pub(crate) mod style;
pub(crate) mod widgets;
use std::collections::HashMap;
use oino_ui::node::Node;
use oino_ui::slot::{UiEntry, UiView};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use crate::theme::Theme;
use focus::UiLocalState;
use crate::render::text::OLine;
pub(crate) struct Ctx<'a> {
pub(crate) theme: &'a Theme,
pub(crate) tick: u64,
pub(crate) local: &'a UiLocalState,
}
impl Ctx<'_> {
pub(crate) fn base(&self) -> Style {
Style::default().fg(self.theme.text)
}
}
#[derive(Debug, Default)]
pub(crate) struct RenderState {
focus_counter: usize,
focused: Option<usize>,
}
impl RenderState {
fn next_focus(&mut self) -> bool {
let focused = self.focused == Some(self.focus_counter);
self.focus_counter += 1;
focused
}
}
#[must_use]
pub(crate) fn render_root(root: &Node, area: Rect, cx: &Ctx<'_>) -> Vec<OLine> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut buffer = Buffer::empty(area);
let mut state = RenderState {
focused: cx.local.focused,
..RenderState::default()
};
render_node(root, "", area, &mut buffer, cx, &mut state, cx.base());
buffer_to_lines(&buffer)
}
#[must_use]
pub(crate) fn render_entry(entry: &UiEntry, area: Rect, cx: &Ctx<'_>) -> Vec<OLine> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
match &entry.view {
UiView::Tree { root } => render_root(root, area, cx),
UiView::Reserved { fallback, .. } => fallback
.as_deref()
.map_or_else(Vec::new, |root| render_root(root, area, cx)),
UiView::Lines { lines } => {
let base = cx.base();
let text: Vec<Line<'static>> = lines
.iter()
.map(|line| Line::from(Span::styled(line.clone(), base)))
.collect();
let mut buffer = Buffer::empty(area);
ratatui::widgets::Paragraph::new(text)
.style(base)
.render(area, &mut buffer);
buffer_to_lines(&buffer)
}
UiView::Status { text } => {
let base = cx.base();
let mut buffer = Buffer::empty(area);
ratatui::widgets::Paragraph::new(style::line(text, base, cx.theme))
.style(base)
.render(area, &mut buffer);
buffer_to_lines(&buffer)
}
UiView::Renderer { .. } | UiView::Settings { .. } => Vec::new(),
}
}
#[must_use]
pub(crate) fn buffer_to_lines(buffer: &Buffer) -> Vec<OLine> {
let area = buffer.area;
let mut out = Vec::with_capacity(usize::from(area.height));
for y in 0..area.height {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut text = String::new();
let mut style = buffer[(area.x, area.y + y)].style();
for x in 0..area.width {
let cell = &buffer[(area.x + x, area.y + y)];
if cell.style() != style {
if !text.is_empty() {
spans.push(Span::styled(std::mem::take(&mut text), style));
}
style = cell.style();
}
text.push_str(cell.symbol());
}
if !text.is_empty() {
spans.push(Span::styled(text, style));
}
out.push(Line::from(spans));
}
out
}
#[allow(
clippy::too_many_arguments,
reason = "the recursive renderer threads the buffer, state, path and inherited style"
)]
pub(crate) fn render_node(
node: &Node,
path: &str,
area: Rect,
buffer: &mut Buffer,
cx: &Ctx<'_>,
state: &mut RenderState,
inherited: Style,
) {
if area.width == 0 || area.height == 0 {
return;
}
let common = node.common();
let base = style::patch(common.style, inherited, cx.theme);
match node {
Node::Layout(layout) => {
let collapsed = layout::collapses(layout, area.width);
if collapsed {
let heights: Vec<u16> = layout
.children
.iter()
.map(|child| measure::height(child, area.width, cx))
.collect();
let rects =
layout::split_vertical_auto(&heights, layout.spacing.unwrap_or(0), area);
for (index, (child, rect)) in layout.children.iter().zip(rects).enumerate() {
let segment = focus::key_or(&child.common().key.map(str::to_owned), index);
render_node(
child,
&focus::join(path, &segment),
rect,
buffer,
cx,
state,
base,
);
}
} else {
let rects = layout::split(layout, area);
for (index, (child, rect)) in layout.children.iter().zip(rects).enumerate() {
let segment = focus::key_or(&child.common().key.map(str::to_owned), index);
render_node(
child,
&focus::join(path, &segment),
rect,
buffer,
cx,
state,
base,
);
}
}
}
Node::Block(block) => {
let spec = style::BlockSpec::from(block);
let collapsible = block.collapsible.unwrap_or(false);
let collapsed = if collapsible {
let focused = state.next_focus();
let _ = focused;
cx.local.collapsed(path, block.collapsed.unwrap_or(false))
} else {
block.collapsed.unwrap_or(false)
};
let marker = collapsible.then_some(collapsed);
let ratatui_block = style::block(&spec, marker, base, cx.theme).style(base);
let inner = ratatui_block.inner(area);
ratatui_block.render(area, buffer);
if !collapsed && let Some(child) = &block.child {
render_node(
child,
&focus::join(path, "child"),
inner,
buffer,
cx,
state,
base,
);
}
}
Node::Unknown(unknown) => {
if let Some(fallback) = &unknown.fallback {
render_node(
fallback,
&focus::join(path, "fallback"),
area,
buffer,
cx,
state,
base,
);
}
}
Node::Paragraph(_)
| Node::List(_)
| Node::Table(_)
| Node::Tabs(_)
| Node::Gauge(_)
| Node::LineGauge(_)
| Node::Sparkline(_)
| Node::BarChart(_)
| Node::Chart(_)
| Node::Scrollbar(_) => {
widgets::render(node, path, area, buffer, cx, state, base);
}
_ => {
extras::render(node, path, area, buffer, cx, state, base);
}
}
}
#[must_use]
pub fn render_v2_node(node: &Node, width: u16, height: u16, theme: &Theme) -> Vec<Line<'static>> {
let local = UiLocalState::default();
let cx = Ctx {
theme,
tick: 0,
local: &local,
};
render_root(
node,
Rect {
x: 0,
y: 0,
width,
height,
},
&cx,
)
}
#[must_use]
pub fn measure_v2_node(node: &Node, width: u16, theme: &Theme) -> u16 {
let local = UiLocalState::default();
let cx = Ctx {
theme,
tick: 0,
local: &local,
};
measure::height(node, width, &cx)
}
#[derive(Clone, Debug)]
struct Cached {
key: CacheKey,
lines: Vec<OLine>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CacheKey {
rev: u64,
width: u16,
height: u16,
epoch: u64,
focused: Option<usize>,
tick: u64,
theme: &'static str,
}
#[derive(Debug, Default)]
pub(crate) struct UiCache {
entries: HashMap<String, Cached>,
}
impl UiCache {
pub(crate) fn lines(
&mut self,
entry: &UiEntry,
area: Rect,
cx: &Ctx<'_>,
animated: bool,
) -> Vec<OLine> {
let key = CacheKey {
rev: entry.rev,
width: area.width,
height: area.height,
epoch: cx.local.epoch,
focused: cx.local.focused,
tick: if animated { cx.tick } else { 0 },
theme: cx.theme.name,
};
if let Some(cached) = self.entries.get(&entry.id)
&& cached.key == key
{
return cached.lines.clone();
}
let lines = render_entry(entry, area, cx);
self.entries.insert(
entry.id.clone(),
Cached {
key,
lines: lines.clone(),
},
);
lines
}
pub(crate) fn retain(&mut self, entries: &[&UiEntry]) {
let ids: std::collections::HashSet<&str> =
entries.iter().map(|entry| entry.id.as_str()).collect();
self.entries.retain(|id, _| ids.contains(id.as_str()));
}
pub(crate) fn invalidate(&mut self, id: &str) {
self.entries.remove(id);
}
}
#[must_use]
pub(crate) fn animated(entry: &UiEntry) -> bool {
fn walk(node: &Node) -> bool {
match node {
Node::Spinner(_) => true,
Node::Layout(layout) => layout.children.iter().any(walk),
Node::Block(block) => block.child.as_deref().is_some_and(walk),
Node::Tabs(tabs) => tabs
.panes
.as_ref()
.is_some_and(|panes| panes.values().any(walk)),
Node::Empty(empty) => empty.action.as_deref().is_some_and(walk),
Node::Unknown(unknown) => unknown.fallback.as_deref().is_some_and(walk),
_ => false,
}
}
match &entry.view {
UiView::Tree { root } => walk(root),
UiView::Reserved { fallback, .. } => fallback.as_deref().is_some_and(walk),
_ => false,
}
}