use super::{panel, render};
use ratatui::{
Frame,
layout::{Margin, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph},
};
use std::collections::{BTreeSet, HashMap};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const ACCENT: Color = Color::Indexed(81);
const SELECTION_BG: Color = Color::Rgb(15, 51, 62);
const BODY_FG: Color = Color::Indexed(252);
const BODY_BG: Color = Color::Indexed(236);
const INPUT_BG: Color = Color::Indexed(238);
const POPUP_ERROR_FG: Color = Color::Indexed(210);
const POPUP_PROMPT_FG: Color = Color::Indexed(222);
const LABEL_FG: Color = Color::Indexed(222);
pub(super) const COMPACT_COLUMNS: u16 = 60;
pub(super) const LIST_ROW_PREFIX: &str = " ";
pub struct DetailsView<'a> {
pub rows: &'a [render::DetailRow],
pub top: &'a mut usize,
}
pub struct View<'a> {
pub query: &'a str,
pub focused: bool,
pub show_completed: bool,
pub include_archived: bool,
pub bulk_selected: Option<&'a BTreeSet<i64>>,
pub top: &'a mut usize,
pub follow_selected: bool,
pub modal_lines: Option<(&'a [render::PopupRow], PopupLayout)>,
pub hide_cursor: bool,
pub details: Option<DetailsView<'a>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WheelArea {
List(usize),
Details(usize),
Editor(usize),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClickTarget {
Task(i64),
Editor(usize),
ToggleCompleted,
ToggleArchived,
}
pub struct Panes {
pub list: Rect,
pub details: Rect,
pub editor: Rect,
}
pub struct PopupLayout {
pub outer: Rect,
pub content: Rect,
bordered: bool,
}
impl PopupLayout {
pub(super) fn enlarged(self, area: Rect) -> Self {
let margin = u16::from(self.bordered) * 4;
let width = self
.outer
.width
.saturating_add(self.outer.width.div_ceil(2))
.min(area.width.saturating_sub(margin));
let height = self
.outer
.height
.saturating_add(self.outer.height.div_ceil(2))
.min(area.height.saturating_sub(margin));
let outer = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
let inset = u16::from(self.bordered);
Self {
outer,
content: outer.inner(Margin::new(inset, inset)),
bordered: self.bordered,
}
}
}
pub fn popup_layout(area: Rect, rows: usize) -> PopupLayout {
let bordered = area.width >= 20 && area.height >= 14;
let border = usize::from(bordered) * 2;
let width = if bordered {
area.width.saturating_sub(4).min(48)
} else {
area.width.min(48)
};
let max_height = if bordered {
area.height.saturating_sub(4)
} else {
area.height
};
let height = rows.saturating_add(border).min(usize::from(max_height)) as u16;
let outer = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
let inset = u16::from(bordered);
PopupLayout {
outer,
content: outer.inner(Margin::new(inset, inset)),
bordered,
}
}
fn popup_row_style(kind: render::PopupKind, title: bool, color: bool) -> Style {
use render::PopupKind;
if !color {
return Style::default();
}
let body = Style::default().fg(BODY_FG).bg(BODY_BG);
match kind {
PopupKind::Heading => body.fg(ACCENT).add_modifier(Modifier::BOLD),
PopupKind::Hint => body.fg(Color::Gray),
PopupKind::Warning => body.fg(POPUP_PROMPT_FG),
PopupKind::Error => {
let style = body.fg(POPUP_ERROR_FG);
if title {
style.add_modifier(Modifier::BOLD)
} else {
style
}
}
PopupKind::Body | PopupKind::Action | PopupKind::Choice => body,
PopupKind::Input => body.bg(INPUT_BG),
PopupKind::SelectedAction | PopupKind::SelectedChoice => body.bg(SELECTION_BG),
}
}
fn popup_row_line(text: String, kind: render::PopupKind, color: bool) -> Line<'static> {
use render::PopupKind;
let marker = match kind {
PopupKind::SelectedAction | PopupKind::SelectedChoice => "> ",
PopupKind::Action | PopupKind::Choice => " ",
_ => "",
};
if !color {
return Line::from(format!("{marker}{text}"));
}
match kind {
PopupKind::Action | PopupKind::SelectedAction => {
let key_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD);
match text.split_once(' ') {
Some((key, label)) => Line::from(vec![
Span::raw(marker.to_owned()),
Span::styled(key.to_owned(), key_style),
Span::raw(format!(" {label}")),
]),
None => Line::from(vec![
Span::raw(marker.to_owned()),
Span::styled(text, key_style),
]),
}
}
PopupKind::Choice | PopupKind::SelectedChoice => Line::from(format!("{marker}{text}")),
PopupKind::Hint => hotkey_line(&text, true),
PopupKind::Input
| PopupKind::Body
| PopupKind::Heading
| PopupKind::Error
| PopupKind::Warning => Line::from(text),
}
}
pub(super) fn popup(
frame: &mut Frame<'_>,
lines: &[render::PopupRow],
color: bool,
show_cursor: bool,
) {
let layout = popup_layout(frame.area(), lines.len());
popup_at(frame, lines, color, show_cursor, layout);
}
pub(super) fn popup_at(
frame: &mut Frame<'_>,
lines: &[render::PopupRow],
color: bool,
show_cursor: bool,
layout: PopupLayout,
) {
use render::PopupKind;
let PopupLayout {
outer,
content,
bordered,
} = layout;
frame.render_widget(Clear, outer);
let body_style = popup_row_style(PopupKind::Action, false, color);
let border_style = if color {
body_style.fg(Color::DarkGray)
} else {
body_style
};
let block = Block::default().style(body_style);
let block = if bordered {
block.borders(Borders::ALL).border_style(border_style)
} else {
block
};
frame.render_widget(block, outer);
let mut cursor = (content.x, content.y);
let mut explicit_cursor = None;
for (index, row) in lines.iter().take(usize::from(content.height)).enumerate() {
let width = usize::from(content.width);
let text = if row.cursor.is_some() {
render::clipped(&row.text, width)
} else if row.kind == PopupKind::Input {
let safe = render::clipped(&row.text, usize::MAX);
text_tail(&safe, width.saturating_sub(1)).0
} else {
let marker_width = usize::from(matches!(
row.kind,
PopupKind::Action
| PopupKind::SelectedAction
| PopupKind::Choice
| PopupKind::SelectedChoice
)) * 2;
render::clipped(&row.text, width.saturating_sub(marker_width))
};
if row.kind == PopupKind::Input {
cursor = (
content.x + (text.width() as u16).min(content.width.saturating_sub(1)),
content.y + index as u16,
);
} else if matches!(
row.kind,
PopupKind::SelectedAction | PopupKind::SelectedChoice
) {
cursor = (content.x, content.y + index as u16);
}
if let Some(column) = row.cursor {
explicit_cursor = Some((
content.x + column.min(width.saturating_sub(1)) as u16,
content.y + index as u16,
));
}
let style = popup_row_style(row.kind, index == 0, color);
let line = popup_row_line(text, row.kind, color);
frame.render_widget(
Paragraph::new(line).style(style),
Rect::new(content.x, content.y + index as u16, content.width, 1),
);
}
if show_cursor {
frame.set_cursor_position(explicit_cursor.unwrap_or(cursor));
}
}
pub fn panes(area: Rect) -> Panes {
if area.width < COMPACT_COLUMNS {
let list_height = area.height.div_ceil(3);
let details_height = area.height / 3;
let details_y = area.y + list_height;
let editor_y = details_y + details_height;
return Panes {
list: Rect::new(area.x, area.y, area.width, list_height),
details: Rect::new(area.x, details_y, area.width, details_height),
editor: Rect::new(
area.x,
editor_y,
area.width,
area.height - list_height - details_height,
),
};
}
let list_height = ((u32::from(area.height) * 35 + 50) / 100) as u16;
let list_height = list_height.max(4).min(area.height.saturating_sub(4));
let details_height = ((u32::from(area.height) * 20 + 50) / 100) as u16;
let details_height = details_height
.max(1)
.min(area.height.saturating_sub(list_height + 3));
let editor_y = area.y + list_height + details_height;
if area.width >= 150 {
let details_width = ((u32::from(area.width) * 40 + 50) / 100) as u16;
let list_width = area.width - details_width;
let upper_height = list_height + details_height;
return Panes {
list: Rect::new(area.x, area.y, list_width, upper_height.saturating_sub(1)),
details: Rect::new(area.x + list_width, area.y, details_width, upper_height),
editor: Rect::new(area.x, editor_y, area.width, area.height - upper_height),
};
}
Panes {
list: Rect::new(area.x, area.y, area.width, list_height),
details: Rect::new(area.x, area.y + list_height, area.width, details_height),
editor: Rect::new(
area.x,
editor_y,
area.width,
area.height - list_height - details_height,
),
}
}
fn details_block(area: Rect) -> Block<'static> {
let block = Block::default().padding(Padding::horizontal(2));
if area.height >= 3 && area.width >= 8 {
block.borders(Borders::ALL).border_type(BorderType::Double)
} else {
block
}
}
pub fn details_content(area: Rect) -> Rect {
details_block(area).inner(area)
}
pub fn details_height(area: Rect) -> usize {
usize::from(details_content(area).height)
}
fn list_content(area: Rect, filter_visible: bool) -> Rect {
let filter_height = u16::from(filter_visible);
Rect::new(
area.x,
area.y + filter_height,
area.width,
area.height.saturating_sub(filter_height),
)
}
pub fn filter_visible(
query: &str,
focused: bool,
show_completed: bool,
include_archived: bool,
) -> bool {
focused || !query.is_empty() || !show_completed || include_archived
}
pub fn completed_button(list: Rect) -> Rect {
let archived = archived_button(list);
let width = if list.width >= 37 { 13 } else { 4 };
Rect::new(archived.x.saturating_sub(width + 1), list.y, width, 1)
}
pub fn archived_button(list: Rect) -> Rect {
let width = if list.width >= 37 { 12 } else { 4 };
Rect::new(list.x + list.width.saturating_sub(width), list.y, width, 1)
}
pub fn wheel_area(
size: (u16, u16),
column: u16,
row: u16,
filter_visible: bool,
) -> Option<WheelArea> {
if size.0 < 12 || size.1 < 8 || column >= size.0 || row >= size.1 {
return None;
}
let Panes {
list,
details,
editor,
} = panes(Rect::new(0, 0, size.0, size.1));
if list.contains(Position::new(column, row)) {
Some(WheelArea::List(usize::from(
list_content(list, filter_visible).height,
)))
} else if details_content(details).contains(Position::new(column, row)) {
Some(WheelArea::Details(details_height(details)))
} else if row >= editor.y && row < editor.y + editor.height - 1 {
Some(WheelArea::Editor(usize::from(
editor.height.saturating_sub(2),
)))
} else {
None
}
}
pub struct HitState<'a> {
pub rows: &'a [panel::ListRow],
pub filter_visible: bool,
pub list_top: usize,
pub editor_top: usize,
pub layout: &'a render::Layout,
}
pub fn click_target(
size: (u16, u16),
column: u16,
row: u16,
hit: HitState<'_>,
) -> Option<ClickTarget> {
if size.0 < 12 || size.1 < 8 || column >= size.0 || row >= size.1 {
return None;
}
let Panes { list, editor, .. } = panes(Rect::new(0, 0, size.0, size.1));
if hit.filter_visible && completed_button(list).contains(Position::new(column, row)) {
return Some(ClickTarget::ToggleCompleted);
}
if hit.filter_visible && archived_button(list).contains(Position::new(column, row)) {
return Some(ClickTarget::ToggleArchived);
}
let content = list_content(list, hit.filter_visible);
if content.contains(Position::new(column, row)) {
let index = hit.list_top + usize::from(row - content.y);
return hit.rows.get(index)?.task_id.map(ClickTarget::Task);
}
if row > editor.y && row < editor.y + editor.height - 1 {
let layout_row = hit.editor_top + usize::from(row - editor.y - 1);
if layout_row < hit.layout.rows.len() {
return Some(ClickTarget::Editor(
hit.layout.nearest(layout_row, usize::from(column)),
));
}
}
None
}
fn text_tail(text: &str, available: usize) -> (String, usize) {
let mut suffix = Vec::new();
let mut used = 0;
for grapheme in text.graphemes(true).rev() {
let cells = grapheme.width();
if used + cells > available {
break;
}
suffix.push(grapheme);
used += cells;
}
suffix.reverse();
(suffix.concat(), used)
}
fn filter_line(query: &str, width: usize, focused: bool, color: bool) -> (Line<'static>, u16) {
let label = if width >= 10 {
"Filter: "
} else if width >= 5 {
"F: "
} else {
""
};
let (tail, used) = text_tail(query, width.saturating_sub(label.len() + 1));
let label_style = if color && focused {
Style::default()
.fg(ACCENT)
.bg(Color::Reset)
.add_modifier(Modifier::BOLD)
} else if color {
Style::default().fg(Color::Gray).bg(Color::Reset)
} else {
Style::default()
};
let line = Line::from(vec![Span::styled(label, label_style), Span::raw(tail)]);
let cursor = (label.len() + used).min(width.saturating_sub(1)) as u16;
(line, cursor)
}
fn row_style(status: Option<&str>, selected: bool, color: bool) -> Style {
if !color {
return Style::default();
}
let style = match status {
Some("in_progress") => Style::default().fg(ACCENT),
Some("completed") => Style::default().fg(Color::DarkGray),
Some("error") => Style::default().fg(Color::Red),
_ => Style::default(),
};
if selected {
style.bg(SELECTION_BG)
} else {
style
}
}
fn editor_line(layout: &render::Layout, index: usize, color: bool) -> Line<'static> {
let line = &layout.rows[index];
if !color {
return Line::from(line.clone());
}
let mut spans = Vec::new();
let mut offset = 0;
for &(start, end, kind) in &layout.highlight_spans[index] {
let foreground = match kind {
render::HighlightKind::Image => ACCENT,
render::HighlightKind::Paste => Color::Indexed(222),
};
spans.push(Span::raw(line[offset..start].to_owned()));
spans.push(Span::styled(
line[start..end].to_owned(),
Style::default().fg(foreground),
));
offset = end;
}
spans.push(Span::raw(line[offset..].to_owned()));
Line::from(spans)
}
fn hotkey_line(keys: &str, color: bool) -> Line<'static> {
let safe = render::clipped(keys, usize::MAX);
if !color {
return Line::from(safe);
}
let key_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD);
let mut spans = Vec::new();
for part in safe.split_inclusive(' ') {
let key = part.trim_end_matches(' ');
let shortcut = key.starts_with("Ctrl-")
|| key.starts_with("Shift-")
|| matches!(
key,
"Ctrl+/"
| "Esc"
| "Esc/Ctrl-C"
| "Backspace"
| "Tab"
| "⇥"
| "Tab/Enter"
| "Enter"
| "Enter/Ctrl-S"
| "Enter/^S"
| "Space"
| "Spc"
| "␣"
| "Up/Down"
| "Up/Dn"
| "↑↓"
| "PgUp/PgDn"
| "Pg↑↓"
| "^F"
| "^H"
| "^S"
| "^U"
| "S-↵"
| "↵"
| "y"
| "Y"
| "y/Y"
| "n"
| "n/Esc"
| "r"
| "o"
);
spans.push(Span::styled(
key.to_owned(),
if shortcut {
key_style
} else {
Style::default()
},
));
spans.push(Span::raw(part[key.len()..].to_owned()));
}
Line::from(spans)
}
fn editor(
frame: &mut Frame<'_>,
area: Rect,
editor: render::DashboardEditor<'_>,
color: bool,
) -> Option<Position> {
let body = Rect::new(area.x, area.y + 1, area.width, area.height - 2);
let (row, column) = editor.layout.positions[editor.cursor];
let body_height = usize::from(body.height);
*editor.top = (*editor.top).min(editor.layout.rows.len().saturating_sub(body_height));
if editor.follow_cursor {
if row < *editor.top {
*editor.top = row;
}
if row >= editor.top.saturating_add(body_height) {
*editor.top = row + 1 - body_height;
}
}
let heading_style = if color {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
frame.render_widget(
Paragraph::new(render::clipped(editor.chrome.title, area.width.into()))
.style(heading_style),
Rect::new(area.x, area.y, area.width, 1),
);
let body_style = if color {
Style::default().fg(BODY_FG).bg(BODY_BG)
} else {
Style::default()
};
let lines: Vec<_> = (0..body_height)
.filter_map(|offset| {
let index = editor.top.saturating_add(offset);
(index < editor.layout.rows.len()).then(|| editor_line(editor.layout, index, color))
})
.collect();
frame.render_widget(Paragraph::new(lines).style(body_style), body);
let footer = if editor.chrome.message.is_empty() {
hotkey_line(editor.chrome.keys, color)
} else {
Line::from(render::clipped(editor.chrome.message, area.width.into()))
};
let footer_style = if !color {
Style::default()
} else if editor.chrome.message.is_empty() {
Style::default().fg(Color::Gray)
} else if editor.message_is_error {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Yellow)
};
frame.render_widget(
Paragraph::new(footer).style(footer_style),
Rect::new(area.x, area.y + area.height - 1, area.width, 1),
);
if row >= *editor.top && row < editor.top.saturating_add(body_height) {
Some(Position::new(
body.x.saturating_add(column as u16),
body.y.saturating_add((row - *editor.top) as u16),
))
} else {
None
}
}
fn detail_style(kind: render::DetailKind, color: bool) -> Style {
if !color {
return Style::default();
}
match kind {
render::DetailKind::Section => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
render::DetailKind::Heading => Style::default().add_modifier(Modifier::BOLD),
render::DetailKind::InProgress => Style::default().fg(Color::Cyan),
render::DetailKind::Completed => Style::default().fg(Color::DarkGray),
render::DetailKind::Error => Style::default().fg(Color::Red),
render::DetailKind::Success => Style::default().fg(Color::Green),
render::DetailKind::Muted => Style::default().add_modifier(Modifier::DIM),
render::DetailKind::Warning => Style::default().fg(Color::Yellow),
render::DetailKind::Body => Style::default(),
}
}
fn details(frame: &mut Frame<'_>, area: Rect, view: DetailsView<'_>, color: bool) {
let block = details_block(area).border_style(if color {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
});
let content = block.inner(area);
frame.render_widget(block, area);
let height = usize::from(content.height);
*view.top = (*view.top).min(view.rows.len().saturating_sub(height));
for (offset, row) in view.rows.iter().skip(*view.top).take(height).enumerate() {
let spans = row
.spans
.iter()
.map(|&(start, end, kind)| {
Span::styled(row.text[start..end].to_owned(), detail_style(kind, color))
})
.collect::<Vec<_>>();
let line = if spans.is_empty() {
Line::from(row.text.clone())
} else {
Line::from(spans)
};
frame.render_widget(
Paragraph::new(line).style(detail_style(row.kind, color)),
Rect::new(content.x, content.y + offset as u16, content.width, 1),
);
}
}
pub fn draw(
frame: &mut Frame<'_>,
rows: &[panel::ListRow],
statuses: &HashMap<i64, &str>,
selected: Option<i64>,
list_view: View<'_>,
editor_state: render::DashboardEditor<'_>,
color: bool,
) {
let area = frame.area();
if area.width < 12 || area.height < 8 {
frame.render_widget(
Paragraph::new(render::clipped(
"Resize terminal (min 12x8)",
area.width.into(),
)),
area,
);
if !list_view.hide_cursor {
frame.set_cursor_position((area.x, area.y));
}
return;
}
let Panes {
list,
details: details_area,
editor: editor_area,
} = panes(area);
let filter_visible = filter_visible(
list_view.query,
list_view.focused,
list_view.show_completed,
list_view.include_archived,
);
let content = list_content(list, filter_visible);
let list_height = usize::from(content.height);
*list_view.top = if list_view.follow_selected {
panel::scroll_to(rows, selected, *list_view.top, list_height)
} else {
(*list_view.top).min(rows.len().saturating_sub(list_height))
};
if filter_visible {
let button = completed_button(list);
let query_width = button.x.saturating_sub(list.x + 1);
let (filter, filter_cursor) = filter_line(
list_view.query,
usize::from(query_width),
list_view.focused,
color,
);
let filter_style = if color {
Style::default().fg(BODY_FG).bg(BODY_BG)
} else {
Style::default()
};
frame.render_widget(
Block::default().style(filter_style),
Rect::new(list.x, list.y, list.width, 1),
);
frame.render_widget(
Paragraph::new(filter).style(filter_style),
Rect::new(list.x, list.y, query_width, 1),
);
for (button, enabled, name, compact) in [
(button, list_view.show_completed, "Completed", "C"),
(
archived_button(list),
list_view.include_archived,
"Archived",
"A",
),
] {
let mark = if enabled { '✓' } else { '×' };
let label = if button.width > 4 {
format!("[{mark} {name}]")
} else {
format!("[{mark}{compact}]")
};
let button_style = if color {
filter_style
.fg(if enabled { ACCENT } else { Color::Gray })
.add_modifier(Modifier::BOLD)
} else {
filter_style
};
frame.render_widget(Paragraph::new(label).style(button_style), button);
}
if list_view.focused && !list_view.hide_cursor {
frame.set_cursor_position((list.x + filter_cursor, list.y));
}
}
if rows.is_empty() {
frame.render_widget(
Paragraph::new("No matching tasks."),
Rect::new(content.x, content.y, content.width, 1),
);
}
for (offset, row) in rows
.iter()
.skip(*list_view.top)
.take(list_height)
.enumerate()
{
let is_selected = selected.is_some() && row.task_id == selected;
let style = row_style(
row.task_id.and_then(|id| statuses.get(&id).copied()),
is_selected,
color,
);
let index = list_view.top.saturating_add(offset);
let first = index == 0 || rows[index - 1].task_id != row.task_id;
let marked = first
&& row
.task_id
.is_some_and(|id| list_view.bulk_selected.is_some_and(|ids| ids.contains(&id)));
let mut spans = vec![Span::raw(if marked { "+" } else { LIST_ROW_PREFIX })];
let start = row.description_start.filter(|_| row.dirty).unwrap_or(0);
if row.dirty && row.description_start.is_some() {
spans.push(Span::raw(row.text[..start].to_owned()));
let dirty_style = if color {
Style::default().fg(POPUP_PROMPT_FG).bg(if is_selected {
SELECTION_BG
} else {
BODY_BG
})
} else {
Style::default()
};
spans.push(Span::styled("[*]", dirty_style));
spans.push(Span::raw(" "));
}
if let Some((label_start, label_end)) = row.label_range.filter(|_| color) {
spans.push(Span::raw(row.text[start..label_start].to_owned()));
spans.push(Span::styled(
row.text[label_start..label_end].to_owned(),
Style::default().fg(LABEL_FG),
));
spans.push(Span::raw(row.text[label_end..].to_owned()));
} else {
spans.push(Span::raw(row.text[start..].to_owned()));
}
frame.render_widget(
Paragraph::new(Line::from(spans)).style(style),
Rect::new(content.x, content.y + offset as u16, content.width, 1),
);
}
let empty_details = [render::DetailRow::new(
"Select task to view details.",
render::DetailKind::Muted,
)];
let mut empty_top = 0;
let details_view = list_view.details.unwrap_or(DetailsView {
rows: &empty_details,
top: &mut empty_top,
});
if details_area.height > 0 {
details(frame, details_area, details_view, color);
}
let editor_cursor = editor(frame, editor_area, editor_state, color);
if let Some(cursor) = editor_cursor.filter(|_| !list_view.focused && !list_view.hide_cursor) {
frame.set_cursor_position(cursor);
}
if let Some((lines, layout)) = list_view.modal_lines {
popup_at(frame, lines, color, !list_view.hide_cursor, layout);
}
}