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 display: DisplayMode,
51 input: Option<Input>,
52 pub config: Config,
53 last_seen_ymd: u32,
54 hovered: Option<usize>,
56 hovered_tab: Option<usize>,
57 popup_hover: Option<PopupButton>,
58 last_click: Option<LastClick>,
59 drag_from: Option<usize>,
61 pulse: bool,
63 new_task: Option<NewTaskDraft>,
65 hovered_header_btn: Option<HeaderButton>,
67 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, 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 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 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 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 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 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 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 let Some(input) = self.input.as_mut() {
393 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 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 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 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 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 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 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 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 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 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 self.input = Some(Input { kind: InputKind::Command, buffer: String::new() });
659 }
660 KeyCode::Char('E') => {
661 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 self.input = Some(Input { kind: InputKind::Normal, buffer: String::new() });
670 }
671 KeyCode::Char('I') => {
672 self.input = Some(Input { kind: InputKind::Interrupt, buffer: String::new() });
674 }
675 KeyCode::Enter => {
676 if let Some(active_idx) = self.day.active_index() {
678 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 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 if !self.day.tasks.is_empty() {
729 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 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 if matches!(ev.kind, KeyEventKind::Release) {
770 return;
771 }
772 if self.in_input_mode() {
774 self.handle_key(ev.code);
775 return;
776 }
777 if let Some(action) = self.config.keys.action_for(&ev) {
779 self.apply_action(action);
780 return;
781 }
782 self.handle_key(ev.code);
784 }
785
786 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 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 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 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 let enabled = crate::ui::header_action_button_enabled(self);
857 if !enabled[i] {
858 return;
859 }
860 match i {
861 0 => {
862 self.input =
864 Some(Input { kind: InputKind::Normal, buffer: String::new() });
865 }
866 1 => {
867 self.start_selected();
869 }
870 2 => {
871 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 self.finish_selected();
883 }
884 4 => {
885 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 return;
898 }
899 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 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 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 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 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 self.drag_from = match self.view {
953 View::Today | View::Future => Some(idx),
954 View::Past => None,
955 };
956 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 self.bring_selected_from_future();
970 }
971 View::Past => {}
972 }
973 self.last_click = None;
975 } else {
976 self.last_click =
977 Some(LastClick { when: now, index: idx, button: MouseButton::Left });
978 }
979 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 self.update_hover_from_coords(ev.column, ev.row, list);
987 }
988 MouseEventKind::Up(MouseButton::Left) => {
989 if let Some(from) = self.drag_from.take() {
991 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(active_idx) = self.day.active_index() {
1155 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 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 self.finish_selected();
1198 }
1199 A::OpenPopup => {
1200 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 if !self.day.tasks.is_empty() {
1227 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 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 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 task.state = crate::task::TaskState::Planned;
1312 task.planned_ymd = today_ymd();
1313 let t = task;
1314 self.day.add_task(t);
1315 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 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 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 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 if self.is_confirm_delete() {
1440 return;
1441 }
1442 if seconds > 0 {
1444 self.pulse = !self.pulse;
1445 }
1446 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 fn start_selected(&mut self) {
1467 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 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 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 self.sweep_done_before(self.last_seen_ymd);
1527 }
1528}
1529
1530impl App {
1531 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 continue;
1542 }
1543 i += 1;
1544 }
1545 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 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 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 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 self.config.day_start_minutes = h * 60 + m;
1608 let _ = crate::config::write_day_start(h, m);
1610 }
1611 }
1612 }
1613 "mode" => {
1614 if let Some(arg) = it.next() {
1615 match arg {
1616 "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 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 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 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); 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; 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 self.hovered = Some((len - 1) as usize);
1710 } else if self.drag_from.is_some() && within_header {
1711 self.hovered = Some(0);
1713 } else {
1714 self.hovered = None;
1715 }
1716 } else {
1717 self.hovered = None;
1718 }
1719 }
1720}
1721
1722impl App {
1723 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}