use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use crate::app::{App, InputMode};
use crate::dashboard::utils::{breathe_color, breathe_t, dim_toward_bg, themed_rgb};
use std::collections::HashMap;
use crate::ui::string_to_color;
fn shimmer_spans(text: &str, green: Option<(u8, u8, u8)>) -> Vec<Span<'static>> {
const SWEEP_MS: f64 = 1400.0; const PAUSE_MS: f64 = 1100.0; const WIDTH: f64 = 4.0;
let chars: Vec<char> = text.chars().collect();
let n = chars.len() as f64;
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64;
let t = millis % (SWEEP_MS + PAUSE_MS);
let head = if t < SWEEP_MS {
-WIDTH + (t / SWEEP_MS) * (n + 2.0 * WIDTH)
} else {
f64::INFINITY
};
chars
.iter()
.enumerate()
.map(|(i, c)| {
let d = (head - i as f64).abs();
let intensity = ((WIDTH - d) / WIDTH).clamp(0.0, 1.0);
let style = match green {
Some(g) => {
let (r, gr, b) = lighten(g, intensity * 0.7);
let s = Style::default().fg(Color::Rgb(r, gr, b));
if intensity > 0.75 {
s.add_modifier(Modifier::BOLD)
} else {
s
}
}
None => {
if intensity > 0.7 {
Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD)
} else if intensity > 0.0 {
Style::default().fg(Color::LightGreen)
} else {
Style::default().fg(Color::Green)
}
}
};
Span::styled(c.to_string(), style)
})
.collect()
}
fn lighten((r, g, b): (u8, u8, u8), t: f64) -> (u8, u8, u8) {
let f = |c: u8| (c as f64 + (255.0 - c as f64) * t).round().clamp(0.0, 255.0) as u8;
(f(r), f(g), f(b))
}
fn duration_spans(
duration: &str,
running: bool,
selected: bool,
time_style: Style,
palette: &HashMap<u8, (u8, u8, u8)>,
eye_candy: bool,
) -> Vec<Span<'static>> {
let text = format!("{:>12}", duration);
let mut out = vec![Span::styled("[".to_string(), time_style)];
if selected {
let base = themed_rgb(time_style.fg.unwrap_or(Color::White), palette);
let (r, g, b) = if eye_candy {
breathe_color(base, breathe_t())
} else {
base
};
out.push(Span::styled(
text,
Style::default().fg(Color::Rgb(r, g, b)).add_modifier(Modifier::BOLD),
));
} else if running && eye_candy {
out.extend(shimmer_spans(&text, palette.get(&2).copied()));
} else {
out.push(Span::styled(text, time_style));
}
out.push(Span::styled("]".to_string(), time_style));
out
}
pub fn draw_entries(f: &mut Frame, app: &App, area: Rect) {
let list_area = if app.has_day_row() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
draw_at_work_row(f, app, chunks[0]);
chunks[1]
} else {
area
};
draw_entry_list(f, app, list_area);
}
fn format_duration_seconds(seconds: i64) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if hours > 0 {
format!("{}h {:02}m {:02}s", hours, minutes, secs)
} else if minutes > 0 {
format!("{}m {:02}s", minutes, secs)
} else {
format!("{}s", secs)
}
}
fn draw_at_work_row(f: &mut Frame, app: &App, area: Rect) {
let Some((start, end, manual)) = app.at_work_span() else {
return;
};
let span_seconds = end.signed_duration_since(start).num_seconds().max(0);
let now = chrono::Local::now().naive_local();
let off_work_seconds: i64 = app
.entries
.iter()
.filter(|e| e.off_work)
.map(|e| {
e.end_time
.unwrap_or(now)
.signed_duration_since(e.start_time)
.num_seconds()
.max(0)
})
.sum();
let work_seconds = (span_seconds - off_work_seconds).max(0);
let is_running = app.entries.iter().any(|e| e.is_running());
let selected = app.at_work_selected;
let label_color = if selected { Color::White } else { Color::Cyan };
let muted = if selected {
Color::Gray
} else {
dim_toward_bg(Color::White, &app.term_palette, 0.55)
};
let mut spans = vec![
Span::styled("⊙ ", Style::default().fg(label_color).add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} - {}", start.format("%H:%M"), end.format("%H:%M")),
Style::default().fg(if selected { Color::White } else { Color::Gray }),
),
Span::raw(" "),
Span::styled("Work hours: ", Style::default().fg(if selected { Color::White } else { Color::Gray })),
Span::styled(
format_duration_seconds(work_seconds),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
];
if is_running {
spans.push(Span::styled(
" < running",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
));
}
if off_work_seconds > 0 {
spans.push(Span::styled(
format!(
" ({} - {} off work)",
format_duration_seconds(span_seconds),
format_duration_seconds(off_work_seconds),
),
Style::default().fg(muted),
));
} else {
spans.push(Span::styled(
format!(" ({})", format_duration_seconds(span_seconds)),
Style::default().fg(muted),
));
}
if manual {
spans.push(Span::styled(
" ( manual )",
Style::default().fg(muted),
));
}
let block = Block::default()
.borders(Borders::ALL)
.title("At Work")
.border_style(if selected {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
let style = if selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
let para = Paragraph::new(Line::from(spans)).block(block).style(style);
f.render_widget(para, area);
}
fn draw_entry_list(f: &mut Frame, app: &App, area: Rect) {
let colors: Vec<Color> = app
.config
.colors
.iter()
.map(|s| string_to_color(s))
.collect();
let items: Vec<ListItem> = app
.entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let start = entry.start_time.format("%H:%M").to_string();
let end = entry
.end_time
.map(|t| t.format("%H:%M").to_string())
.unwrap_or_else(|| "...".to_string());
let duration = entry.duration_formatted();
let entry_color = if entry.off_work {
Color::Indexed(247)
} else {
colors[entry.color as usize % colors.len()]
};
let is_selected = !app.at_work_selected && Some(idx) == app.selected_index;
let time_style = if entry.is_running() {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
};
let task_name = if !entry.issue_key.is_empty() {
app.task_names.get(&entry.issue_key).cloned()
} else {
None
};
let dot_color = if is_selected && !app.config.hide_eye_candy {
let (r, g, b) = breathe_color(themed_rgb(entry_color, &app.term_palette), breathe_t());
Color::Rgb(r, g, b)
} else {
entry_color
};
let mut row1_spans = vec![
Span::styled(
"●",
Style::default().fg(dot_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
if task_name.is_some() {
row1_spans.push(Span::styled(
format!("{:5} - {:5}", start, end),
Style::default().fg(Color::Gray),
));
row1_spans.push(Span::raw(" "));
row1_spans.extend(duration_spans(&duration, entry.is_running(), is_selected, time_style, &app.term_palette, !app.config.hide_eye_candy));
row1_spans.push(Span::raw(" "));
row1_spans.push(Span::raw(&entry.description));
if entry.logged {
row1_spans.push(Span::styled(
" ( logged )",
Style::default().fg(Color::Green),
));
}
if entry.off_work {
row1_spans.push(Span::styled(
" ( off work )",
Style::default().fg(Color::Magenta),
));
}
} else {
row1_spans.push(Span::styled(
format!("{:5} - {:5}", start, end),
Style::default().fg(Color::Gray),
));
row1_spans.push(Span::raw(" "));
row1_spans.extend(duration_spans(&duration, entry.is_running(), is_selected, time_style, &app.term_palette, !app.config.hide_eye_candy));
row1_spans.push(Span::raw(" "));
if !entry.issue_key.is_empty() {
row1_spans.push(Span::styled(
format!("[{}] ", entry.issue_key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
row1_spans.push(Span::raw(&entry.description));
if entry.logged {
row1_spans.push(Span::styled(
" ( logged )",
Style::default().fg(Color::Green),
));
}
if entry.off_work {
row1_spans.push(Span::styled(
" ( off work )",
Style::default().fg(Color::Magenta),
));
}
}
if is_selected {
let base = themed_rgb(Color::White, &app.term_palette);
let (r, g, b) = if app.config.hide_eye_candy {
base
} else {
breathe_color(base, breathe_t())
};
row1_spans.push(Span::raw(" "));
row1_spans.push(Span::styled(
"◀",
Style::default()
.fg(Color::Rgb(r, g, b))
.add_modifier(Modifier::BOLD),
));
}
let style = if !app.at_work_selected && Some(idx) == app.selected_index {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
if let Some(name) = task_name {
let secondary_color = if is_selected {
Color::Gray
} else {
Color::DarkGray
};
let row2_spans = vec![
Span::raw(" "),
Span::styled("→ task: ", Style::default().fg(secondary_color)),
Span::styled(
format!("[{}]", entry.issue_key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
name,
Style::default().fg(secondary_color),
),
];
ListItem::new(vec![
Line::from(row1_spans),
Line::from(row2_spans),
]).style(style)
} else {
ListItem::new(Line::from(row1_spans)).style(style)
}
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Time Entries"));
f.render_widget(list, area);
}
pub fn draw_suggestions_modal(f: &mut Frame, app: &App) {
if let InputMode::Creating {
suggestions,
selected_suggestion,
..
} = &app.input_mode
{
let area = f.area();
let modal_width = area.width.min(80);
let modal_height = (suggestions.len() as u16 + 4).min(20);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect {
x: modal_x,
y: modal_y,
width: modal_width,
height: modal_height,
};
let background = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Quick Select (↑↓ to navigate, Enter to select) ")
.style(Style::default().bg(Color::Black));
f.render_widget(background, modal_area);
let inner_area = Rect {
x: modal_area.x + 1,
y: modal_area.y + 1,
width: modal_area.width.saturating_sub(2),
height: modal_area.height.saturating_sub(2),
};
let mut items = vec![];
let new_style = if *selected_suggestion == 0 {
Style::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
items.push(ListItem::new(Line::from(vec![Span::styled(
" → New (blank entry)",
new_style,
)])));
for (idx, (issue_key, description, usage_count)) in suggestions.iter().enumerate() {
let is_selected = *selected_suggestion == idx + 1;
let style = if is_selected {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
let spans = vec![
Span::raw(" "),
Span::styled(
format!("[{}]", issue_key),
if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
},
),
Span::raw(" "),
Span::styled(description, style),
Span::raw(" "),
Span::styled(
format!("({}×)", usage_count),
if is_selected {
Style::default().bg(Color::DarkGray).fg(Color::DarkGray)
} else {
Style::default().fg(Color::DarkGray)
},
),
];
items.push(ListItem::new(Line::from(spans)).style(style));
}
let list = List::new(items);
f.render_widget(list, inner_area);
}
}
pub fn draw_operations_menu(f: &mut Frame, app: &App) {
let operations = [
"Sync all task names from Jira",
"Weekly summary",
"Keyboard shortcuts",
"Settings",
];
let area = f.area();
let modal_width = 50u16.min(area.width);
let modal_height = (operations.len() as u16 + 2).min(area.height);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect {
x: modal_x,
y: modal_y,
width: modal_width,
height: modal_height,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Operations (↑↓ Enter, Esc to close) ")
.style(Style::default().bg(Color::Black));
f.render_widget(block, modal_area);
let inner_area = Rect {
x: modal_area.x + 1,
y: modal_area.y + 1,
width: modal_area.width.saturating_sub(2),
height: modal_area.height.saturating_sub(2),
};
if let InputMode::OperationsMenu { selected_index } = &app.input_mode {
let items: Vec<ListItem> = operations
.iter()
.enumerate()
.map(|(i, op)| {
let style = if i == *selected_index {
Style::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(*op, style),
])).style(style)
})
.collect();
let list = List::new(items);
f.render_widget(list, inner_area);
}
}
pub fn draw_hotkeys(f: &mut Frame, _app: &App) {
let shortcuts = [
("Shift+L", "Mark entry logged (without sending to Jira)"),
("t", "Open Tasks"),
("Shift+↑/↓", "Move entry up/down"),
("K / J", "Move entry up/down (works on any terminal)"),
("↑ on top entry", "Select the \"At Work\" day row"),
("w", "Show changelog / What's New"),
];
let area = f.area();
let modal_width = 60u16.min(area.width);
let modal_height = (shortcuts.len() as u16 + 2).min(area.height);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect {
x: modal_x,
y: modal_y,
width: modal_width,
height: modal_height,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Keyboard shortcuts (any key to close) ")
.style(Style::default().bg(Color::Black));
f.render_widget(block, modal_area);
let inner_area = Rect {
x: modal_area.x + 1,
y: modal_area.y + 1,
width: modal_area.width.saturating_sub(2),
height: modal_area.height.saturating_sub(2),
};
let lines: Vec<Line> = shortcuts
.iter()
.map(|(key, desc)| {
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{:<16}", key),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(*desc),
])
})
.collect();
f.render_widget(Paragraph::new(lines), inner_area);
}