Visual indicators for skips and overlaps, fix bottom bar

This commit is contained in:
Ian Keane 2025-11-25 19:32:28 -05:00
parent 31049a53dd
commit c61e66b1f6
3 changed files with 253 additions and 83 deletions

283
src/ui.rs
View file

@ -6,6 +6,8 @@ use ratatui::{
Frame,
};
use chrono::DateTime;
use crate::{
app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen},
state::TimeItem,
@ -27,20 +29,21 @@ pub fn render(frame: &mut Frame, app: &App) {
fn render_main(frame: &mut Frame, app: &App) {
// Calculate layout - accounting for bottom bar if needed
let show_bottom_bar = app.config.show_help_hint;
let has_active_timer = app.state.active_timer.is_some();
let has_status = app.status_message.is_some();
let show_help_hint = app.config.show_help_hint;
// Show bottom bar if we have any of: timer, status, or help hint
let show_bottom_bar = has_active_timer || has_status || show_help_hint;
let bottom_height = if show_bottom_bar {
if has_status {
2
2 // Need extra line for status
} else {
1
1 // Just tracking or help hint
}
} else {
if has_status {
1
} else {
0
}
0
};
// Count enabled sections
@ -70,66 +73,125 @@ fn render_main(frame: &mut Frame, app: &App) {
}
// Build constraints for enabled sections
let section_percentage = 100 / enabled_sections as u16;
let mut constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
if bottom_height > 0 {
constraints.push(Constraint::Length(bottom_height));
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
// Render enabled sections
let mut chunk_idx = 0;
let mut constraints = Vec::new();
if app.config.show_permanent {
render_section(
frame,
chunks[chunk_idx],
"Permanent Items",
&app.state.permanent_items,
app.state.current_pane == 0,
app.state.selected_indices[0],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recurring {
render_section(
frame,
chunks[chunk_idx],
"Recurring Items",
&app.state.recurring_items,
app.state.current_pane == 1,
app.state.selected_indices[1],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recent {
render_section(
frame,
chunks[chunk_idx],
"Ad-Hoc Items",
&app.state.recent_items,
app.state.current_pane == 2,
app.state.selected_indices[2],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
// Render bottom bar if needed
if bottom_height > 0 {
let bottom_area = chunks[chunk_idx];
render_bottom_bar(frame, bottom_area, app);
// Reserve space for bottom bar first, then split remainder among sections
constraints.push(Constraint::Min(0)); // Sections get remaining space
constraints.push(Constraint::Length(bottom_height)); // Bottom bar gets fixed height
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
// Split the top area among enabled sections
let section_percentage = 100 / enabled_sections as u16;
let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(section_constraints)
.split(layout[0]);
// Render enabled sections
let mut chunk_idx = 0;
if app.config.show_permanent {
render_section(
frame,
chunks[chunk_idx],
"Permanent Items",
&app.state.permanent_items,
app.state.current_pane == 0,
app.state.selected_indices[0],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recurring {
render_section(
frame,
chunks[chunk_idx],
"Recurring Items",
&app.state.recurring_items,
app.state.current_pane == 1,
app.state.selected_indices[1],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recent {
render_section(
frame,
chunks[chunk_idx],
"Ad-Hoc Items",
&app.state.recent_items,
app.state.current_pane == 2,
app.state.selected_indices[2],
app.state.current_column,
app.config.multi_column,
);
}
// Render bottom bar
render_bottom_bar(frame, layout[1], app);
} else {
// No bottom bar - just render sections
let section_percentage = 100 / enabled_sections as u16;
let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
let mut chunk_idx = 0;
if app.config.show_permanent {
render_section(
frame,
chunks[chunk_idx],
"Permanent Items",
&app.state.permanent_items,
app.state.current_pane == 0,
app.state.selected_indices[0],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recurring {
render_section(
frame,
chunks[chunk_idx],
"Recurring Items",
&app.state.recurring_items,
app.state.current_pane == 1,
app.state.selected_indices[1],
app.state.current_column,
app.config.multi_column,
);
chunk_idx += 1;
}
if app.config.show_recent {
render_section(
frame,
chunks[chunk_idx],
"Ad-Hoc Items",
&app.state.recent_items,
app.state.current_pane == 2,
app.state.selected_indices[2],
app.state.current_column,
app.config.multi_column,
);
}
}
}
@ -363,6 +425,10 @@ fn render_config_help(frame: &mut Frame, app: &App) {
" Default: true",
" Show the 'Ad-Hoc Items' section",
"",
"show_time_gaps: true/false",
" Default: true",
" In log view (by date), show '---' separator for 5+ minute gaps",
"",
"Example configuration:",
"---",
"show_help_hint: true",
@ -371,6 +437,7 @@ fn render_config_help(frame: &mut Frame, app: &App) {
"show_permanent: true",
"show_recurring: true",
"show_recent: false",
"show_time_gaps: true",
"projects:",
" - work",
" - personal",
@ -677,10 +744,11 @@ fn render_log_view(frame: &mut Frame, app: &App) {
// Just find the specific entry line
frame_count = 0;
for (idx, line) in app.log_view_content.iter().enumerate() {
// Use is_entry_line logic which excludes separator lines
let is_entry = if is_by_project {
line.starts_with("\t\t")
} else {
line.starts_with('\t') && !line.starts_with("\t\t")
line.starts_with('\t') && !line.starts_with("\t\t") && line.trim() != "---"
};
if is_entry {
@ -740,17 +808,88 @@ fn render_log_view(frame: &mut Frame, app: &App) {
}
}
// Third pass: render with highlighting
// Third pass: render with highlighting and overlap detection
let is_by_date = matches!(app.log_view_grouping, LogViewGrouping::ByDate);
// Pre-calculate overlaps for efficiency - check consecutive entries within same day
let mut overlap_entry_indices = std::collections::HashSet::new();
if is_by_date && app.log_view_frame_indices.len() > 1 {
// Track which day we're in to avoid comparing across days
let mut current_day_start_entry = 0;
let mut in_day = false;
for (line_idx, line) in app.log_view_content.iter().enumerate() {
// New day header
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_day_start_entry = app.log_view_content[..line_idx]
.iter()
.filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---")
.count();
in_day = true;
} else if in_day && line.starts_with('\t') && !line.is_empty() && line.trim() != "---" {
// This is an entry line
let entry_idx = app.log_view_content[..=line_idx]
.iter()
.filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---")
.count()
.saturating_sub(1);
// Only check overlap if not the first entry of the day
if entry_idx > current_day_start_entry {
let current_frame_idx = app.log_view_frame_indices[entry_idx];
let prev_frame_idx = app.log_view_frame_indices[entry_idx - 1];
if let (Some(current), Some(prev)) = (
app.log_view_frames.get(current_frame_idx),
app.log_view_frames.get(prev_frame_idx),
) {
if let (Ok(curr_start), Ok(prev_stop)) = (
DateTime::parse_from_rfc3339(&current.start),
DateTime::parse_from_rfc3339(&prev.stop),
) {
// Overlap if current starts before previous ends
if curr_start < prev_stop {
overlap_entry_indices.insert(entry_idx);
}
}
}
}
}
}
}
app.log_view_content
.iter()
.enumerate()
.map(|(idx, line)| {
let is_selected = idx >= selected_line_start && idx <= selected_line_end;
// Skip styling for separator lines
let is_separator = line.trim() == "---";
// Check if this line corresponds to an overlapping entry
let has_overlap = if !is_separator && is_by_date && line.starts_with('\t') && !line.is_empty() {
// Count which entry this is (0-based in display order)
// Only count actual entry lines, not separator lines
let entry_idx = app.log_view_content[..=idx]
.iter()
.filter(|l| l.starts_with('\t') && !l.is_empty() && l.trim() != "---")
.count()
.saturating_sub(1);
overlap_entry_indices.contains(&entry_idx)
} else {
false
};
let style = if is_selected {
let style = if is_separator {
Style::default().fg(Color::DarkGray) // Dim the separator
} else if is_selected {
Style::default()
.fg(ACTIVE_COLOR)
.add_modifier(Modifier::REVERSED)
} else if has_overlap {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
@ -819,6 +958,12 @@ fn render_log_view_help(frame: &mut Frame, app: &App) {
"- c: Copy selection to clipboard (works at all levels)",
" Copies based on current selection level",
"",
"Visual Indicators (By Date view only):",
"- Yellow highlight: Entry overlaps with previous entry's time",
" (starts before the previous entry ended)",
"- '---' separator: 5+ minute gap between entries",
" (can be disabled with show_time_gaps: false in config)",
"",
"Other:",
"- ?: Show/hide this help",
"- q or ESC: Return to main screen",