use chrono::{Datelike, Local};
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::config::Config;
use crate::ui_settings;
pub fn draw(f: &mut Frame, app: &App) {
// Check if we're in settings mode
if matches!(app.input_mode, InputMode::Settings { .. }) {
ui_settings::draw_settings(f, app);
return;
}
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(10), // Main area (timeline + entries)
Constraint::Length(3), // Footer / Help
])
.split(f.area());
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(35), // Timeline (left side)
Constraint::Min(40), // Entries list (middle)
Constraint::Length(30), // Weekly stats (right side)
])
.split(main_chunks[1]);
draw_header(f, app, main_chunks[0]);
draw_timeline(f, app, content_chunks[0]);
draw_entries(f, app, content_chunks[1]);
draw_weekly_stats(f, app, content_chunks[2]);
draw_footer(f, app, main_chunks[2]);
// Draw suggestions modal on top if creating with suggestions
if let InputMode::Creating { suggestions, .. } = &app.input_mode {
if !suggestions.is_empty() {
draw_suggestions_modal(f, app);
}
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let date_str = app.current_date.format(&app.config.date_format).to_string();
// Calculate stats
let (day_start, day_end_or_now, total_time) = if !app.entries.is_empty() {
let start = app.entries.first().unwrap().start_time;
let end = if app.entries.iter().any(|e| e.is_running()) {
Local::now().naive_local()
} else {
app.entries.last().and_then(|e| e.end_time).unwrap_or(start)
};
let duration = end.signed_duration_since(start);
(
start.format("%H:%M").to_string(),
end.format("%H:%M").to_string(),
format_duration_seconds(duration.num_seconds()),
)
} else {
("--:--".to_string(), "--:--".to_string(), "0m".to_string())
};
let is_running = app.entries.iter().any(|e| e.is_running());
let status = if is_running { "Running" } else { "Stopped" };
let status_color = if is_running {
Color::Green
} else {
Color::Gray
};
let header_text = vec![Line::from(vec![
Span::styled(
"Time Tracker",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" | "),
Span::styled(date_str, Style::default().fg(Color::Yellow)),
Span::raw(" | Day: "),
Span::styled(
format!("{} - {}", day_start, day_end_or_now),
Style::default().fg(Color::Magenta),
),
Span::raw(" | Total Time: "),
Span::styled(
total_time,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" | "),
Span::styled(
status,
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
])];
let header = Paragraph::new(header_text).block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}
fn draw_timeline(f: &mut Frame, app: &App, area: Rect) {
if app.entries.is_empty() {
let empty = Paragraph::new("No entries\nfor this day.\n\nPress 'n'\nto create one.")
.block(Block::default().borders(Borders::ALL).title("Timeline"))
.style(Style::default().fg(Color::Gray));
f.render_widget(empty, area);
return;
}
// Get editing times to potentially expand the range
let (editing_start, editing_end, editing_field) = match &app.input_mode {
InputMode::Editing {
start_time,
end_time,
current_field,
..
}
| InputMode::Creating {
start_time,
end_time,
current_field,
..
} => {
let start = chrono::NaiveTime::parse_from_str(start_time, "%H:%M")
.ok()
.map(|t| app.current_date.and_time(t));
let end = if !end_time.is_empty() {
chrono::NaiveTime::parse_from_str(end_time, "%H:%M")
.ok()
.map(|t| app.current_date.and_time(t))
} else {
None
};
(start, end, Some(*current_field))
}
_ => (None, None, None),
};
// Get time bounds from entries
let mut start_time = app.entries.first().unwrap().start_time;
let has_running = app.entries.iter().any(|e| e.is_running());
let mut end_time = if has_running {
Local::now().naive_local()
} else {
app.entries
.iter()
.filter_map(|e| e.end_time)
.max()
.unwrap_or(start_time)
};
// Expand range to include editing times
if let Some(edit_start) = editing_start {
start_time = start_time.min(edit_start);
}
if let Some(edit_end) = editing_end {
end_time = end_time.max(edit_end);
}
// Ensure minimum range of at least 5 minutes for better visibility
let min_duration = chrono::Duration::minutes(5);
if end_time.signed_duration_since(start_time) < min_duration {
end_time = start_time + min_duration;
}
let total_duration = end_time.signed_duration_since(start_time);
let total_seconds = total_duration.num_seconds().max(1) as f64;
// Build vertical timeline
let mut lines = vec![];
// Header info
lines.push(Line::from(vec![
Span::styled("Start: ", Style::default().fg(Color::Gray)),
Span::styled(
start_time.format("%H:%M").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(
if has_running { "Now: " } else { "End: " },
Style::default().fg(Color::Gray),
),
Span::styled(
end_time.format("%H:%M").to_string(),
Style::default()
.fg(if has_running {
Color::Green
} else {
Color::Yellow
})
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Total: ", Style::default().fg(Color::Gray)),
Span::styled(
format_duration_seconds(total_seconds as i64),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"━".repeat(31),
Style::default().fg(Color::DarkGray),
)));
// Calculate vertical bar height
let bar_height = (area.height.saturating_sub(10)) as usize; // Account for header and borders
// Use colors from config
let colors: Vec<Color> = app
.config
.colors
.iter()
.map(|s| string_to_color(s))
.collect();
// Collect editing indicator times to always show them
let mut editing_times = vec![];
if let Some(s) = editing_start {
editing_times.push((s, true)); // true = start
}
if let Some(e) = editing_end {
editing_times.push((e, false)); // false = end
}
// Pre-calculate first and last row indices for each running entry
let mut entry_row_bounds = vec![];
for entry in app.entries.iter() {
if !entry.is_running() {
continue;
}
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
let mut first_row = None;
let mut last_row = None;
for r in 0..=bar_height {
let r_seconds = (total_seconds * r as f64 / bar_height as f64) as i64;
if r_seconds >= entry_start_offset && r_seconds < entry_end_offset {
if first_row.is_none() {
first_row = Some(r);
}
last_row = Some(r);
}
}
if let (Some(first), Some(last)) = (first_row, last_row) {
entry_row_bounds.push((entry.id, first, last));
}
}
// Build map of which entries overlap at each row (for showing multiple entries)
let mut row_entries: std::collections::HashMap<usize, Vec<(i64, Color, bool)>> =
std::collections::HashMap::new();
for entry in app.entries.iter() {
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
let entry_color = colors[entry.color as usize % colors.len()];
for r in 0..=bar_height {
let r_seconds = (total_seconds * r as f64 / bar_height as f64) as i64;
if r_seconds >= entry_start_offset && r_seconds < entry_end_offset {
row_entries.entry(r).or_insert_with(Vec::new).push((
entry.id,
entry_color,
entry.is_running(),
));
}
}
}
// Build vertical bars for each row
// We want to ensure the last row shows the end time, so we use <= bar_height
for row in 0..=bar_height {
let row_start_seconds = (total_seconds * row as f64 / bar_height as f64) as i64;
let row_start_time = start_time + chrono::Duration::seconds(row_start_seconds);
// Check if we should force-show an editing indicator at this position
let mut forced_indicator = None;
for (edit_time, is_start) in &editing_times {
let edit_offset = edit_time.signed_duration_since(start_time).num_seconds();
// Show indicator if we're close to this time or if no row would naturally show it
if (row_start_seconds as f64)
>= (edit_offset as f64 - total_seconds / (bar_height as f64) / 2.0)
&& (row_start_seconds as f64)
<= (edit_offset as f64 + total_seconds / (bar_height as f64) / 2.0)
{
forced_indicator = Some((*edit_time, *is_start));
break;
}
}
// Find which entry (if any) covers this time slice
let mut entry_info = None;
for entry in app.entries.iter() {
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
if row_start_seconds >= entry_start_offset && row_start_seconds < entry_end_offset {
// Check if this is first or last row for a running entry
let mut is_first_row = false;
let mut is_last_row = false;
if entry.is_running() {
for (entry_id, first, last) in &entry_row_bounds {
if *entry_id == entry.id {
is_first_row = row == *first;
is_last_row = row == *last;
break;
}
}
}
entry_info = Some((
colors[entry.color as usize % colors.len()],
entry.is_running(),
is_first_row,
is_last_row,
));
break;
}
}
let line = if let Some((edit_time, is_start)) = forced_indicator {
// Show editing indicator
let time_label = edit_time.format("%H:%M").to_string();
if is_start {
let is_active = editing_field == Some(1);
let bg_color = if is_active { Color::White } else { Color::Gray };
let fg_color = Color::Black;
let text = format!("{} START {}", time_label, time_label);
let padding = 26usize.saturating_sub(text.len());
Line::from(vec![Span::styled(
format!("{}{}", text, " ".repeat(padding)),
Style::default()
.fg(fg_color)
.bg(bg_color)
.add_modifier(Modifier::BOLD),
)])
} else {
let is_active = editing_field == Some(2);
let bg_color = if is_active { Color::White } else { Color::Gray };
let fg_color = Color::Black;
let text = format!("{} END {}", time_label, time_label);
let padding = 26usize.saturating_sub(text.len());
Line::from(vec![Span::styled(
format!("{}{}", text, " ".repeat(padding)),
Style::default()
.fg(fg_color)
.bg(bg_color)
.add_modifier(Modifier::BOLD),
)])
}
} else {
let time_label = row_start_time.format("%H:%M").to_string();
// Get all entries at this row for overlap detection
let entries_at_row = row_entries.get(&row).map(|v| v.as_slice()).unwrap_or(&[]);
let has_overlap = entries_at_row.len() > 1;
if let Some((color, is_running, is_first_row, is_last_row)) = entry_info {
let mut spans = vec![Span::styled(
format!("{} ", time_label),
Style::default().fg(Color::DarkGray),
)];
if is_running {
// Running entry: green edges and first/last rows, colored content
if is_first_row || is_last_row {
// First or last row: all green
spans.push(Span::styled(
"█".repeat(20),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
} else {
// Middle rows: green edges, colored content
spans.push(Span::styled(
"█",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"█".repeat(18),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"█",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
}
} else {
// Stopped entry: just the color
spans.push(Span::styled("█".repeat(20), Style::default().fg(color)));
}
// Add overlap indicators (small circles after the bar)
if has_overlap {
spans.push(Span::raw(" "));
for (idx, (_, overlap_color, overlap_running)) in
entries_at_row.iter().enumerate()
{
if idx > 0 {
// Skip the first entry (already shown in main bar)
if *overlap_running {
// Running overlapping entry: show both green and color
spans.push(Span::styled(
"●",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"●",
Style::default()
.fg(*overlap_color)
.add_modifier(Modifier::BOLD),
));
} else {
// Stopped overlapping entry: just the color
spans.push(Span::styled("●", Style::default().fg(*overlap_color)));
}
}
}
}
Line::from(spans)
} else {
Line::from(vec![
Span::styled(
format!("{} ", time_label),
Style::default().fg(Color::DarkGray),
),
Span::styled("░".repeat(20), Style::default().fg(Color::DarkGray)),
])
}
};
lines.push(line);
}
let timeline =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Timeline"));
f.render_widget(timeline, area);
}
fn draw_entries(f: &mut Frame, app: &App, area: Rect) {
// Use colors from config
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 = colors[entry.color as usize % colors.len()];
let mut spans = vec![
Span::styled(
"●",
Style::default()
.fg(entry_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{:5} - {:5}", start, end),
Style::default().fg(Color::Gray),
),
Span::raw(" "),
Span::styled(
if entry.is_running() { "[" } else { "[" },
if entry.is_running() {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
},
),
Span::styled(
format!("{:>12}", duration),
if entry.is_running() {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
},
),
Span::styled(
"]",
if entry.is_running() {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
},
),
Span::raw(" "),
];
if !entry.issue_key.is_empty() {
spans.push(Span::styled(
format!("[{}] ", entry.issue_key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::raw(&entry.description));
if entry.logged {
spans.push(Span::styled(
" ( logged )",
Style::default().fg(Color::Green),
));
}
let style = if Some(idx) == app.selected_index {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
ListItem::new(Line::from(spans)).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Time Entries"));
f.render_widget(list, area);
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let help_text = match &app.input_mode {
InputMode::Normal => {
// If there's a status message, show it along with help
if let Some(status) = &app.status_message {
vec![
Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(status),
Span::styled(" [ESC to clear]", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled("f", Style::default().fg(Color::Cyan)),
Span::raw(": Fetch Task "),
Span::styled("w", Style::default().fg(Color::Cyan)),
Span::raw(": Create Worklog "),
Span::styled("v", Style::default().fg(Color::Cyan)),
Span::raw(": Verify Worklog "),
Span::styled("l", Style::default().fg(Color::Cyan)),
Span::raw(": Log "),
Span::styled("n", Style::default().fg(Color::Cyan)),
Span::raw(": New "),
Span::styled("e", Style::default().fg(Color::Cyan)),
Span::raw(": Edit "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(": Quit"),
]),
]
} else {
vec![Line::from(vec![
Span::styled("n", Style::default().fg(Color::Cyan)),
Span::raw(": New "),
Span::styled("e", Style::default().fg(Color::Cyan)),
Span::raw(": Edit "),
Span::styled("d/D", Style::default().fg(Color::Cyan)),
Span::raw(": Delete "),
Span::styled("s", Style::default().fg(Color::Cyan)),
Span::raw(": Stop/Restart "),
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(": Copy Time "),
Span::styled("l", Style::default().fg(Color::Cyan)),
Span::raw(": Log to JIRA "),
Span::styled("Shift+L", Style::default().fg(Color::Cyan)),
Span::raw(": Toggle Logged "),
Span::styled("f", Style::default().fg(Color::Cyan)),
Span::raw(": Fetch Task "),
Span::styled("w", Style::default().fg(Color::Cyan)),
Span::raw(": Worklog "),
Span::styled("v", Style::default().fg(Color::Cyan)),
Span::raw(": Verify "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(": Quit"),
])]
}
}
InputMode::Editing { field, .. } => {
vec![Line::from(vec![
Span::raw(format!("Editing: {} | ", field)),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(": Save "),
Span::styled("Tab", Style::default().fg(Color::Cyan)),
Span::raw(": Next Field "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(": Cancel"),
])]
}
InputMode::Creating { field, .. } => {
vec![Line::from(vec![
Span::raw(format!("New Entry: {} | ", field)),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(": Save "),
Span::styled("Tab", Style::default().fg(Color::Cyan)),
Span::raw(": Next Field "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(": Cancel"),
])]
}
InputMode::ConfirmDelete { .. } => {
vec![Line::from(vec![
Span::styled(
"⚠ Delete this entry? ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("Y", Style::default().fg(Color::Green)),
Span::raw(": Yes "),
Span::styled("N", Style::default().fg(Color::Red)),
Span::raw(": No "),
Span::styled("Esc", Style::default().fg(Color::Gray)),
Span::raw(": Cancel"),
])]
}
InputMode::Config { field, .. } => {
vec![Line::from(vec![
Span::raw(format!("Configuration: {} | ", field)),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(": Save "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(": Cancel"),
])]
}
InputMode::Settings { .. } => {
// Settings mode has its own footer, so return empty
vec![]
}
};
let footer = Paragraph::new(help_text).block(Block::default().borders(Borders::ALL));
f.render_widget(footer, 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_weekly_stats(f: &mut Frame, app: &App, area: Rect) {
let days_from_monday = app.current_date.weekday().num_days_from_monday();
let week_start = app.current_date - chrono::Duration::days(days_from_monday as i64);
let week_end = week_start + chrono::Duration::days(6);
// Get week number (ISO 8601 week)
let week_number = app.current_date.iso_week().week();
let title = format!(
"W{} ({} - {})",
week_number,
week_start.format(&app.config.date_format),
week_end.format(&app.config.date_format)
);
let mut lines = vec![];
let mut total_minutes = 0i64;
let mut has_issued = false;
for (label, minutes) in &app.weekly_stats {
total_minutes += minutes;
let hours = minutes / 60;
let mins = minutes % 60;
let duration_str = if hours > 0 {
format!("{}h {:02}m", hours, mins)
} else {
format!("{}m", mins)
};
// Check if this is the first non-issued item
if label.is_empty() {
if !has_issued {
// First non-issued item, no separator needed
} else if !lines.is_empty() {
// Add separator before non-issued items
lines.push(Line::from(Span::styled(
"─".repeat(26),
Style::default().fg(Color::DarkGray),
)));
}
} else {
has_issued = true;
}
let (display_label, color) = if label.is_empty() {
("(no label)".to_string(), Color::DarkGray)
} else {
(label.clone(), Color::Yellow)
};
// Truncate label if too long
let display_label = if display_label.len() > 12 {
format!("{}...", &display_label[..9])
} else {
format!("{:<12}", display_label)
};
lines.push(Line::from(vec![
Span::styled(
display_label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{:>10}", duration_str),
Style::default().fg(Color::Cyan),
),
]));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"No entries this week",
Style::default().fg(Color::DarkGray),
)));
} else {
// Add separator and total
lines.push(Line::from(Span::raw("─".repeat(26))));
let total_hours = total_minutes / 60;
let total_mins = total_minutes % 60;
let total_str = if total_hours > 0 {
format!("{}h {:02}m", total_hours, total_mins)
} else {
format!("{}m", total_mins)
};
lines.push(Line::from(vec![
Span::styled(
"Total ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{:>10}", total_str),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]));
}
let stats = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(stats, area);
}
pub fn string_to_color(color_str: &str) -> Color {
match color_str {
"Blue" => Color::Blue,
"Cyan" => Color::Cyan,
"Green" => Color::Green,
"Yellow" => Color::Yellow,
"Magenta" => Color::Magenta,
"Red" => Color::Red,
"LightBlue" => Color::LightBlue,
"LightCyan" => Color::LightCyan,
"LightGreen" => Color::LightGreen,
"LightYellow" => Color::LightYellow,
"LightMagenta" => Color::LightMagenta,
"LightRed" => Color::LightRed,
"DarkGray" => Color::DarkGray,
"Gray" => Color::Gray,
"White" => Color::White,
"Black" => Color::Black,
_ => Color::White,
}
}
fn draw_suggestions_modal(f: &mut Frame, app: &App) {
if let InputMode::Settings {
jira_url,
date_format,
browser_command,
colors,
current_field,
..
} = &app.input_mode
{
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(10), // Settings form
Constraint::Length(4), // Footer / Help
])
.split(f.area());
// Header
let header_text = vec![Line::from(vec![Span::styled(
"⚙ Settings",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)])];
let header = Paragraph::new(header_text).block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
// Settings form
let mut lines = vec![];
// JIRA URL field
let jira_style = if *current_field == 0 {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(
" JIRA URL: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(jira_url.clone(), jira_style),
]));
lines.push(Line::from(""));
// Date format field
let date_format_style = if *current_field == 1 {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(
" Date Format: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(date_format.clone(), date_format_style),
Span::styled(" (e.g., %d.%m.-%y)", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
// Browser command field
let browser_command_style = if *current_field == 2 {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(
" Browser Command: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(browser_command.clone(), browser_command_style),
Span::styled(
" (e.g., xdg-open, firefox)",
Style::default().fg(Color::DarkGray),
),
]));
lines.push(Line::from(""));
// Color fields
lines.push(Line::from(Span::styled(
" Color Palette:",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for (idx, color_name) in Config::color_names().iter().enumerate() {
let field_idx = idx + 3;
let is_selected = *current_field == field_idx;
let color_value = &colors[idx];
let actual_color = string_to_color(color_value);
let mut spans = vec![
Span::raw(" "),
Span::styled(
format!("{:<10}", color_name),
Style::default().fg(Color::Gray),
),
Span::raw(" "),
];
if is_selected {
spans.push(Span::styled(
"◀ ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
"███",
Style::default()
.fg(actual_color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
let value_style = if is_selected {
Style::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(actual_color)
};
spans.push(Span::styled(format!("{:<15}", color_value), value_style));
if is_selected {
spans.push(Span::styled(
" ▶",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(spans));
}
let settings_form = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Configuration"),
);
f.render_widget(settings_form, chunks[1]);
// Footer
let help_text = vec![
Line::from(vec![
Span::styled("Tab/Shift+Tab", Style::default().fg(Color::Cyan)),
Span::raw(": Navigate "),
Span::styled("←/→", Style::default().fg(Color::Cyan)),
Span::raw(": Change Color "),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(": Save "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(": Cancel"),
]),
Line::from(vec![
Span::styled(
"Tip:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Use arrow keys to cycle through available colors for each slot"),
]),
];
let footer = Paragraph::new(help_text).block(Block::default().borders(Borders::ALL));
f.render_widget(footer, chunks[2]);
}
}
fn draw_suggestions_modal(f: &mut Frame, app: &App) {
if let InputMode::Creating {
suggestions,
selected_suggestion,
..
} = &app.input_mode
{
// Calculate modal size - centered and smaller than full screen
let area = f.area();
let modal_width = area.width.min(80);
let modal_height = (suggestions.len() as u16 + 4).min(20); // +4 for borders and "new" option
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,
};
// Clear background with a semi-transparent effect (we'll use borders to simulate this)
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);
// Inner area for the list
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),
};
// Build items list
let mut items = vec![];
// First item: "New" option
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,
)])));
// Add all suggestions
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);
}
}