chute_kun/lib/
app.rs

1use crate::config::Config;
2use crate::date::today_ymd;
3use crate::task::{DayPlan, Task};
4use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
5use ratatui::layout::Rect;
6use std::time::Instant;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum View {
10    Past,
11    #[default]
12    Today,
13    Future,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum DisplayMode {
18    #[default]
19    List,
20    Calendar,
21}
22
23impl View {
24    fn next(self) -> Self {
25        match self {
26            View::Past => View::Today,
27            View::Today => View::Future,
28            View::Future => View::Past,
29        }
30    }
31    fn prev(self) -> Self {
32        match self {
33            View::Past => View::Future,
34            View::Today => View::Past,
35            View::Future => View::Today,
36        }
37    }
38}
39
40#[derive(Debug, Default)]
41pub struct App {
42    pub title: String,
43    pub should_quit: bool,
44    pub day: DayPlan,
45    selected: usize,
46    tomorrow: Vec<Task>,
47    history: Vec<Task>,
48    view: View,
49    // Current content rendering mode (table vs. time blocks)
50    display: DisplayMode,
51    input: Option<Input>,
52    pub config: Config,
53    last_seen_ymd: u32,
54    // Mouse UI state
55    hovered: Option<usize>,
56    hovered_tab: Option<usize>,
57    popup_hover: Option<PopupButton>,
58    last_click: Option<LastClick>,
59    // Drag state for list reordering
60    drag_from: Option<usize>,
61    // Pulse toggle for simple UI animation effects
62    pulse: bool,
63    // Two-step task creation: after title input, prompt for estimate
64    new_task: Option<NewTaskDraft>,
65    // Header (title-bar) UI hover state
66    hovered_header_btn: Option<HeaderButton>,
67    // Category picker selection index when open
68    cat_pick_idx: usize,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum InputKind {
73    Normal,
74    Interrupt,
75    Command,
76    EstimateEdit,
77    NewTaskEstimate,
78    ConfirmDelete,
79    CategoryPicker,
80    StartTimeEdit,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum HeaderButton {
85    New,
86    Start,
87    Stop,
88    Finish,
89    Delete,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93struct Input {
94    kind: InputKind,
95    buffer: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99struct NewTaskDraft {
100    source: InputKind, // Normal or Interrupt
101    title: String,
102    default_estimate: u16,
103    planned_ymd: u32,
104}
105
106impl App {
107    fn handle_mouse_in_popup(&mut self, ev: MouseEvent, area: Rect) {
108        // Delete confirmation
109        if self.is_confirm_delete() {
110            if let Some(popup) = crate::ui::compute_delete_popup_rect(self, area) {
111                let (del_btn, cancel_btn) = crate::ui::delete_popup_button_hitboxes(self, popup);
112                match ev.kind {
113                    MouseEventKind::Moved => {
114                        let pos = (ev.column, ev.row);
115                        self.popup_hover = if point_in_rect(pos.0, pos.1, del_btn) {
116                            Some(PopupButton::Delete)
117                        } else if point_in_rect(pos.0, pos.1, cancel_btn) {
118                            Some(PopupButton::Cancel)
119                        } else {
120                            None
121                        };
122                    }
123                    MouseEventKind::Down(MouseButton::Left) => {
124                        let pos = (ev.column, ev.row);
125                        if point_in_rect(pos.0, pos.1, del_btn) {
126                            self.delete_selected();
127                            self.input = None;
128                        } else if point_in_rect(pos.0, pos.1, cancel_btn) {
129                            self.input = None;
130                        }
131                    }
132                    _ => {}
133                }
134            }
135        } else if self.is_estimate_editing() {
136            // Estimate editor (slider)
137            if let Some(popup) = crate::ui::compute_estimate_popup_rect(self, area) {
138                let (track, ok, cancel) = crate::ui::estimate_slider_hitboxes(self, popup);
139                let (prev_btn, _label_rect, next_btn) =
140                    crate::ui::date_picker_hitboxes(self, popup);
141                match ev.kind {
142                    MouseEventKind::Moved => {
143                        let pos = (ev.column, ev.row);
144                        self.popup_hover = if point_in_rect(pos.0, pos.1, ok) {
145                            Some(PopupButton::EstOk)
146                        } else if point_in_rect(pos.0, pos.1, cancel) {
147                            Some(PopupButton::EstCancel)
148                        } else if point_in_rect(pos.0, pos.1, prev_btn) {
149                            Some(PopupButton::DatePrev)
150                        } else if point_in_rect(pos.0, pos.1, next_btn) {
151                            Some(PopupButton::DateNext)
152                        } else {
153                            None
154                        };
155                    }
156                    MouseEventKind::Down(MouseButton::Left)
157                    | MouseEventKind::Drag(MouseButton::Left) => {
158                        let pos = (ev.column, ev.row);
159                        if point_in_rect(pos.0, pos.1, track) {
160                            let m = crate::ui::minutes_from_slider_x(track, 0, 240, 5, pos.0);
161                            if let Some(t) = self.day.tasks.get_mut(self.selected) {
162                                t.estimate_min = m;
163                            }
164                        } else if point_in_rect(pos.0, pos.1, prev_btn) {
165                            if let Some(t) = self.day.tasks.get_mut(self.selected) {
166                                let today = today_ymd();
167                                let cand = crate::date::add_days_to_ymd(t.planned_ymd, -1);
168                                t.planned_ymd = cand.max(today);
169                            }
170                        } else if point_in_rect(pos.0, pos.1, next_btn) {
171                            if let Some(t) = self.day.tasks.get_mut(self.selected) {
172                                t.planned_ymd = crate::date::add_days_to_ymd(t.planned_ymd, 1);
173                            }
174                        } else if point_in_rect(pos.0, pos.1, ok)
175                            || point_in_rect(pos.0, pos.1, cancel)
176                        {
177                            self.input = None;
178                        }
179                    }
180                    _ => {}
181                }
182            }
183        } else if self.is_new_task_estimate() {
184            if let Some(popup) = crate::ui::compute_new_task_estimate_popup_rect(self, area) {
185                let (add, cancel) = crate::ui::input_popup_button_hitboxes(self, popup);
186                let (prev_btn, _label_rect, next_btn) =
187                    crate::ui::date_picker_hitboxes(self, popup);
188                match ev.kind {
189                    MouseEventKind::Moved => {
190                        let pos = (ev.column, ev.row);
191                        self.popup_hover = if point_in_rect(pos.0, pos.1, add) {
192                            Some(PopupButton::InputAdd)
193                        } else if point_in_rect(pos.0, pos.1, cancel) {
194                            Some(PopupButton::InputCancel)
195                        } else if point_in_rect(pos.0, pos.1, prev_btn) {
196                            Some(PopupButton::DatePrev)
197                        } else if point_in_rect(pos.0, pos.1, next_btn) {
198                            Some(PopupButton::DateNext)
199                        } else {
200                            None
201                        };
202                    }
203                    MouseEventKind::Down(MouseButton::Left)
204                    | MouseEventKind::Drag(MouseButton::Left) => {
205                        let pos = (ev.column, ev.row);
206                        // Allow clicking on the slider track to set value
207                        let (track, _ok2, _cancel2) =
208                            crate::ui::estimate_slider_hitboxes(self, popup);
209                        if point_in_rect(pos.0, pos.1, track) {
210                            let m = crate::ui::minutes_from_slider_x(track, 0, 240, 5, pos.0);
211                            if let Some(inp) = self.input.as_mut() {
212                                inp.buffer = m.to_string();
213                            }
214                        } else if point_in_rect(pos.0, pos.1, prev_btn) {
215                            if let Some(d) = self.new_task.as_mut() {
216                                let today = today_ymd();
217                                let cand = crate::date::add_days_to_ymd(d.planned_ymd, -1);
218                                d.planned_ymd = cand.max(today);
219                            }
220                        } else if point_in_rect(pos.0, pos.1, next_btn) {
221                            if let Some(d) = self.new_task.as_mut() {
222                                d.planned_ymd = crate::date::add_days_to_ymd(d.planned_ymd, 1);
223                            }
224                        } else if point_in_rect(pos.0, pos.1, add) {
225                            self.handle_key(KeyCode::Enter);
226                        } else if point_in_rect(pos.0, pos.1, cancel) {
227                            self.new_task = None;
228                            self.input = None;
229                        }
230                    }
231                    _ => {}
232                }
233            }
234        } else if self.is_command_mode() {
235            if let Some(popup) = crate::ui::compute_command_popup_rect(self, area) {
236                let (run, cancel) = crate::ui::command_popup_button_hitboxes(self, popup);
237                match ev.kind {
238                    MouseEventKind::Moved => {
239                        let pos = (ev.column, ev.row);
240                        self.popup_hover = if point_in_rect(pos.0, pos.1, run) {
241                            Some(PopupButton::InputAdd)
242                        } else if point_in_rect(pos.0, pos.1, cancel) {
243                            Some(PopupButton::InputCancel)
244                        } else {
245                            None
246                        };
247                    }
248                    MouseEventKind::Down(MouseButton::Left) => {
249                        let pos = (ev.column, ev.row);
250                        if point_in_rect(pos.0, pos.1, run) {
251                            self.handle_key(KeyCode::Enter);
252                        } else if point_in_rect(pos.0, pos.1, cancel) {
253                            self.input = None;
254                        }
255                    }
256                    _ => {}
257                }
258            }
259        } else if self.is_category_picker() {
260            if let Some(popup) = crate::ui::compute_category_popup_rect(self, area) {
261                let rows = crate::ui::category_picker_hitboxes(self, popup);
262                match ev.kind {
263                    MouseEventKind::Moved => {
264                        let pos = (ev.column, ev.row);
265                        for (i, r) in rows.iter().enumerate() {
266                            if point_in_rect(pos.0, pos.1, *r) {
267                                self.cat_pick_idx = i;
268                                break;
269                            }
270                        }
271                    }
272                    MouseEventKind::Down(MouseButton::Left) => {
273                        let pos = (ev.column, ev.row);
274                        for (i, r) in rows.iter().enumerate() {
275                            if point_in_rect(pos.0, pos.1, *r) {
276                                self.cat_pick_idx = i;
277                                self.apply_selected_category();
278                                self.input = None;
279                                break;
280                            }
281                        }
282                    }
283                    _ => {}
284                }
285            }
286        } else if self.is_start_time_edit() {
287            if let Some(popup) = crate::ui::compute_start_time_popup_rect(self, area) {
288                let (track, ok, cancel) = crate::ui::estimate_slider_hitboxes(self, popup);
289                match ev.kind {
290                    MouseEventKind::Moved => {
291                        let pos = (ev.column, ev.row);
292                        self.popup_hover = if crate::app::point_in_rect(pos.0, pos.1, ok) {
293                            Some(PopupButton::EstOk)
294                        } else if crate::app::point_in_rect(pos.0, pos.1, cancel) {
295                            Some(PopupButton::EstCancel)
296                        } else {
297                            None
298                        };
299                    }
300                    MouseEventKind::Down(MouseButton::Left)
301                    | MouseEventKind::Drag(MouseButton::Left) => {
302                        let pos = (ev.column, ev.row);
303                        if crate::app::point_in_rect(pos.0, pos.1, track) {
304                            let m =
305                                crate::ui::minutes_from_slider_x(track, 0, 23 * 60 + 59, 5, pos.0);
306                            if let Some(inp) = self.input.as_mut() {
307                                inp.buffer = m.to_string();
308                            }
309                        } else if crate::app::point_in_rect(pos.0, pos.1, ok) {
310                            // Apply and close
311                            let m = self
312                                .input
313                                .as_ref()
314                                .and_then(|i| i.buffer.parse::<u16>().ok())
315                                .unwrap_or(self.config.day_start_minutes);
316                            if let Some(t) = self.day.tasks.get_mut(self.selected) {
317                                t.fixed_start_min = Some(m.min(23 * 60 + 59));
318                            }
319                            self.input = None;
320                        } else if crate::app::point_in_rect(pos.0, pos.1, cancel) {
321                            self.input = None;
322                        }
323                    }
324                    _ => {}
325                }
326            }
327        } else if self.in_input_mode() && !self.is_command_mode() {
328            // Task name input (Normal/Interrupt)
329            if let Some(popup) = crate::ui::compute_input_popup_rect(self, area) {
330                let (add, cancel) = crate::ui::input_popup_button_hitboxes(self, popup);
331                match ev.kind {
332                    MouseEventKind::Moved => {
333                        let pos = (ev.column, ev.row);
334                        self.popup_hover = if point_in_rect(pos.0, pos.1, add) {
335                            Some(PopupButton::InputAdd)
336                        } else if point_in_rect(pos.0, pos.1, cancel) {
337                            Some(PopupButton::InputCancel)
338                        } else {
339                            None
340                        };
341                    }
342                    MouseEventKind::Down(MouseButton::Left) => {
343                        let pos = (ev.column, ev.row);
344                        if point_in_rect(pos.0, pos.1, add) {
345                            // Reuse key handling to submit
346                            self.handle_key(KeyCode::Enter);
347                        } else if point_in_rect(pos.0, pos.1, cancel) {
348                            self.input = None;
349                        }
350                    }
351                    _ => {}
352                }
353            }
354        }
355    }
356    pub fn new() -> Self {
357        if std::env::var("RUST_TEST_THREADS").is_ok() {
358            Self::with_config(Config::default())
359        } else {
360            Self::with_config(Config::load())
361        }
362    }
363
364    pub fn with_config(config: Config) -> Self {
365        let ymd = today_ymd();
366        Self {
367            title: "Chute-kun".to_string(),
368            should_quit: false,
369            day: DayPlan::new(vec![]),
370            selected: 0,
371            tomorrow: vec![],
372            history: vec![],
373            view: View::default(),
374            display: DisplayMode::List,
375            input: None,
376            config,
377            last_seen_ymd: ymd,
378            hovered: None,
379            hovered_tab: None,
380            popup_hover: None,
381            last_click: None,
382            drag_from: None,
383            pulse: false,
384            new_task: None,
385            hovered_header_btn: None,
386            cat_pick_idx: 0,
387        }
388    }
389
390    pub fn handle_key(&mut self, code: KeyCode) {
391        // If we are in input mode, interpret keys as text editing/submit/cancel
392        if let Some(input) = self.input.as_mut() {
393            // Pre-capture default estimate for new-task flow to avoid borrowing self during edits
394            let new_task_default = self.new_task.as_ref().map(|d| d.default_estimate).unwrap_or(25);
395            match input.kind {
396                InputKind::Normal | InputKind::Interrupt => match code {
397                    KeyCode::Enter => {
398                        let (default_title, est) = match input.kind {
399                            InputKind::Normal => ("New Task", 25u16),
400                            InputKind::Interrupt => ("Interrupt", 15u16),
401                            _ => unreachable!(),
402                        };
403                        let title = if input.buffer.trim().is_empty() {
404                            default_title.to_string()
405                        } else {
406                            input.buffer.trim().to_string()
407                        };
408                        // Move to estimate entry step with default prefilled
409                        self.new_task = Some(NewTaskDraft {
410                            source: input.kind,
411                            title,
412                            default_estimate: est,
413                            planned_ymd: today_ymd(),
414                        });
415                        self.input =
416                            Some(Input { kind: InputKind::NewTaskEstimate, buffer: String::new() });
417                    }
418                    KeyCode::Esc => {
419                        self.input = None;
420                    }
421                    KeyCode::Backspace => {
422                        input.buffer.pop();
423                    }
424                    KeyCode::Char(c) => {
425                        input.buffer.push(c);
426                    }
427                    _ => {}
428                },
429                InputKind::NewTaskEstimate => match code {
430                    KeyCode::Enter => {
431                        if let Some(draft) = self.new_task.take() {
432                            let cur = self
433                                .input
434                                .as_ref()
435                                .and_then(|i| i.buffer.trim().parse::<u16>().ok())
436                                .unwrap_or(draft.default_estimate);
437                            let today = today_ymd();
438                            if draft.planned_ymd == today {
439                                let idx = self.add_task(&draft.title, cur);
440                                if let Some(t) = self.day.tasks.get_mut(idx) {
441                                    t.planned_ymd = draft.planned_ymd;
442                                }
443                                self.selected = idx;
444                            } else if draft.planned_ymd > today {
445                                let mut t = Task::new(&draft.title, cur);
446                                t.planned_ymd = draft.planned_ymd;
447                                self.tomorrow.push(t);
448                            } else {
449                                // Clamp to today if past (defensive)
450                                let idx = self.add_task(&draft.title, cur);
451                                if let Some(t) = self.day.tasks.get_mut(idx) {
452                                    t.planned_ymd = today;
453                                }
454                                self.selected = idx;
455                            }
456                        }
457                        self.input = None;
458                    }
459                    KeyCode::Esc => {
460                        // Cancel entire creation flow
461                        self.new_task = None;
462                        self.input = None;
463                    }
464                    KeyCode::Backspace => {
465                        input.buffer.pop();
466                    }
467                    KeyCode::Up | KeyCode::Right | KeyCode::Char('k') => {
468                        let base =
469                            input.buffer.trim().parse::<u16>().ok().unwrap_or(new_task_default);
470                        let next = base.saturating_add(5).min(240);
471                        input.buffer = next.to_string();
472                    }
473                    KeyCode::Down | KeyCode::Left | KeyCode::Char('j') => {
474                        let base =
475                            input.buffer.trim().parse::<u16>().ok().unwrap_or(new_task_default);
476                        let next = base.saturating_sub(5);
477                        input.buffer = next.to_string();
478                    }
479                    KeyCode::Char('.') => {
480                        if let Some(d) = self.new_task.as_mut() {
481                            d.planned_ymd = crate::date::add_days_to_ymd(d.planned_ymd, 1);
482                        }
483                    }
484                    KeyCode::Char(',') => {
485                        if let Some(d) = self.new_task.as_mut() {
486                            let today = today_ymd();
487                            let cand = crate::date::add_days_to_ymd(d.planned_ymd, -1);
488                            d.planned_ymd = cand.max(today);
489                        }
490                    }
491                    KeyCode::Char(_c) => {}
492                    _ => {}
493                },
494                InputKind::Command => match code {
495                    KeyCode::Enter => {
496                        let cmd = input.buffer.trim().to_string();
497                        self.apply_command(&cmd);
498                        self.input = None;
499                    }
500                    KeyCode::Esc => {
501                        self.input = None;
502                    }
503                    KeyCode::Backspace => {
504                        input.buffer.pop();
505                    }
506                    KeyCode::Char(c) => input.buffer.push(c),
507                    _ => {}
508                },
509                InputKind::CategoryPicker => match code {
510                    KeyCode::Up | KeyCode::Char('k') => {
511                        if self.cat_pick_idx > 0 {
512                            self.cat_pick_idx -= 1;
513                        }
514                    }
515                    KeyCode::Down | KeyCode::Char('j') => {
516                        if self.cat_pick_idx < 3 {
517                            self.cat_pick_idx += 1;
518                        }
519                    }
520                    KeyCode::Enter => {
521                        self.apply_selected_category();
522                        self.input = None;
523                    }
524                    KeyCode::Esc => {
525                        self.input = None;
526                    }
527                    _ => {}
528                },
529                InputKind::EstimateEdit => match code {
530                    KeyCode::Enter | KeyCode::Esc => {
531                        // finish editing
532                        self.input = None;
533                    }
534                    KeyCode::Up => {
535                        self.day.adjust_estimate(self.selected, 5);
536                    }
537                    KeyCode::Down => {
538                        self.day.adjust_estimate(self.selected, -5);
539                    }
540                    KeyCode::Right => {
541                        self.day.adjust_estimate(self.selected, 5);
542                    }
543                    KeyCode::Left => {
544                        self.day.adjust_estimate(self.selected, -5);
545                    }
546                    KeyCode::Char('k') => {
547                        self.day.adjust_estimate(self.selected, 5);
548                    }
549                    KeyCode::Char('j') => {
550                        self.day.adjust_estimate(self.selected, -5);
551                    }
552                    KeyCode::Char('.') => {
553                        if let Some(t) = self.day.tasks.get_mut(self.selected) {
554                            let base = if crate::date::is_valid_ymd(t.planned_ymd) {
555                                t.planned_ymd
556                            } else {
557                                today_ymd()
558                            };
559                            t.planned_ymd = crate::date::add_days_to_ymd(base, 1);
560                        }
561                    }
562                    KeyCode::Char(',') => {
563                        if let Some(t) = self.day.tasks.get_mut(self.selected) {
564                            let today = today_ymd();
565                            let base = if crate::date::is_valid_ymd(t.planned_ymd) {
566                                t.planned_ymd
567                            } else {
568                                today
569                            };
570                            let cand = crate::date::add_days_to_ymd(base, -1);
571                            t.planned_ymd = cand.max(today);
572                        }
573                    }
574                    _ => {}
575                },
576                InputKind::ConfirmDelete => match code {
577                    // Confirm via Enter or 'y'; cancel via Esc or 'n'
578                    KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
579                        self.delete_selected();
580                        self.input = None;
581                    }
582                    KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
583                        self.input = None;
584                    }
585                    _ => {}
586                },
587                InputKind::StartTimeEdit => match code {
588                    KeyCode::Enter | KeyCode::Esc => {
589                        // Apply on Enter, discard on Esc
590                        if matches!(code, KeyCode::Enter) {
591                            if let Some(buf) = self.input.as_ref().map(|i| i.buffer.clone()) {
592                                if let Ok(mins) = buf.trim().parse::<u16>() {
593                                    if let Some(t) = self.day.tasks.get_mut(self.selected) {
594                                        let clamped = mins.min(23 * 60 + 59);
595                                        t.fixed_start_min = Some(clamped);
596                                    }
597                                }
598                            }
599                        }
600                        self.input = None;
601                    }
602                    KeyCode::Up | KeyCode::Right | KeyCode::Char('k') => {
603                        if let Some(input) = self.input.as_mut() {
604                            let base = input.buffer.trim().parse::<u16>().ok().unwrap_or(0);
605                            let next = (base + 5).min(23 * 60 + 59);
606                            input.buffer = next.to_string();
607                        }
608                    }
609                    KeyCode::Down | KeyCode::Left | KeyCode::Char('j') => {
610                        if let Some(input) = self.input.as_mut() {
611                            let base = input.buffer.trim().parse::<u16>().ok().unwrap_or(0);
612                            let next = base.saturating_sub(5);
613                            input.buffer = next.to_string();
614                        }
615                    }
616                    _ => {}
617                },
618            }
619            return;
620        }
621        match code {
622            KeyCode::Char('q') => {
623                // In Calendar view, 'q' returns to main (List) instead of quitting
624                if matches!(self.display_mode(), DisplayMode::Calendar) {
625                    self.display = DisplayMode::List;
626                } else {
627                    self.should_quit = true;
628                }
629            }
630            KeyCode::Char('t') => {
631                self.toggle_display_mode();
632            }
633            KeyCode::Char(' ') => {
634                // Open Start Time slider via legacy direct key handling
635                let initial = self
636                    .day
637                    .tasks
638                    .get(self.selected)
639                    .and_then(|t| t.fixed_start_min)
640                    .unwrap_or(self.config.day_start_minutes);
641                self.input =
642                    Some(Input { kind: InputKind::StartTimeEdit, buffer: initial.to_string() });
643            }
644            KeyCode::Char('c') => {
645                // Cycle category of the selected task (all views)
646                if let Some(t) = self.selected_task_mut_current() {
647                    use crate::task::Category as C;
648                    t.category = match t.category {
649                        C::General => C::Work,
650                        C::Work => C::Home,
651                        C::Home => C::Hobby,
652                        C::Hobby => C::General,
653                    };
654                }
655            }
656            KeyCode::Char(':') => {
657                // Open command palette
658                self.input = Some(Input { kind: InputKind::Command, buffer: String::new() });
659            }
660            KeyCode::Char('E') => {
661                // Enter estimate edit mode if a task is available
662                if !self.day.tasks.is_empty() {
663                    self.input =
664                        Some(Input { kind: InputKind::EstimateEdit, buffer: String::new() });
665                }
666            }
667            KeyCode::Char('i') => {
668                // Enter input mode for a normal task
669                self.input = Some(Input { kind: InputKind::Normal, buffer: String::new() });
670            }
671            KeyCode::Char('I') => {
672                // Enter input mode for an interrupt task
673                self.input = Some(Input { kind: InputKind::Interrupt, buffer: String::new() });
674            }
675            KeyCode::Enter => {
676                // Toggle: if active -> pause; else start/resume selected or first eligible.
677                if let Some(active_idx) = self.day.active_index() {
678                    // End the running session before pausing
679                    let now = crate::clock::system_now_minutes();
680                    if let Some(t) = self.day.tasks.get_mut(active_idx) {
681                        t.end_session(now);
682                    }
683                    self.day.pause_active();
684                } else {
685                    let s = self.selected;
686                    let eligible = matches!(
687                        self.day.tasks.get(s).map(|t| t.state),
688                        Some(crate::task::TaskState::Paused | crate::task::TaskState::Planned)
689                    );
690                    if eligible {
691                        self.day.start(s);
692                        // Record actual start time if first activation
693                        if let Some(t) = self.day.tasks.get_mut(s) {
694                            if t.started_at_min.is_none() {
695                                t.started_at_min = Some(crate::clock::system_now_minutes());
696                            }
697                            let now = crate::clock::system_now_minutes();
698                            t.start_session(now);
699                        }
700                    } else if let Some(idx) = (0..self.day.tasks.len()).find(|&i| {
701                        matches!(
702                            self.day.tasks[i].state,
703                            crate::task::TaskState::Paused | crate::task::TaskState::Planned
704                        )
705                    }) {
706                        self.day.start(idx);
707                        self.selected = idx;
708                        if let Some(t) = self.day.tasks.get_mut(idx) {
709                            if t.started_at_min.is_none() {
710                                t.started_at_min = Some(crate::clock::system_now_minutes());
711                            }
712                            let now = crate::clock::system_now_minutes();
713                            t.start_session(now);
714                        }
715                    }
716                }
717            }
718            KeyCode::Char(']') => {
719                let new = self.day.reorder_down(self.selected);
720                self.selected = new;
721            }
722            KeyCode::Char('[') => {
723                let new = self.day.reorder_up(self.selected);
724                self.selected = new;
725            }
726            KeyCode::Char('e') => {
727                // Open estimate edit mode
728                if !self.day.tasks.is_empty() {
729                    // Backfill missing planned date on legacy tasks to avoid panics in date ops
730                    if let Some(t) = self.day.tasks.get_mut(self.selected) {
731                        if !crate::date::is_valid_ymd(t.planned_ymd) {
732                            t.planned_ymd = today_ymd();
733                        }
734                    }
735                    self.input =
736                        Some(Input { kind: InputKind::EstimateEdit, buffer: String::new() });
737                }
738            }
739            KeyCode::Char('p') => {
740                self.postpone_selected();
741            }
742            KeyCode::Char('x') => {
743                // Open delete confirmation on Today view with an existing task
744                if self.view == View::Today && !self.day.tasks.is_empty() {
745                    self.input =
746                        Some(Input { kind: InputKind::ConfirmDelete, buffer: String::new() });
747                }
748            }
749            KeyCode::Char('b') => {
750                self.bring_selected_from_future();
751            }
752            KeyCode::Tab => {
753                self.set_view(self.view.next());
754            }
755            KeyCode::BackTab => {
756                self.set_view(self.view.prev());
757            }
758            KeyCode::Up => self.select_up(),
759            KeyCode::Down => self.select_down(),
760            KeyCode::Char('k') => self.select_up(),
761            KeyCode::Char('j') => self.select_down(),
762            _ => {}
763        }
764    }
765
766    pub fn handle_key_event(&mut self, ev: KeyEvent) {
767        // Only act on key press/repeat; ignore key release to avoid double-handling
768        // on terminals reporting event types (iTerm2, Ghostty, etc.).
769        if matches!(ev.kind, KeyEventKind::Release) {
770            return;
771        }
772        // If in input mode, delegate to text edit handling
773        if self.in_input_mode() {
774            self.handle_key(ev.code);
775            return;
776        }
777        // Try config-based keymap first
778        if let Some(action) = self.config.keys.action_for(&ev) {
779            self.apply_action(action);
780            return;
781        }
782        // Fallback to legacy code-based handling to keep backward compatibility in tests
783        self.handle_key(ev.code);
784    }
785
786    /// Handle mouse events using the current terminal area to map coordinates to UI regions.
787    /// - Left click on a list row selects that task; double-click toggles start/pause.
788    /// - Right click on a list row opens estimate edit.
789    /// - Clicking tabs switches views.
790    /// - Mouse move updates hover index.
791    /// - Ignores clicks while in input/popup modes.
792    pub fn handle_mouse_event(&mut self, ev: MouseEvent, area: Rect) {
793        if self.in_input_mode()
794            || self.is_confirm_delete()
795            || self.is_estimate_editing()
796            || self.is_new_task_estimate()
797            || self.is_command_mode()
798            || self.is_category_picker()
799        {
800            self.handle_mouse_in_popup(ev, area);
801            return;
802        }
803        let (tabs, _banner, list, _help) = crate::ui::compute_layout(self, area);
804        match ev.kind {
805            MouseEventKind::Moved => {
806                // Title-bar buttons hover
807                if ev.row == area.y {
808                    let boxes = crate::ui::header_action_buttons_hitboxes(area);
809                    let mut hit: Option<HeaderButton> = None;
810                    for (i, r) in boxes.iter().enumerate() {
811                        if point_in_rect(ev.column, ev.row, *r) {
812                            hit = Some(match i {
813                                0 => HeaderButton::New,
814                                1 => HeaderButton::Start,
815                                2 => HeaderButton::Stop,
816                                3 => HeaderButton::Finish,
817                                4 => HeaderButton::Delete,
818                                _ => unreachable!(),
819                            });
820                            break;
821                        }
822                    }
823                    self.hovered_header_btn = hit;
824                } else {
825                    self.hovered_header_btn = None;
826                }
827                self.update_hover_from_coords(ev.column, ev.row, list);
828                // Tab hover
829                if ev.row == tabs.y {
830                    let boxes = crate::ui::tab_hitboxes(self, tabs);
831                    let mut hit = None;
832                    for (i, r) in boxes.iter().enumerate() {
833                        if point_in_rect(ev.column, ev.row, *r) {
834                            hit = Some(i);
835                            break;
836                        }
837                    }
838                    self.hovered_tab = hit;
839                } else {
840                    self.hovered_tab = None;
841                }
842            }
843            MouseEventKind::Down(MouseButton::Left) => {
844                // Title-bar buttons click
845                if ev.row == area.y {
846                    let boxes = crate::ui::header_action_buttons_hitboxes(area);
847                    let mut clicked: Option<usize> = None;
848                    for (i, r) in boxes.iter().enumerate() {
849                        if point_in_rect(ev.column, ev.row, *r) {
850                            clicked = Some(i);
851                            break;
852                        }
853                    }
854                    if let Some(i) = clicked {
855                        // Suppress clicks on disabled buttons
856                        let enabled = crate::ui::header_action_button_enabled(self);
857                        if !enabled[i] {
858                            return;
859                        }
860                        match i {
861                            0 => {
862                                // New task
863                                self.input =
864                                    Some(Input { kind: InputKind::Normal, buffer: String::new() });
865                            }
866                            1 => {
867                                // Start/resume selected (pause other active if needed)
868                                self.start_selected();
869                            }
870                            2 => {
871                                // Stop (pause active)
872                                if let Some(idx) = self.day.active_index() {
873                                    let now = crate::clock::system_now_minutes();
874                                    if let Some(t) = self.day.tasks.get_mut(idx) {
875                                        t.end_session(now);
876                                    }
877                                }
878                                self.day.pause_active();
879                            }
880                            3 => {
881                                // Finish selected
882                                self.finish_selected();
883                            }
884                            4 => {
885                                // Delete (confirm)
886                                if self.view == View::Today && !self.day.tasks.is_empty() {
887                                    self.input = Some(Input {
888                                        kind: InputKind::ConfirmDelete,
889                                        buffer: String::new(),
890                                    });
891                                }
892                            }
893                            _ => {}
894                        }
895                    }
896                    // After handling title click, stop so it doesn't fall through
897                    return;
898                }
899                // Tabs click
900                if ev.row == tabs.y {
901                    let boxes = crate::ui::tab_hitboxes(self, tabs);
902                    let mut clicked = None;
903                    for (i, r) in boxes.iter().enumerate() {
904                        if point_in_rect(ev.column, ev.row, *r) {
905                            clicked = Some(i);
906                            break;
907                        }
908                    }
909                    match clicked {
910                        Some(0) => self.set_view(View::Past),
911                        Some(1) => self.set_view(View::Today),
912                        Some(2) => self.set_view(View::Future),
913                        _ => {}
914                    }
915                    // View change can shift list area (different banner/help height). Re-align hover
916                    let (_t2, _b2, list2, _h2) = crate::ui::compute_layout(self, area);
917                    self.update_hover_from_coords(ev.column, ev.row, list2);
918                    self.drag_from = None;
919                    return;
920                }
921                // List click (ignore header at list.y)
922                if ev.row <= list.y || ev.row >= list.y.saturating_add(list.height) {
923                    self.drag_from = None;
924                    return;
925                }
926                let idx = self.index_from_list_row(ev.row, list);
927                // Category dot hit detection inside Task column (restrict to exact row Y)
928                // Columns: Plan(5) | Est(4) | Task | Act | Actual; spacing = 1 between columns
929                let task_x0 = list.x.saturating_add(5 + 1 + 4 + 1);
930                let dot_x_main = task_x0.saturating_add(2);
931                let dot_x_drag = task_x0.saturating_add(4);
932                let row_y = list.y.saturating_add(1).saturating_add(idx as u16);
933                if ev.row == row_y && (ev.column == dot_x_main || ev.column == dot_x_drag) {
934                    self.selected = idx;
935                    use crate::task::Category as C;
936                    if let Some(t) = self.selected_task_mut_current() {
937                        t.category = match t.category {
938                            C::General => C::Work,
939                            C::Work => C::Home,
940                            C::Home => C::Hobby,
941                            C::Hobby => C::General,
942                        };
943                    }
944                    // Don't treat as part of a double-click
945                    self.last_click = None;
946                    let (_t2, _b2, list2, _h2) = crate::ui::compute_layout(self, area);
947                    self.update_hover_from_coords(ev.column, ev.row, list2);
948                    return;
949                }
950                self.selected = idx;
951                // Begin potential drag reorder in Today/Future lists
952                self.drag_from = match self.view {
953                    View::Today | View::Future => Some(idx),
954                    View::Past => None,
955                };
956                // Detect double-click
957                let now = Instant::now();
958                const THRESHOLD_MS: u128 = 600;
959                let is_double = matches!(
960                    self.last_click,
961                    Some(LastClick { when, index, button: MouseButton::Left })
962                        if index == idx && now.duration_since(when).as_millis() <= THRESHOLD_MS
963                );
964                if is_double {
965                    match self.view {
966                        View::Today => self.toggle_task_start_pause(idx),
967                        View::Future => {
968                            // Bring from Future to Today but do not auto-start
969                            self.bring_selected_from_future();
970                        }
971                        View::Past => {}
972                    }
973                    // Reset so the second Down of the pair doesn't trigger again
974                    self.last_click = None;
975                } else {
976                    self.last_click =
977                        Some(LastClick { when: now, index: idx, button: MouseButton::Left });
978                }
979                // Starting/pausing can add/remove the active banner which shifts list Y by ±1.
980                // Recompute hover using the current coordinates against the new layout.
981                let (_t2, _b2, list2, _h2) = crate::ui::compute_layout(self, area);
982                self.update_hover_from_coords(ev.column, ev.row, list2);
983            }
984            MouseEventKind::Drag(MouseButton::Left) => {
985                // While dragging, update hover to provide visual guidance
986                self.update_hover_from_coords(ev.column, ev.row, list);
987            }
988            MouseEventKind::Up(MouseButton::Left) => {
989                // Finalize a drag-reorder if one started inside the list
990                if let Some(from) = self.drag_from.take() {
991                    // Compute drop target from current row; insert after the hovered row
992                    // when dragging downward, and before when dragging upward.
993                    let hover = self.index_from_list_row(ev.row, list);
994                    let slot = if from < hover { hover.saturating_add(1) } else { hover };
995                    match self.view {
996                        View::Today => {
997                            let new = self.day.move_index(from, slot);
998                            self.selected = new;
999                        }
1000                        View::Future => {
1001                            let new = self.move_future_index(from, slot);
1002                            self.selected = new;
1003                        }
1004                        View::Past => {}
1005                    }
1006                }
1007            }
1008            MouseEventKind::Down(MouseButton::Right) => {
1009                // Right click opens estimate editor on the clicked row (ignore header)
1010                if ev.row <= list.y || ev.row >= list.y.saturating_add(list.height) {
1011                    return;
1012                }
1013                let idx = self.index_from_list_row(ev.row, list);
1014                // If right-click is on the category dot of that exact row, open picker
1015                let task_x0 = list.x.saturating_add(5 + 1 + 4 + 1);
1016                let row_y = list.y.saturating_add(1).saturating_add(idx as u16);
1017                let dot_x_main = task_x0.saturating_add(2);
1018                let dot_x_drag = task_x0.saturating_add(4);
1019                if ev.row == row_y && (ev.column == dot_x_main || ev.column == dot_x_drag) {
1020                    self.open_category_picker_for(idx);
1021                    let (_t2, _b2, list2, _h2) = crate::ui::compute_layout(self, area);
1022                    self.update_hover_from_coords(ev.column, ev.row, list2);
1023                    return;
1024                }
1025                self.selected = idx;
1026                if !self.day.tasks.is_empty() {
1027                    if let Some(t) = self.day.tasks.get_mut(self.selected) {
1028                        if !crate::date::is_valid_ymd(t.planned_ymd) {
1029                            t.planned_ymd = today_ymd();
1030                        }
1031                    }
1032                    self.input =
1033                        Some(Input { kind: InputKind::EstimateEdit, buffer: String::new() });
1034                }
1035                // Popups don't affect list geometry, but realign hover defensively.
1036                let (_t2, _b2, list2, _h2) = crate::ui::compute_layout(self, area);
1037                self.update_hover_from_coords(ev.column, ev.row, list2);
1038            }
1039            _ => {}
1040        }
1041    }
1042
1043    /// For tests: update hover by coordinates without constructing a MouseEvent.
1044    pub fn handle_mouse_move(&mut self, col: u16, row: u16, area: Rect) {
1045        let (tabs, _banner, list, _help) = crate::ui::compute_layout(self, area);
1046        self.update_hover_from_coords(col, row, list);
1047        // Tabs hover
1048        if row == tabs.y {
1049            let boxes = crate::ui::tab_hitboxes(self, tabs);
1050            self.hovered_tab = boxes
1051                .iter()
1052                .enumerate()
1053                .find(|(_, r)| point_in_rect(col, row, **r))
1054                .map(|(i, _)| i);
1055        } else {
1056            self.hovered_tab = None;
1057        }
1058        // Popup hover
1059        if self.is_confirm_delete() {
1060            if let Some(popup) = crate::ui::compute_delete_popup_rect(self, area) {
1061                let (del_btn, cancel_btn) = crate::ui::delete_popup_button_hitboxes(self, popup);
1062                if point_in_rect(col, row, del_btn) {
1063                    self.popup_hover = Some(PopupButton::Delete);
1064                } else if point_in_rect(col, row, cancel_btn) {
1065                    self.popup_hover = Some(PopupButton::Cancel);
1066                } else {
1067                    self.popup_hover = None;
1068                }
1069            }
1070        }
1071    }
1072
1073    /// Handle pasted text from the terminal (bracketed/kitty paste etc.).
1074    /// Appends to the input buffer only when in input mode.
1075    pub fn handle_paste(&mut self, s: &str) {
1076        if let Some(input) = self.input.as_mut() {
1077            input.buffer.push_str(s);
1078        }
1079    }
1080
1081    pub fn add_task(&mut self, title: &str, estimate_min: u16) -> usize {
1082        self.day.add_task(Task::new(title, estimate_min))
1083    }
1084
1085    pub fn finish_active(&mut self) {
1086        if let Some(idx) = self.day.active_index() {
1087            // Use current date at the moment of finishing to respect test overrides
1088            // set via CHUTE_KUN_TODAY. Avoid relying on cached last_seen_ymd here
1089            // because tests may set the env var after App initialization.
1090            let ymd = crate::date::today_ymd();
1091            let now = crate::clock::system_now_minutes();
1092            if let Some(t) = self.day.tasks.get_mut(idx) {
1093                t.finished_at_min = Some(now);
1094                t.end_session(now);
1095            }
1096            self.day.finish_at(idx, ymd);
1097        }
1098    }
1099
1100    /// Finish the currently selected task regardless of its active state.
1101    pub fn finish_selected(&mut self) {
1102        if self.day.tasks.is_empty() {
1103            return;
1104        }
1105        let idx = self.selected.min(self.day.tasks.len() - 1);
1106        // Use current date at the moment of finishing for the same reason as above.
1107        let ymd = crate::date::today_ymd();
1108        let now = crate::clock::system_now_minutes();
1109        if let Some(t) = self.day.tasks.get_mut(idx) {
1110            t.finished_at_min = Some(now);
1111            t.end_session(now);
1112        }
1113        self.day.finish_at(idx, ymd);
1114    }
1115
1116    fn apply_action(&mut self, action: crate::config::Action) {
1117        use crate::config::Action as A;
1118        match action {
1119            A::Quit => {
1120                // In Calendar view, treat Quit as 'back to List' to avoid accidental exit
1121                if matches!(self.display_mode(), DisplayMode::Calendar) {
1122                    self.display = DisplayMode::List;
1123                } else {
1124                    self.should_quit = true;
1125                }
1126            }
1127            A::CategoryCycle => {
1128                if let Some(t) = self.selected_task_mut_current() {
1129                    use crate::task::Category as C;
1130                    t.category = match t.category {
1131                        C::General => C::Work,
1132                        C::Work => C::Home,
1133                        C::Home => C::Hobby,
1134                        C::Hobby => C::General,
1135                    };
1136                }
1137            }
1138            A::CategoryPicker => {
1139                // Open picker for the current view if it has any items
1140                let len = self.current_len();
1141                if len > 0 {
1142                    let idx = self.selected.min(len - 1);
1143                    self.open_category_picker_for(idx);
1144                }
1145            }
1146            A::AddTask => {
1147                self.input = Some(Input { kind: InputKind::Normal, buffer: String::new() });
1148            }
1149            A::AddInterrupt => {
1150                self.input = Some(Input { kind: InputKind::Interrupt, buffer: String::new() });
1151            }
1152            A::StartOrResume => {
1153                // Toggle behavior for Enter-mapped action: pause if active, otherwise start/resume
1154                if let Some(active_idx) = self.day.active_index() {
1155                    // End current session before pausing
1156                    let now = crate::clock::system_now_minutes();
1157                    if let Some(t) = self.day.tasks.get_mut(active_idx) {
1158                        t.end_session(now);
1159                    }
1160                    self.day.pause_active();
1161                } else {
1162                    let s = self.selected;
1163                    let eligible = matches!(
1164                        self.day.tasks.get(s).map(|t| t.state),
1165                        Some(crate::task::TaskState::Paused | crate::task::TaskState::Planned)
1166                    );
1167                    if eligible {
1168                        self.day.start(s);
1169                        // Record actual start time if first activation and open a new session
1170                        if let Some(t) = self.day.tasks.get_mut(s) {
1171                            let now = crate::clock::system_now_minutes();
1172                            if t.started_at_min.is_none() {
1173                                t.started_at_min = Some(now);
1174                            }
1175                            t.start_session(now);
1176                        }
1177                    } else if let Some(idx) = (0..self.day.tasks.len()).find(|&i| {
1178                        matches!(
1179                            self.day.tasks[i].state,
1180                            crate::task::TaskState::Paused | crate::task::TaskState::Planned
1181                        )
1182                    }) {
1183                        self.day.start(idx);
1184                        self.selected = idx;
1185                        if let Some(t) = self.day.tasks.get_mut(idx) {
1186                            let now = crate::clock::system_now_minutes();
1187                            if t.started_at_min.is_none() {
1188                                t.started_at_min = Some(now);
1189                            }
1190                            t.start_session(now);
1191                        }
1192                    }
1193                }
1194            }
1195            A::FinishActive => {
1196                // Now defined as "finish selected"
1197                self.finish_selected();
1198            }
1199            A::OpenPopup => {
1200                // Open Start Time slider for selected task
1201                let initial = self
1202                    .day
1203                    .tasks
1204                    .get(self.selected)
1205                    .and_then(|t| t.fixed_start_min)
1206                    .unwrap_or(self.config.day_start_minutes);
1207                self.input =
1208                    Some(Input { kind: InputKind::StartTimeEdit, buffer: initial.to_string() });
1209            }
1210            A::Delete => {
1211                if self.view == View::Today && !self.day.tasks.is_empty() {
1212                    self.input =
1213                        Some(Input { kind: InputKind::ConfirmDelete, buffer: String::new() });
1214                }
1215            }
1216            A::ReorderUp => {
1217                let new = self.day.reorder_up(self.selected);
1218                self.selected = new;
1219            }
1220            A::ReorderDown => {
1221                let new = self.day.reorder_down(self.selected);
1222                self.selected = new;
1223            }
1224            A::EstimatePlus => {
1225                // Repurpose to open estimate editor for backward compatibility with config name
1226                if !self.day.tasks.is_empty() {
1227                    // Backfill missing planned date so edit UI shows Today/Tomorrow correctly
1228                    if let Some(t) = self.day.tasks.get_mut(self.selected) {
1229                        if !crate::date::is_valid_ymd(t.planned_ymd) {
1230                            t.planned_ymd = today_ymd();
1231                        }
1232                    }
1233                    self.input =
1234                        Some(Input { kind: InputKind::EstimateEdit, buffer: String::new() });
1235                }
1236            }
1237            A::Postpone => {
1238                self.postpone_selected();
1239            }
1240            A::BringToToday => {
1241                self.bring_selected_from_future();
1242            }
1243            A::ViewNext => {
1244                self.set_view(self.view.next());
1245            }
1246            A::ViewPrev => {
1247                self.set_view(self.view.prev());
1248            }
1249            A::SelectUp => self.select_up(),
1250            A::SelectDown => self.select_down(),
1251            A::ToggleBlocks => {
1252                self.toggle_display_mode();
1253            }
1254        }
1255    }
1256
1257    pub fn selected_index(&self) -> usize {
1258        self.selected
1259    }
1260    pub fn select_up(&mut self) {
1261        let len = self.current_len();
1262        if len == 0 {
1263            return;
1264        }
1265        let new = self.selected.saturating_sub(1);
1266        if new != self.selected {
1267            self.selected = new;
1268        }
1269    }
1270    pub fn select_down(&mut self) {
1271        let len = self.current_len();
1272        if len == 0 {
1273            return;
1274        }
1275        let last = len - 1;
1276        let new = (self.selected + 1).min(last);
1277        if new != self.selected {
1278            self.selected = new;
1279        }
1280    }
1281
1282    pub fn postpone_selected(&mut self) {
1283        if self.day.tasks.is_empty() {
1284            return;
1285        }
1286        let idx = self.selected.min(self.day.tasks.len() - 1);
1287        if let Some(task) = self.day.remove(idx) {
1288            // stay planned for tomorrow
1289            let next_day = crate::date::add_days_to_ymd(today_ymd(), 1);
1290            self.tomorrow.push(Task {
1291                state: crate::task::TaskState::Planned,
1292                planned_ymd: next_day,
1293                ..task
1294            });
1295        }
1296        if !self.day.tasks.is_empty() {
1297            self.selected = self.selected.min(self.day.tasks.len() - 1);
1298        } else {
1299            self.selected = 0;
1300        }
1301    }
1302
1303    /// Bring a task from Future to Today (mirror of postpone). No-op unless in Future view.
1304    pub fn bring_selected_from_future(&mut self) {
1305        if self.view != View::Future || self.tomorrow.is_empty() {
1306            return;
1307        }
1308        let idx = self.selected.min(self.tomorrow.len() - 1);
1309        let mut task = self.tomorrow.remove(idx);
1310        // Ensure it becomes Planned in Today and append to the end.
1311        task.state = crate::task::TaskState::Planned;
1312        task.planned_ymd = today_ymd();
1313        let t = task;
1314        self.day.add_task(t);
1315        // Adjust selection within Future list
1316        if !self.tomorrow.is_empty() {
1317            self.selected = self.selected.min(self.tomorrow.len() - 1);
1318        } else {
1319            self.selected = 0;
1320        }
1321    }
1322
1323    pub fn tomorrow_tasks(&self) -> &Vec<Task> {
1324        &self.tomorrow
1325    }
1326    pub fn history_tasks(&self) -> &Vec<Task> {
1327        &self.history
1328    }
1329
1330    pub fn view(&self) -> View {
1331        self.view
1332    }
1333    pub fn display_mode(&self) -> DisplayMode {
1334        self.display
1335    }
1336
1337    // Input mode helpers for UI/tests
1338    pub fn in_input_mode(&self) -> bool {
1339        self.input.is_some()
1340    }
1341    pub fn input_buffer(&self) -> Option<&str> {
1342        self.input.as_ref().map(|i| i.buffer.as_str())
1343    }
1344    pub fn is_estimate_editing(&self) -> bool {
1345        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::EstimateEdit))
1346    }
1347    pub fn is_new_task_estimate(&self) -> bool {
1348        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::NewTaskEstimate))
1349    }
1350    pub fn is_command_mode(&self) -> bool {
1351        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::Command))
1352    }
1353    pub fn is_confirm_delete(&self) -> bool {
1354        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::ConfirmDelete))
1355    }
1356    pub fn is_category_picker(&self) -> bool {
1357        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::CategoryPicker))
1358    }
1359    pub fn is_start_time_edit(&self) -> bool {
1360        matches!(self.input.as_ref().map(|i| i.kind), Some(InputKind::StartTimeEdit))
1361    }
1362    /// True only when typing a task title (Normal/Interrupt), not for estimate/confirm popups.
1363    pub fn is_text_input_mode(&self) -> bool {
1364        matches!(
1365            self.input.as_ref().map(|i| i.kind),
1366            Some(InputKind::Normal | InputKind::Interrupt)
1367        )
1368    }
1369    pub fn selected_estimate(&self) -> Option<u16> {
1370        self.day.tasks.get(self.selected).map(|t| t.estimate_min)
1371    }
1372    pub fn new_task_title(&self) -> Option<&str> {
1373        self.new_task.as_ref().map(|d| d.title.as_str())
1374    }
1375    pub fn new_task_default_estimate(&self) -> Option<u16> {
1376        self.new_task.as_ref().map(|d| d.default_estimate)
1377    }
1378    pub fn new_task_planned_ymd(&self) -> Option<u32> {
1379        self.new_task.as_ref().map(|d| d.planned_ymd)
1380    }
1381    pub fn hovered_index(&self) -> Option<usize> {
1382        self.hovered
1383    }
1384    pub fn is_dragging(&self) -> bool {
1385        self.drag_from.is_some()
1386    }
1387    pub fn drag_source_index(&self) -> Option<usize> {
1388        if self.view == View::Today {
1389            self.drag_from
1390        } else {
1391            None
1392        }
1393    }
1394    pub fn pulse_on(&self) -> bool {
1395        self.pulse
1396    }
1397    pub fn hovered_tab_index(&self) -> Option<usize> {
1398        self.hovered_tab
1399    }
1400    pub fn popup_hover_button(&self) -> Option<PopupButton> {
1401        self.popup_hover
1402    }
1403    pub fn hovered_header_button(&self) -> Option<HeaderButton> {
1404        self.hovered_header_btn
1405    }
1406    pub fn category_pick_index(&self) -> usize {
1407        self.cat_pick_idx
1408    }
1409
1410    fn set_view(&mut self, v: View) {
1411        self.view = v;
1412        // clamp selection to current view length
1413        let len = self.current_len();
1414        if len == 0 {
1415            self.selected = 0;
1416        } else {
1417            self.selected = self.selected.min(len - 1);
1418        }
1419        self.hovered = None;
1420    }
1421
1422    pub fn toggle_display_mode(&mut self) {
1423        self.display = match self.display {
1424            DisplayMode::List => DisplayMode::Calendar,
1425            DisplayMode::Calendar => DisplayMode::List,
1426        };
1427    }
1428
1429    fn current_len(&self) -> usize {
1430        match self.view {
1431            View::Past => self.history.len(),
1432            View::Today => self.day.tasks.len(),
1433            View::Future => self.tomorrow.len(),
1434        }
1435    }
1436
1437    pub fn tick(&mut self, seconds: u16) {
1438        // Freeze app time updates while a confirmation popup is open
1439        if self.is_confirm_delete() {
1440            return;
1441        }
1442        // Simple pulse animation toggle
1443        if seconds > 0 {
1444            self.pulse = !self.pulse;
1445        }
1446        // Sweep when the local date changes
1447        let today = today_ymd();
1448        if today != self.last_seen_ymd {
1449            self.last_seen_ymd = today;
1450            self.sweep_done_before(today);
1451        }
1452        if let Some(active) = self.day.active_index() {
1453            if let Some(t) = self.day.tasks.get_mut(active) {
1454                t.actual_carry_sec = t.actual_carry_sec.saturating_add(seconds);
1455                while t.actual_carry_sec >= 60 {
1456                    t.actual_carry_sec -= 60;
1457                    t.actual_min = t.actual_min.saturating_add(1);
1458                }
1459            }
1460        }
1461    }
1462}
1463
1464impl App {
1465    /// Start or resume the selected task. If another task is active, pause it first.
1466    fn start_selected(&mut self) {
1467        // Only meaningful on Today view
1468        if self.view != View::Today {
1469            return;
1470        }
1471        let s = self.selected;
1472        if let Some(active_idx) = self.day.active_index() {
1473            if active_idx != s {
1474                let now = crate::clock::system_now_minutes();
1475                if let Some(t) = self.day.tasks.get_mut(active_idx) {
1476                    t.end_session(now);
1477                }
1478                self.day.pause_active();
1479            } else {
1480                // Already active; nothing to do
1481                return;
1482            }
1483        }
1484        let eligible = matches!(
1485            self.day.tasks.get(s).map(|t| t.state),
1486            Some(crate::task::TaskState::Paused | crate::task::TaskState::Planned)
1487        );
1488        if eligible {
1489            self.day.start(s);
1490            if let Some(t) = self.day.tasks.get_mut(s) {
1491                let now = crate::clock::system_now_minutes();
1492                if t.started_at_min.is_none() {
1493                    t.started_at_min = Some(now);
1494                }
1495                t.start_session(now);
1496            }
1497        }
1498    }
1499    fn delete_selected(&mut self) {
1500        if self.view != View::Today || self.day.tasks.is_empty() {
1501            return;
1502        }
1503        let idx = self.selected.min(self.day.tasks.len() - 1);
1504        let _ = self.day.remove(idx);
1505        if !self.day.tasks.is_empty() {
1506            self.selected = self.selected.min(self.day.tasks.len() - 1);
1507        } else {
1508            self.selected = 0;
1509        }
1510    }
1511
1512    /// Replace task lists from an external snapshot.
1513    /// - Resets selection and carry seconds; keeps config.
1514    pub fn apply_snapshot(
1515        &mut self,
1516        today: Vec<crate::task::Task>,
1517        future: Vec<crate::task::Task>,
1518        past: Vec<crate::task::Task>,
1519    ) {
1520        self.day = DayPlan::new(today);
1521        self.tomorrow = future;
1522        self.history = past;
1523        self.selected = 0;
1524        self.set_view(View::Today);
1525        // Ensure old done items are moved to Past on startup
1526        self.sweep_done_before(self.last_seen_ymd);
1527    }
1528}
1529
1530impl App {
1531    /// Move any Today tasks with `done_ymd` strictly before `ymd` to history.
1532    pub fn sweep_done_before(&mut self, ymd: u32) {
1533        let mut i = 0;
1534        while i < self.day.tasks.len() {
1535            let move_to_past = matches!(self.day.tasks[i].done_ymd, Some(d) if d < ymd);
1536            if move_to_past {
1537                if let Some(task) = self.day.remove(i) {
1538                    self.history.push(task);
1539                }
1540                // don't increment i; elements shifted left
1541                continue;
1542            }
1543            i += 1;
1544        }
1545        // Clamp selection
1546        if !self.day.tasks.is_empty() {
1547            self.selected = self.selected.min(self.day.tasks.len() - 1);
1548        } else {
1549            self.selected = 0;
1550        }
1551    }
1552
1553    fn apply_command(&mut self, cmd: &str) {
1554        // Supported:
1555        // - "est +15m" / "est -5" / "est 90m" (estimate edit)
1556        // - "base HH:MM" (予定の基準時刻 = day_start を変更)
1557        // - "mode blocks|list" (表示モードを切替)
1558        let mut it = cmd.split_whitespace();
1559        let Some(head) = it.next() else {
1560            return;
1561        };
1562        match head {
1563            "est" => {
1564                let Some(arg) = it.next() else {
1565                    return;
1566                };
1567                let s = arg.trim();
1568                if s.starts_with('+') || s.starts_with('-') {
1569                    // relative delta
1570                    let sign = if s.starts_with('+') { 1 } else { -1 };
1571                    let num_part = s[1..].trim_end_matches('m');
1572                    if let Ok(v) = num_part.parse::<i16>() {
1573                        let delta = v.saturating_mul(sign);
1574                        self.day.adjust_estimate(self.selected, delta);
1575                    }
1576                } else {
1577                    // absolute minutes
1578                    let num_part = s.trim_end_matches('m');
1579                    if let Ok(v) = num_part.parse::<u16>() {
1580                        if let Some(t) = self.day.tasks.get_mut(self.selected) {
1581                            t.estimate_min = v;
1582                        }
1583                    }
1584                }
1585            }
1586            "at" => {
1587                if let Some(arg) = it.next() {
1588                    if arg == "-"
1589                        || arg.eq_ignore_ascii_case("none")
1590                        || arg.eq_ignore_ascii_case("clear")
1591                    {
1592                        if let Some(t) = self.day.tasks.get_mut(self.selected) {
1593                            t.fixed_start_min = None;
1594                        }
1595                    } else if let Ok((h, m)) = crate::config::parse_hhmm_or_compact(arg) {
1596                        let minutes = h * 60 + m;
1597                        if let Some(t) = self.day.tasks.get_mut(self.selected) {
1598                            t.fixed_start_min = Some(minutes);
1599                        }
1600                    }
1601                }
1602            }
1603            "base" => {
1604                if let Some(arg) = it.next() {
1605                    if let Ok((h, m)) = crate::config::parse_hhmm_or_compact(arg) {
1606                        // Update in-memory immediately
1607                        self.config.day_start_minutes = h * 60 + m;
1608                        // Persist by default
1609                        let _ = crate::config::write_day_start(h, m);
1610                    }
1611                }
1612            }
1613            "mode" => {
1614                if let Some(arg) = it.next() {
1615                    match arg {
1616                        // Backward-compatible aliases now map to Calendar
1617                        "blocks" | "timeline" | "calendar" => self.display = DisplayMode::Calendar,
1618                        "list" | "table" => self.display = DisplayMode::List,
1619                        _ => {}
1620                    }
1621                }
1622            }
1623            _ => {}
1624        }
1625    }
1626}
1627
1628impl App {
1629    fn toggle_task_start_pause(&mut self, idx: usize) {
1630        if self.day.active_index() == Some(idx) {
1631            self.day.pause_active();
1632        } else {
1633            self.day.start(idx);
1634            self.selected = idx;
1635        }
1636    }
1637
1638    fn apply_selected_category(&mut self) {
1639        use crate::task::Category as C;
1640        let pick = self.cat_pick_idx;
1641        if let Some(t) = self.selected_task_mut_current() {
1642            t.category = match pick {
1643                0 => C::General,
1644                1 => C::Work,
1645                2 => C::Home,
1646                3 => C::Hobby,
1647                _ => C::General,
1648            };
1649        }
1650    }
1651
1652    fn open_category_picker_for(&mut self, idx: usize) {
1653        self.selected = idx;
1654        // Initialize picker index to current category position for the active list
1655        use crate::task::Category as C;
1656        let cur = match self.view {
1657            View::Past => self.history.get(idx).map(|t| t.category),
1658            View::Today => self.day.tasks.get(idx).map(|t| t.category),
1659            View::Future => self.tomorrow.get(idx).map(|t| t.category),
1660        }
1661        .unwrap_or(C::General);
1662        self.cat_pick_idx = match cur {
1663            C::General => 0,
1664            C::Work => 1,
1665            C::Home => 2,
1666            C::Hobby => 3,
1667        };
1668        self.input = Some(Input { kind: InputKind::CategoryPicker, buffer: String::new() });
1669    }
1670
1671    fn selected_task_mut_current(&mut self) -> Option<&mut Task> {
1672        match self.view {
1673            View::Past => self.history.get_mut(self.selected),
1674            View::Today => self.day.tasks.get_mut(self.selected),
1675            View::Future => self.tomorrow.get_mut(self.selected),
1676        }
1677    }
1678
1679    fn index_from_list_row(&self, row: u16, list: Rect) -> usize {
1680        // Table uses a header row at list.y; first data row starts at list.y + 1.
1681        // Map mouse row to data index accordingly.
1682        let rel = row.saturating_sub(list.y.saturating_add(1)) as usize;
1683        let len = self.current_len();
1684        rel.min(len.saturating_sub(1))
1685    }
1686
1687    fn update_hover_from_coords(&mut self, col: u16, row: u16, list: Rect) {
1688        // Only treat rows strictly below the header as actual task rows in normal movement.
1689        // While dragging, allow hover to extend into the tail space (snaps to last row) so
1690        // users get a visible drop target at the end of the list.
1691        let len = self.current_len() as u16;
1692        if len == 0 {
1693            self.hovered = None;
1694            return;
1695        }
1696        let first_row_y = list.y.saturating_add(1);
1697        let past_last_row_y = first_row_y.saturating_add(len); // exclusive upper bound
1698        let within_cols = col >= list.x && col < list.x.saturating_add(list.width);
1699        let within_list_block = row >= first_row_y && row < list.y.saturating_add(list.height);
1700        let within_header = row >= list.y && row < first_row_y; // header line area
1701        let within_task_rows = row >= first_row_y && row < past_last_row_y;
1702
1703        if within_cols {
1704            if within_task_rows {
1705                let idx = self.index_from_list_row(row, list);
1706                self.hovered = Some(idx);
1707            } else if self.drag_from.is_some() && within_list_block {
1708                // Dragging in tail space: snap hover to last row for visual target
1709                self.hovered = Some((len - 1) as usize);
1710            } else if self.drag_from.is_some() && within_header {
1711                // Dragging into head space (header): snap hover to first row
1712                self.hovered = Some(0);
1713            } else {
1714                self.hovered = None;
1715            }
1716        } else {
1717            self.hovered = None;
1718        }
1719    }
1720}
1721
1722impl App {
1723    /// Reorder tasks within the Future (tomorrow) list using an insertion slot model.
1724    /// Returns the final index of the moved task in the Future list.
1725    fn move_future_index(&mut self, from: usize, to_slot: usize) -> usize {
1726        let len = self.tomorrow.len();
1727        if len == 0 || from >= len {
1728            return from;
1729        }
1730        let slot = to_slot.min(len);
1731        let dest_final = if from < slot { slot.saturating_sub(1) } else { slot };
1732        if dest_final == from {
1733            return from;
1734        }
1735        let item = self.tomorrow.remove(from);
1736        self.tomorrow.insert(dest_final, item);
1737        dest_final
1738    }
1739}
1740
1741#[derive(Clone, Copy, Debug)]
1742struct LastClick {
1743    when: Instant,
1744    index: usize,
1745    button: MouseButton,
1746}
1747
1748#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1749pub enum PopupButton {
1750    Delete,
1751    Cancel,
1752    EstMinus,
1753    EstPlus,
1754    EstOk,
1755    EstCancel,
1756    InputAdd,
1757    InputCancel,
1758    DatePrev,
1759    DateNext,
1760}
1761
1762fn point_in_rect(x: u16, y: u16, r: Rect) -> bool {
1763    x >= r.x && x < r.x.saturating_add(r.width) && y >= r.y && y < r.y.saturating_add(r.height)
1764}