1use anyhow::{anyhow, Context, Result};
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use ratatui::style::Color;
8use serde::Deserialize;
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone)]
13pub struct Config {
14 pub day_start_minutes: u16,
15 pub keys: KeyMap,
16 pub categories: CategoryTheme,
17}
18
19impl Default for Config {
20 fn default() -> Self {
21 Self {
22 day_start_minutes: 9 * 60,
23 keys: KeyMap::default(),
24 categories: CategoryTheme::default(),
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
32pub struct CategoryTheme {
33 pub general: CategoryStyle,
34 pub work: CategoryStyle,
35 pub home: CategoryStyle,
36 pub hobby: CategoryStyle,
37}
38
39#[derive(Debug, Clone)]
40pub struct CategoryStyle {
41 pub name: String,
42 pub color: Color,
43}
44
45impl Default for CategoryTheme {
46 fn default() -> Self {
47 CategoryTheme {
48 general: CategoryStyle { name: "General".into(), color: Color::White },
49 work: CategoryStyle { name: "Work".into(), color: Color::Blue },
50 home: CategoryStyle { name: "Home".into(), color: Color::Yellow },
51 hobby: CategoryStyle { name: "Hobby".into(), color: Color::Magenta },
52 }
53 }
54}
55
56impl Config {
57 pub fn category_color(&self, cat: crate::task::Category) -> Color {
58 match cat {
59 crate::task::Category::General => self.categories.general.color,
60 crate::task::Category::Work => self.categories.work.color,
61 crate::task::Category::Home => self.categories.home.color,
62 crate::task::Category::Hobby => self.categories.hobby.color,
63 }
64 }
65 pub fn category_name(&self, cat: crate::task::Category) -> String {
66 match cat {
67 crate::task::Category::General => self.categories.general.name.clone(),
68 crate::task::Category::Work => self.categories.work.name.clone(),
69 crate::task::Category::Home => self.categories.home.name.clone(),
70 crate::task::Category::Hobby => self.categories.hobby.name.clone(),
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
76pub struct KeyMap {
77 pub quit: Vec<KeySpec>,
78 pub add_task: Vec<KeySpec>,
79 pub add_interrupt: Vec<KeySpec>,
80 pub start_or_resume: Vec<KeySpec>,
81 pub finish_active: Vec<KeySpec>,
82 pub popup: Vec<KeySpec>,
83 pub delete: Vec<KeySpec>,
84 pub reorder_up: Vec<KeySpec>,
85 pub reorder_down: Vec<KeySpec>,
86 pub estimate_plus: Vec<KeySpec>,
87 pub postpone: Vec<KeySpec>,
88 pub bring_to_today: Vec<KeySpec>,
89 pub view_next: Vec<KeySpec>,
90 pub view_prev: Vec<KeySpec>,
91 pub select_up: Vec<KeySpec>,
92 pub select_down: Vec<KeySpec>,
93 pub toggle_blocks: Vec<KeySpec>,
94 pub category_cycle: Vec<KeySpec>,
95 pub category_picker: Vec<KeySpec>,
96}
97
98impl Default for KeyMap {
99 fn default() -> Self {
100 use KeySpec as K;
101 let k = |s| K::parse(s).expect("valid default key spec");
102 KeyMap {
103 quit: vec![k("q")],
104 add_task: vec![k("i")],
105 add_interrupt: vec![k("I")],
106 start_or_resume: vec![k("Enter")],
107 finish_active: vec![k("Shift+Enter"), k("f")],
108 popup: vec![k("Space")],
109 delete: vec![k("x")],
110 reorder_up: vec![k("[")],
111 reorder_down: vec![k("]")],
112 estimate_plus: vec![k("e")],
113 postpone: vec![k("p")],
114 bring_to_today: vec![k("b")],
115 view_next: vec![k("Tab")],
116 view_prev: vec![k("BackTab")],
117 select_up: vec![k("Up"), k("k")],
118 select_down: vec![k("Down"), k("j")],
119 toggle_blocks: vec![k("t")],
120 category_cycle: vec![k("c")],
121 category_picker: vec![k("Shift+c")],
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Action {
128 Quit,
129 AddTask,
130 AddInterrupt,
131 StartOrResume,
132 FinishActive,
133 OpenPopup,
134 Delete,
135 ReorderUp,
136 ReorderDown,
137 EstimatePlus,
138 Postpone,
139 BringToToday,
140 ViewNext,
141 ViewPrev,
142 SelectUp,
143 SelectDown,
144 ToggleBlocks,
145 CategoryCycle,
146 CategoryPicker,
147}
148
149impl KeyMap {
150 pub fn action_for(&self, ev: &KeyEvent) -> Option<Action> {
151 let matches = |list: &Vec<KeySpec>| list.iter().any(|k| k.matches(ev));
152 if matches(&self.quit) {
153 Some(Action::Quit)
154 } else if matches(&self.add_task) {
155 Some(Action::AddTask)
156 } else if matches(&self.add_interrupt) {
157 Some(Action::AddInterrupt)
158 } else if matches(&self.start_or_resume) {
159 Some(Action::StartOrResume)
160 } else if matches(&self.finish_active) {
161 Some(Action::FinishActive)
162 } else if matches(&self.popup) {
163 Some(Action::OpenPopup)
164 } else if matches(&self.delete) {
165 Some(Action::Delete)
166 } else if matches(&self.reorder_up) {
167 Some(Action::ReorderUp)
168 } else if matches(&self.reorder_down) {
169 Some(Action::ReorderDown)
170 } else if matches(&self.estimate_plus) {
171 Some(Action::EstimatePlus)
172 } else if matches(&self.postpone) {
173 Some(Action::Postpone)
174 } else if matches(&self.bring_to_today) {
175 Some(Action::BringToToday)
176 } else if matches(&self.view_next) {
177 Some(Action::ViewNext)
178 } else if matches(&self.view_prev) {
179 Some(Action::ViewPrev)
180 } else if matches(&self.select_up) {
181 Some(Action::SelectUp)
182 } else if matches(&self.select_down) {
183 Some(Action::SelectDown)
184 } else if matches(&self.toggle_blocks) {
185 Some(Action::ToggleBlocks)
186 } else if matches(&self.category_cycle) {
187 Some(Action::CategoryCycle)
188 } else if matches(&self.category_picker) {
189 Some(Action::CategoryPicker)
190 } else {
191 None
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy)]
197pub struct KeySpec {
198 pub code: KeyCode,
199 pub modifiers: KeyModifiers,
200}
201
202impl KeySpec {
203 pub fn parse(s: &str) -> Result<Self> {
204 let s = s.trim();
205 if s.is_empty() {
206 return Err(anyhow!("empty key spec"));
207 }
208 let mut parts = s.split('+').map(str::trim).collect::<Vec<_>>();
209 let key_str = parts.pop().unwrap();
210 let mut mods = KeyModifiers::empty();
211 for m in parts {
212 match m.to_ascii_lowercase().as_str() {
213 "shift" => mods |= KeyModifiers::SHIFT,
214 "ctrl" | "control" => mods |= KeyModifiers::CONTROL,
215 "alt" => mods |= KeyModifiers::ALT,
216 other => return Err(anyhow!("unsupported modifier: {}", other)),
217 }
218 }
219 let mut code = match key_str {
220 "Enter" => KeyCode::Enter,
221 "Space" => KeyCode::Char(' '),
222 "Tab" => KeyCode::Tab,
223 "BackTab" => KeyCode::BackTab,
224 "Up" => KeyCode::Up,
225 "Down" => KeyCode::Down,
226 s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()),
228 other => return Err(anyhow!("unsupported key: {}", other)),
229 };
230 if let KeyCode::Char(c) = code {
234 let is_alpha = c.is_ascii_alphabetic();
235 let is_upper = c.is_ascii_uppercase();
236 let has_ctrl = mods.contains(KeyModifiers::CONTROL);
237 if is_alpha && is_upper && !has_ctrl {
238 mods |= KeyModifiers::SHIFT;
240 code = KeyCode::Char(c.to_ascii_lowercase());
241 }
242 if has_ctrl && is_alpha {
243 code = KeyCode::Char(c.to_ascii_lowercase());
245 }
246 }
247 Ok(KeySpec { code, modifiers: mods })
248 }
249
250 pub fn matches(&self, ev: &KeyEvent) -> bool {
251 use KeyCode::*;
252 let (mut sc, mut sm) = (self.code, self.modifiers);
254 let (mut ec, mut em) = (ev.code, ev.modifiers);
255 if let KeyCode::Char(c) = sc {
259 let is_alpha = c.is_ascii_alphabetic();
260 let is_upper = c.is_ascii_uppercase();
261 let has_ctrl = sm.contains(KeyModifiers::CONTROL);
262 if is_alpha && is_upper && !has_ctrl {
263 sc = KeyCode::Char(c.to_ascii_lowercase());
264 sm |= KeyModifiers::SHIFT;
265 }
266 if has_ctrl && is_alpha {
267 sc = KeyCode::Char(c.to_ascii_lowercase());
268 }
269 }
270 if let KeyCode::Char(c) = ec {
271 let is_alpha = c.is_ascii_alphabetic();
272 let is_upper = c.is_ascii_uppercase();
273 let has_ctrl = em.contains(KeyModifiers::CONTROL);
274 if is_alpha && is_upper && !has_ctrl {
275 ec = KeyCode::Char(c.to_ascii_lowercase());
276 em |= KeyModifiers::SHIFT;
277 }
278 if has_ctrl && is_alpha {
279 ec = KeyCode::Char(c.to_ascii_lowercase());
280 }
281 }
282 let self_is_shift_tab = (sc == Tab && sm.contains(KeyModifiers::SHIFT)) || sc == BackTab;
283 let ev_is_shift_tab = (ec == Tab && em.contains(KeyModifiers::SHIFT)) || ec == BackTab;
284 if self_is_shift_tab && ev_is_shift_tab {
285 return true;
286 }
287 ec == sc && em == sm
288 }
289
290 pub fn label(&self) -> String {
293 use KeyCode::*;
294 let base = match self.code {
295 Enter => "Enter".to_string(),
296 Tab => "Tab".to_string(),
297 BackTab => "Shift+Tab".to_string(),
298 Up => "Up".to_string(),
299 Down => "Down".to_string(),
300 KeyCode::Char(' ') => "Space".to_string(),
301 KeyCode::Char(c) => c.to_string(),
302 _ => format!("{:?}", self.code),
303 };
304 let mut parts: Vec<&'static str> = Vec::new();
306 if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != BackTab {
307 parts.push("Shift");
308 }
309 if self.modifiers.contains(KeyModifiers::CONTROL) {
310 parts.push("Ctrl");
311 }
312 if self.modifiers.contains(KeyModifiers::ALT) {
313 parts.push("Alt");
314 }
315 if parts.is_empty() {
316 base
317 } else {
318 format!("{}+{}", parts.join("+"), base)
319 }
320 }
321}
322
323pub fn join_key_labels(keys: &[KeySpec]) -> String {
325 keys.iter().map(|k| k.label()).collect::<Vec<_>>().join("/")
326}
327
328#[derive(Debug, Deserialize)]
331struct RawConfig {
332 #[serde(default)]
333 day_start: Option<String>,
334 #[serde(default)]
335 keys: Option<RawKeys>,
336 #[serde(default)]
337 categories: Option<RawCategories>,
338}
339
340#[derive(Debug, Deserialize, Default)]
341struct RawKeys {
342 quit: Option<OneOrMany>,
343 add_task: Option<OneOrMany>,
344 add_interrupt: Option<OneOrMany>,
345 start_or_resume: Option<OneOrMany>,
346 finish_active: Option<OneOrMany>,
347 popup: Option<OneOrMany>,
348 delete: Option<OneOrMany>,
349 reorder_up: Option<OneOrMany>,
350 reorder_down: Option<OneOrMany>,
351 estimate_plus: Option<OneOrMany>,
352 postpone: Option<OneOrMany>,
353 bring_to_today: Option<OneOrMany>,
354 view_next: Option<OneOrMany>,
355 view_prev: Option<OneOrMany>,
356 select_up: Option<OneOrMany>,
357 select_down: Option<OneOrMany>,
358 toggle_blocks: Option<OneOrMany>,
359 category_cycle: Option<OneOrMany>,
360 category_picker: Option<OneOrMany>,
361}
362
363#[derive(Debug, Deserialize, Default)]
364struct RawCategories {
365 #[serde(default)]
366 general: Option<RawCategoryStyle>,
367 #[serde(default)]
368 work: Option<RawCategoryStyle>,
369 #[serde(default)]
370 home: Option<RawCategoryStyle>,
371 #[serde(default)]
372 hobby: Option<RawCategoryStyle>,
373}
374
375#[derive(Debug, Deserialize, Default, Clone)]
376struct RawCategoryStyle {
377 #[serde(default)]
378 name: Option<String>,
379 #[serde(default)]
380 color: Option<String>,
381}
382
383#[derive(Debug, Deserialize)]
384#[serde(untagged)]
385enum OneOrMany {
386 One(String),
387 Many(Vec<String>),
388}
389
390impl OneOrMany {
391 fn into_vec(self) -> Vec<String> {
392 match self {
393 OneOrMany::One(s) => vec![s],
394 OneOrMany::Many(v) => v,
395 }
396 }
397}
398
399fn parse_hhmm_to_minutes(s: &str) -> Result<u16> {
400 let parts: Vec<&str> = s.split(':').collect();
401 if parts.len() != 2 {
402 return Err(anyhow!("invalid time format, expected HH:MM: {}", s));
403 }
404 let h: u16 = parts[0].parse().context("invalid hour")?;
405 let m: u16 = parts[1].parse().context("invalid minute")?;
406 Ok((h % 24) * 60 + (m % 60))
407}
408
409fn parse_color(s: &str) -> Result<Color> {
410 let lower = s.trim().to_ascii_lowercase();
411 let named = match lower.as_str() {
412 "white" => Some(Color::White),
413 "blue" => Some(Color::Blue),
414 "yellow" => Some(Color::Yellow),
415 "magenta" => Some(Color::Magenta),
416 "red" => Some(Color::Red),
417 "green" => Some(Color::Green),
418 "cyan" => Some(Color::Cyan),
419 "black" => Some(Color::Black),
420 "gray" | "grey" => Some(Color::Gray),
421 "darkgray" | "darkgrey" => Some(Color::DarkGray),
422 _ => None,
423 };
424 if let Some(c) = named {
425 return Ok(c);
426 }
427 let s = lower.trim();
428 if s.starts_with('#') && s.len() == 7 {
429 let r = u8::from_str_radix(&s[1..3], 16).map_err(|_| anyhow!("bad hex color"))?;
430 let g = u8::from_str_radix(&s[3..5], 16).map_err(|_| anyhow!("bad hex color"))?;
431 let b = u8::from_str_radix(&s[5..7], 16).map_err(|_| anyhow!("bad hex color"))?;
432 return Ok(Color::Rgb(r, g, b));
433 }
434 Err(anyhow!("unknown color: {}", s))
435}
436
437impl Config {
438 pub fn from_toml_str(s: &str) -> Result<Self> {
439 let raw: RawConfig = toml::from_str(s).context("parse config toml")?;
440 let mut cfg = Config::default();
441 if let Some(ds) = raw.day_start {
442 cfg.day_start_minutes = parse_hhmm_to_minutes(&ds)?;
443 }
444 if let Some(keys) = raw.keys {
445 let mut km = KeyMap::default();
446 let apply = |dst: &mut Vec<KeySpec>, src: OneOrMany| -> Result<()> {
447 *dst = src
448 .into_vec()
449 .into_iter()
450 .map(|s| KeySpec::parse(&s))
451 .collect::<Result<Vec<_>>>()?;
452 Ok(())
453 };
454 if let Some(v) = keys.quit {
455 apply(&mut km.quit, v)?;
456 }
457 if let Some(v) = keys.add_task {
458 apply(&mut km.add_task, v)?;
459 }
460 if let Some(v) = keys.add_interrupt {
461 apply(&mut km.add_interrupt, v)?;
462 }
463 if let Some(v) = keys.start_or_resume {
464 apply(&mut km.start_or_resume, v)?;
465 }
466 if let Some(v) = keys.finish_active {
467 apply(&mut km.finish_active, v)?;
468 }
469 if let Some(v) = keys.popup {
470 apply(&mut km.popup, v)?;
471 }
472 if let Some(v) = keys.delete {
473 apply(&mut km.delete, v)?;
474 }
475 if let Some(v) = keys.reorder_up {
476 apply(&mut km.reorder_up, v)?;
477 }
478 if let Some(v) = keys.reorder_down {
479 apply(&mut km.reorder_down, v)?;
480 }
481 if let Some(v) = keys.estimate_plus {
482 apply(&mut km.estimate_plus, v)?;
483 }
484 if let Some(v) = keys.postpone {
485 apply(&mut km.postpone, v)?;
486 }
487 if let Some(v) = keys.bring_to_today {
488 apply(&mut km.bring_to_today, v)?;
489 }
490 if let Some(v) = keys.view_next {
491 apply(&mut km.view_next, v)?;
492 }
493 if let Some(v) = keys.view_prev {
494 apply(&mut km.view_prev, v)?;
495 }
496 if let Some(v) = keys.select_up {
497 apply(&mut km.select_up, v)?;
498 }
499 if let Some(v) = keys.select_down {
500 apply(&mut km.select_down, v)?;
501 }
502 if let Some(v) = keys.toggle_blocks {
503 apply(&mut km.toggle_blocks, v)?;
504 }
505 if let Some(v) = keys.category_cycle {
506 apply(&mut km.category_cycle, v)?;
507 }
508 if let Some(v) = keys.category_picker {
509 apply(&mut km.category_picker, v)?;
510 }
511 cfg.keys = km;
512 }
513 if let Some(cats) = raw.categories {
514 let apply = |dst: &mut CategoryStyle, ent: Option<RawCategoryStyle>| -> Result<()> {
515 if let Some(e) = ent {
516 if let Some(n) = e.name {
517 dst.name = n;
518 }
519 if let Some(c) = e.color {
520 dst.color = parse_color(&c)?;
521 }
522 }
523 Ok(())
524 };
525 apply(&mut cfg.categories.general, cats.general)?;
526 apply(&mut cfg.categories.work, cats.work)?;
527 apply(&mut cfg.categories.home, cats.home)?;
528 apply(&mut cfg.categories.hobby, cats.hobby)?;
529 }
530 Ok(cfg)
531 }
532
533 pub fn load() -> Self {
534 if std::env::var("RUST_TEST_THREADS").is_ok()
537 || std::env::var("CHUTE_KUN_DISABLE_CONFIG").is_ok()
538 {
539 return Config::default();
540 }
541 if let Ok(path) = std::env::var("CHUTE_KUN_CONFIG") {
542 if let Ok(s) = fs::read_to_string(&path) {
543 if let Ok(cfg) = Self::from_toml_str(&s) {
544 return cfg;
545 }
546 }
547 }
548 let path = default_config_path();
549 if let Some(path) = path {
550 if path.exists() {
551 if let Ok(s) = fs::read_to_string(&path) {
552 if let Ok(cfg) = Self::from_toml_str(&s) {
553 return cfg;
554 }
555 }
556 }
557 }
558 Config::default()
559 }
560
561 pub fn default_toml() -> String {
563 r##"# Chute-kun configuration
565# 設定ファイルの場所: $XDG_CONFIG_HOME/chute_kun/config.toml (なければ ~/.config/chute_kun/config.toml)
566
567# 1日の開始時刻(固定表示)。"HH:MM" 形式。既定は 09:00。
568day_start = "09:00"
569
570[keys]
571# 既定のキーバインド。必要なものだけ上書きできます。
572quit = "q"
573add_task = "i"
574add_interrupt = "Shift+i"
575start_or_resume = "Enter"
576finish_active = ["Shift+Enter", "f"]
577popup = "Space"
578delete = "x"
579reorder_up = "["
580reorder_down = "]"
581estimate_plus = "e"
582postpone = "p"
583bring_to_today = "b"
584view_next = "Tab"
585view_prev = "BackTab"
586select_up = ["Up", "k"]
587select_down = ["Down", "j"]
588toggle_blocks = "t"
589category_cycle = "c"
590category_picker = "Shift+c"
591
592[categories]
593# カテゴリ名と色("white"/"blue"/"yellow"/"magenta"/"red"/"green"/"cyan"/"black"/"gray"/"darkgray" または "#RRGGBB")
594[categories.general]
595name = "General"
596color = "white"
597
598[categories.work]
599name = "Work"
600color = "blue"
601
602[categories.home]
603name = "Home"
604color = "yellow"
605
606[categories.hobby]
607name = "Hobby"
608color = "magenta"
609"##.to_string()
610 }
611
612 pub fn write_default_file() -> Result<std::path::PathBuf> {
617 let path = if let Ok(p) = std::env::var("CHUTE_KUN_CONFIG") {
618 std::path::PathBuf::from(p)
619 } else {
620 default_config_path().ok_or_else(|| anyhow!("could not resolve config path"))?
621 };
622 if let Some(parent) = path.parent() {
623 std::fs::create_dir_all(parent).ok();
624 }
625 if !path.exists() {
626 std::fs::write(&path, Self::default_toml()).context("write default config")?;
627 }
628 Ok(path)
629 }
630}
631
632pub fn default_config_path() -> Option<PathBuf> {
633 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
634 return Some(PathBuf::from(xdg).join("chute_kun").join("config.toml"));
635 }
636 if cfg!(target_os = "macos") {
638 if let Some(home) = std::env::var_os("HOME") {
639 return Some(PathBuf::from(home).join(".config").join("chute_kun").join("config.toml"));
640 }
641 }
642 dirs::config_dir().map(|b| b.join("chute_kun").join("config.toml"))
644}
645
646pub fn set_day_start_in_toml(contents: &str, hhmm: &str) -> String {
650 let mut replaced = false;
651 let mut out = String::with_capacity(contents.len() + 32);
652 for line in contents.lines() {
653 let trimmed = line.trim_start();
654 if trimmed.starts_with("day_start") {
655 out.push_str(&format!("day_start = \"{}\"\n", hhmm));
656 replaced = true;
657 } else {
658 out.push_str(line);
659 out.push('\n');
660 }
661 }
662 if !replaced {
663 let mut inserted = String::new();
664 inserted.push_str(&format!("day_start = \"{}\"\n", hhmm));
665 inserted.push_str(&out);
666 return inserted;
667 }
668 out
669}
670
671pub fn parse_hhmm_or_compact(s: &str) -> Result<(u16, u16)> {
673 let s = s.trim();
674 if let Some(colon) = s.find(':') {
675 let h: u16 = s[..colon].parse().context("invalid hour")?;
677 let m: u16 = s[colon + 1..].parse().context("invalid minute")?;
678 if h > 23 || m > 59 {
679 return Err(anyhow!("time out of range"));
680 }
681 return Ok((h, m));
682 }
683 if s.chars().all(|c| c.is_ascii_digit()) && (s.len() == 3 || s.len() == 4) {
685 let (h_str, m_str) = s.split_at(s.len() - 2);
686 let h: u16 = h_str.parse().context("invalid hour")?;
687 let m: u16 = m_str.parse().context("invalid minute")?;
688 if h > 23 || m > 59 {
689 return Err(anyhow!("time out of range"));
690 }
691 return Ok((h, m));
692 }
693 Err(anyhow!("invalid time format, expected HH:MM or HHMM"))
694}
695
696pub fn write_day_start(h: u16, m: u16) -> Result<PathBuf> {
700 if h > 23 || m > 59 {
701 return Err(anyhow!("time out of range"));
702 }
703 let path = Config::write_default_file()?;
704 let normalized = format!("{:02}:{:02}", h, m);
705 let contents = std::fs::read_to_string(&path).unwrap_or_else(|_| Config::default_toml());
706 let updated = set_day_start_in_toml(&contents, &normalized);
707 std::fs::write(&path, updated).context("write updated day_start to config")?;
708 Ok(path)
709}