1use std::path::{Path, PathBuf};
10
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Block, Borders, Clear, Paragraph};
16use toml_edit::{DocumentMut, value};
17
18use crate::config::Config;
19use crate::error::{AppError, Result};
20use crate::theme::Theme;
21use crate::vendor::VendorId;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Focus {
26 Primary,
27 ZaiKey,
28 OpenrouterKey,
29 SaveButton,
30}
31
32impl Focus {
33 pub fn next(self) -> Self {
34 match self {
35 Focus::Primary => Focus::ZaiKey,
36 Focus::ZaiKey => Focus::OpenrouterKey,
37 Focus::OpenrouterKey => Focus::SaveButton,
38 Focus::SaveButton => Focus::Primary,
39 }
40 }
41 pub fn prev(self) -> Self {
42 match self {
43 Focus::Primary => Focus::SaveButton,
44 Focus::ZaiKey => Focus::Primary,
45 Focus::OpenrouterKey => Focus::ZaiKey,
46 Focus::SaveButton => Focus::OpenrouterKey,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct KeyInput {
54 pub buf: String,
55 pub cursor: usize,
57 pub revealed: bool,
59 pub dirty: bool,
63}
64
65impl KeyInput {
66 pub fn from_config(initial: Option<&str>) -> Self {
67 let buf = initial.unwrap_or("").to_string();
68 let cursor = buf.chars().count();
69 Self {
70 buf,
71 cursor,
72 revealed: false,
73 dirty: false,
74 }
75 }
76
77 pub fn insert_char(&mut self, c: char) {
78 let byte_idx = self.char_to_byte(self.cursor);
79 self.buf.insert(byte_idx, c);
80 self.cursor += 1;
81 self.dirty = true;
82 }
83
84 pub fn backspace(&mut self) {
85 if self.cursor == 0 {
86 return;
87 }
88 let prev_byte = self.char_to_byte(self.cursor - 1);
89 let cur_byte = self.char_to_byte(self.cursor);
90 self.buf.replace_range(prev_byte..cur_byte, "");
91 self.cursor -= 1;
92 self.dirty = true;
93 }
94
95 pub fn delete(&mut self) {
96 let n = self.buf.chars().count();
97 if self.cursor >= n {
98 return;
99 }
100 let cur_byte = self.char_to_byte(self.cursor);
101 let next_byte = self.char_to_byte(self.cursor + 1);
102 self.buf.replace_range(cur_byte..next_byte, "");
103 self.dirty = true;
104 }
105
106 pub fn move_left(&mut self) {
107 if self.cursor > 0 {
108 self.cursor -= 1;
109 }
110 }
111 pub fn move_right(&mut self) {
112 if self.cursor < self.buf.chars().count() {
113 self.cursor += 1;
114 }
115 }
116 pub fn move_home(&mut self) {
117 self.cursor = 0;
118 }
119 pub fn move_end(&mut self) {
120 self.cursor = self.buf.chars().count();
121 }
122 pub fn toggle_reveal(&mut self) {
123 self.revealed = !self.revealed;
124 }
125
126 pub fn display(&self) -> String {
128 if self.revealed {
129 self.buf.clone()
130 } else {
131 "•".repeat(self.buf.chars().count())
132 }
133 }
134
135 fn char_to_byte(&self, char_idx: usize) -> usize {
136 self.buf
137 .char_indices()
138 .map(|(b, _)| b)
139 .chain(std::iter::once(self.buf.len()))
140 .nth(char_idx)
141 .unwrap_or(self.buf.len())
142 }
143}
144
145#[derive(Debug, Clone)]
147pub struct SettingsState {
148 pub focus: Focus,
149 pub primary: VendorId,
150 pub zai: KeyInput,
151 pub openrouter: KeyInput,
152 pub status: String,
154}
155
156impl SettingsState {
157 pub fn from_config(cfg: &Config) -> Self {
158 Self {
159 focus: Focus::Primary,
160 primary: cfg.ui.primary.unwrap_or(VendorId::Anthropic),
161 zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
162 openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
163 status: String::new(),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Action {
171 Continue,
173 Close,
175 SavedAndClose,
177}
178
179pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
181 if matches!(code, KeyCode::Esc) {
183 return Action::Close;
184 }
185 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
187 return match save_to_config_default(state) {
188 Ok(()) => {
189 state.status = "saved to ~/.config/ai-usagebar/config.toml (chmod 600)".into();
190 Action::SavedAndClose
191 }
192 Err(e) => {
193 state.status = format!("save failed: {e}");
194 Action::Continue
195 }
196 };
197 }
198 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
200 match state.focus {
201 Focus::ZaiKey => state.zai.toggle_reveal(),
202 Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
203 _ => {}
204 }
205 return Action::Continue;
206 }
207
208 match code {
210 KeyCode::Tab => {
211 state.focus = state.focus.next();
212 return Action::Continue;
213 }
214 KeyCode::BackTab => {
215 state.focus = state.focus.prev();
216 return Action::Continue;
217 }
218 KeyCode::Down => {
219 state.focus = state.focus.next();
220 return Action::Continue;
221 }
222 KeyCode::Up => {
223 state.focus = state.focus.prev();
224 return Action::Continue;
225 }
226 _ => {}
227 }
228
229 match state.focus {
231 Focus::Primary => handle_primary(state, code),
232 Focus::ZaiKey => handle_input(&mut state.zai, code),
233 Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
234 Focus::SaveButton => {
235 if matches!(code, KeyCode::Enter) {
236 return match save_to_config_default(state) {
237 Ok(()) => {
238 state.status =
239 "saved to ~/.config/ai-usagebar/config.toml (chmod 600)".into();
240 Action::SavedAndClose
241 }
242 Err(e) => {
243 state.status = format!("save failed: {e}");
244 Action::Continue
245 }
246 };
247 }
248 }
249 }
250 Action::Continue
251}
252
253fn handle_primary(state: &mut SettingsState, code: KeyCode) {
254 let all = VendorId::all();
256 let idx = all.iter().position(|v| *v == state.primary).unwrap_or(0) as i32;
257 let len = all.len() as i32;
258 let step = match code {
259 KeyCode::Left => -1,
260 KeyCode::Right | KeyCode::Char(' ') => 1,
261 _ => return,
262 };
263 state.primary = all[((idx + step).rem_euclid(len)) as usize];
264}
265
266fn handle_input(input: &mut KeyInput, code: KeyCode) {
267 match code {
268 KeyCode::Char(c) => input.insert_char(c),
269 KeyCode::Backspace => input.backspace(),
270 KeyCode::Delete => input.delete(),
271 KeyCode::Left => input.move_left(),
272 KeyCode::Right => input.move_right(),
273 KeyCode::Home => input.move_home(),
274 KeyCode::End => input.move_end(),
275 _ => {}
276 }
277}
278
279fn save_to_config_default(state: &SettingsState) -> Result<()> {
285 let path = default_config_path()?;
286 if let Some(parent) = path.parent() {
287 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
288 }
289 save_to_path(state, &path)?;
290 crate::waybar::request_refresh();
291 Ok(())
292}
293
294pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
297 let original = std::fs::read_to_string(path).unwrap_or_default();
298 let mut doc: DocumentMut = if original.trim().is_empty() {
299 DocumentMut::new()
300 } else {
301 original.parse().map_err(|e: toml_edit::TomlError| {
302 AppError::Other(format!("config.toml not parseable: {e}"))
303 })?
304 };
305
306 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
308 if state.zai.dirty && !state.zai.buf.is_empty() {
310 set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
311 }
312 if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
314 set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
315 }
316
317 let bytes = doc.to_string();
318 crate::cache::atomic_write(path, bytes.as_bytes())?;
319
320 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 if let Ok(meta) = std::fs::metadata(path) {
325 let mut perms = meta.permissions();
326 perms.set_mode(0o600);
327 let _ = std::fs::set_permissions(path, perms);
328 }
329 }
330 Ok(())
331}
332
333fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
338 let table = doc
339 .entry(section)
340 .or_insert_with(toml_edit::table)
341 .as_table_mut()
342 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
343
344 if let Some(item) = table.get_mut(key) {
345 if let Some(v) = item.as_value_mut() {
346 *v = toml_edit::Value::from(new_value);
347 v.decor_mut().set_prefix(" ");
350 return Ok(());
351 }
352 }
353 table.insert(key, value(new_value));
354 Ok(())
355}
356
357fn default_config_path() -> Result<PathBuf> {
358 directories::ProjectDirs::from("", "", "ai-usagebar")
359 .map(|p| p.config_dir().join("config.toml"))
360 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
361}
362
363pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
365 let modal = centered_rect(60, 60, area);
366 f.render_widget(Clear, modal);
368
369 let accent = parse_hex(&theme.blue).unwrap_or(Color::Cyan);
370 let fg = parse_hex(&theme.fg).unwrap_or(Color::White);
371 let dim = parse_hex(&theme.dim).unwrap_or(Color::DarkGray);
372
373 let block = Block::default()
374 .title(" Settings ")
375 .borders(Borders::ALL)
376 .border_style(Style::default().fg(accent).add_modifier(Modifier::BOLD));
377 let inner = block.inner(modal);
378 f.render_widget(block, modal);
379
380 let chunks = Layout::default()
381 .direction(Direction::Vertical)
382 .constraints([
383 Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
395 .split(inner);
396
397 f.render_widget(
399 Paragraph::new(label(
400 "Primary vendor",
401 state.focus == Focus::Primary,
402 fg,
403 accent,
404 )),
405 chunks[0],
406 );
407 f.render_widget(
408 Paragraph::new(render_radio(&state.primary, accent, dim)),
409 chunks[1],
410 );
411
412 f.render_widget(
414 Paragraph::new(label(
415 "Z.AI API key (ZAI_API_KEY env wins if set)",
416 state.focus == Focus::ZaiKey,
417 fg,
418 accent,
419 )),
420 chunks[3],
421 );
422 f.render_widget(
423 Paragraph::new(render_input(
424 &state.zai,
425 state.focus == Focus::ZaiKey,
426 fg,
427 accent,
428 dim,
429 )),
430 chunks[4],
431 );
432
433 f.render_widget(
435 Paragraph::new(label(
436 "OpenRouter API key (OPENROUTER_API_KEY env wins if set)",
437 state.focus == Focus::OpenrouterKey,
438 fg,
439 accent,
440 )),
441 chunks[5],
442 );
443 f.render_widget(
444 Paragraph::new(render_input(
445 &state.openrouter,
446 state.focus == Focus::OpenrouterKey,
447 fg,
448 accent,
449 dim,
450 )),
451 chunks[6],
452 );
453
454 let save_style = if state.focus == Focus::SaveButton {
456 Style::default()
457 .fg(accent)
458 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
459 } else {
460 Style::default().fg(accent)
461 };
462 f.render_widget(
463 Paragraph::new(Line::from(Span::styled(
464 " [ Save (Ctrl-S) ] ",
465 save_style,
466 ))),
467 chunks[8],
468 );
469
470 if !state.status.is_empty() {
472 f.render_widget(
473 Paragraph::new(Line::from(Span::styled(
474 state.status.clone(),
475 Style::default().fg(dim),
476 ))),
477 chunks[9],
478 );
479 }
480
481 let hint = Line::from(vec![Span::styled(
483 " Tab/↑↓ move · ←→ pick vendor · Ctrl-V reveal · Ctrl-S save · Esc cancel",
484 Style::default().fg(dim),
485 )]);
486 f.render_widget(Paragraph::new(hint), chunks[10]);
487}
488
489fn label(text: &str, focused: bool, fg: Color, accent: Color) -> Line<'static> {
490 let mut style = Style::default().fg(fg);
491 if focused {
492 style = style.fg(accent).add_modifier(Modifier::BOLD);
493 }
494 Line::from(Span::styled(format!(" {text}"), style))
495}
496
497fn render_radio(selected: &VendorId, accent: Color, dim: Color) -> Line<'static> {
498 let mut spans = vec![Span::raw(" ")];
499 for v in VendorId::all() {
500 let is_sel = v == selected;
501 let glyph = if is_sel { "●" } else { "○" };
502 let style = if is_sel {
503 Style::default().fg(accent).add_modifier(Modifier::BOLD)
504 } else {
505 Style::default().fg(dim)
506 };
507 spans.push(Span::styled(
508 format!("{glyph} {} ", vendor_label(*v)),
509 style,
510 ));
511 }
512 Line::from(spans)
513}
514
515fn vendor_label(v: VendorId) -> &'static str {
516 match v {
517 VendorId::Anthropic => "Anthropic",
518 VendorId::Openai => "OpenAI",
519 VendorId::Zai => "Z.AI",
520 VendorId::Openrouter => "OpenRouter",
521 }
522}
523
524fn render_input(
525 input: &KeyInput,
526 focused: bool,
527 fg: Color,
528 accent: Color,
529 dim: Color,
530) -> Line<'static> {
531 let body = if input.buf.is_empty() {
532 "(empty)".to_string()
533 } else {
534 input.display()
535 };
536 let box_style = if focused {
537 Style::default().fg(accent).add_modifier(Modifier::BOLD)
538 } else {
539 Style::default().fg(fg)
540 };
541 let suffix_style = Style::default().fg(dim);
542 let suffix = if input.revealed { " [revealed]" } else { "" };
543 let cursor_hint = if focused {
544 format!(" ▏cur:{}", input.cursor)
545 } else {
546 String::new()
547 };
548 Line::from(vec![
549 Span::styled(format!(" {body}"), box_style),
550 Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
551 ])
552}
553
554fn parse_hex(s: &str) -> Option<Color> {
555 let (r, g, b) = crate::theme::parse_hex_rgb(s)?;
556 Some(Color::Rgb(r, g, b))
557}
558
559fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
561 let popup_h = (r.height * percent_y) / 100;
562 let popup_w = (r.width * percent_x) / 100;
563 Rect {
564 x: r.x + (r.width - popup_w) / 2,
565 y: r.y + (r.height - popup_h) / 2,
566 width: popup_w,
567 height: popup_h,
568 }
569}
570
571pub use crossterm::event::{KeyCode, KeyModifiers};
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use tempfile::NamedTempFile;
578
579 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
580 let mut s = SettingsState {
581 focus: Focus::Primary,
582 primary,
583 zai: KeyInput::from_config(Some(zai)),
584 openrouter: KeyInput::from_config(Some(opr)),
585 status: String::new(),
586 };
587 s.zai.dirty = true;
589 s.openrouter.dirty = true;
590 s
591 }
592
593 #[test]
594 fn focus_cycles_forward_and_backward() {
595 let order = [
596 Focus::Primary,
597 Focus::ZaiKey,
598 Focus::OpenrouterKey,
599 Focus::SaveButton,
600 ];
601 for (i, f) in order.iter().enumerate() {
602 assert_eq!(f.next(), order[(i + 1) % 4]);
603 assert_eq!(f.prev(), order[(i + 3) % 4]);
604 }
605 }
606
607 #[test]
608 fn key_input_insert_backspace_arrow() {
609 let mut k = KeyInput::default();
610 k.insert_char('a');
611 k.insert_char('b');
612 k.insert_char('c');
613 assert_eq!(k.buf, "abc");
614 assert_eq!(k.cursor, 3);
615 assert!(k.dirty);
616 k.move_left();
617 k.move_left();
618 assert_eq!(k.cursor, 1);
619 k.insert_char('x'); assert_eq!(k.buf, "axbc");
621 assert_eq!(k.cursor, 2);
622 k.backspace();
623 assert_eq!(k.buf, "abc");
624 assert_eq!(k.cursor, 1);
625 }
626
627 #[test]
628 fn key_input_masks_by_default_reveals_on_toggle() {
629 let mut k = KeyInput::default();
630 for c in "secret-key".chars() {
631 k.insert_char(c);
632 }
633 assert_eq!(k.display(), "•".repeat(10));
634 k.toggle_reveal();
635 assert_eq!(k.display(), "secret-key");
636 }
637
638 #[test]
639 fn key_input_handles_unicode() {
640 let mut k = KeyInput::default();
641 k.insert_char('a');
642 k.insert_char('→');
643 k.insert_char('b');
644 assert_eq!(k.buf, "a→b");
645 assert_eq!(k.cursor, 3);
646 k.move_left();
647 k.backspace(); assert_eq!(k.buf, "ab");
649 }
650
651 #[test]
652 fn save_to_path_writes_minimal_toml_when_starting_empty() {
653 let f = NamedTempFile::new().unwrap();
654 let s = state_with("zk", "ok", VendorId::Zai);
655 save_to_path(&s, f.path()).unwrap();
656 let raw = std::fs::read_to_string(f.path()).unwrap();
657 assert!(raw.contains("primary = \"zai\""));
658 assert!(raw.contains("[zai]"));
659 assert!(raw.contains("api_key = \"zk\""));
660 assert!(raw.contains("[openrouter]"));
661 assert!(raw.contains("api_key = \"ok\""));
662 }
663
664 #[test]
665 fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
666 let f = NamedTempFile::new().unwrap();
667 std::fs::write(
668 f.path(),
669 r##"# my comment
670[ui]
671# pre-existing comment
672primary = "anthropic"
673
674[zai]
675enabled = true
676api_key_env = "ZAI_API_KEY"
677# tier comment
678plan_tier = "pro"
679
680[openrouter]
681enabled = true
682api_key_env = "OPENROUTER_API_KEY"
683"##,
684 )
685 .unwrap();
686
687 let s = state_with("zk2", "ok2", VendorId::Openrouter);
688 save_to_path(&s, f.path()).unwrap();
689
690 let raw = std::fs::read_to_string(f.path()).unwrap();
691 assert!(raw.contains("# my comment"));
693 assert!(raw.contains("# pre-existing comment"));
694 assert!(raw.contains("# tier comment"));
695 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
697 assert!(raw.contains("plan_tier = \"pro\""));
698 assert!(raw.contains("primary = \"openrouter\""));
700 assert!(raw.contains("api_key = \"zk2\""));
702 assert!(raw.contains("api_key = \"ok2\""));
703 }
704
705 #[test]
706 fn save_does_not_write_empty_key_when_dirty_but_blank() {
707 let f = NamedTempFile::new().unwrap();
708 let mut s = state_with("", "", VendorId::Anthropic);
709 s.zai.dirty = true;
712 s.openrouter.dirty = true;
713 save_to_path(&s, f.path()).unwrap();
714 let raw = std::fs::read_to_string(f.path()).unwrap();
715 assert!(!raw.contains("api_key ="));
717 }
718
719 #[test]
720 #[cfg(unix)]
721 fn save_chmods_to_600() {
722 use std::os::unix::fs::PermissionsExt;
723 let f = NamedTempFile::new().unwrap();
724 let s = state_with("zk", "ok", VendorId::Zai);
725 save_to_path(&s, f.path()).unwrap();
726 let mode = std::fs::metadata(f.path()).unwrap().permissions().mode();
727 assert_eq!(mode & 0o777, 0o600);
728 }
729
730 #[test]
731 fn handle_key_tab_cycles_focus() {
732 let mut s = SettingsState {
733 focus: Focus::Primary,
734 primary: VendorId::Anthropic,
735 zai: KeyInput::default(),
736 openrouter: KeyInput::default(),
737 status: String::new(),
738 };
739 assert_eq!(
740 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
741 Action::Continue
742 );
743 assert_eq!(s.focus, Focus::ZaiKey);
744 assert_eq!(
745 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
746 Action::Continue
747 );
748 assert_eq!(s.focus, Focus::Primary);
749 }
750
751 #[test]
752 fn handle_key_esc_closes_without_saving() {
753 let mut s = SettingsState {
754 focus: Focus::Primary,
755 primary: VendorId::Anthropic,
756 zai: KeyInput::default(),
757 openrouter: KeyInput::default(),
758 status: String::new(),
759 };
760 assert_eq!(
761 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
762 Action::Close
763 );
764 }
765
766 #[test]
767 fn handle_key_left_right_cycles_primary_vendor() {
768 let mut s = SettingsState {
769 focus: Focus::Primary,
770 primary: VendorId::Anthropic,
771 zai: KeyInput::default(),
772 openrouter: KeyInput::default(),
773 status: String::new(),
774 };
775 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
776 assert_eq!(s.primary, VendorId::Openai);
777 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
778 assert_eq!(s.primary, VendorId::Zai);
779 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
780 assert_eq!(s.primary, VendorId::Openai);
781 }
782
783 #[test]
784 fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
785 let mut s = SettingsState {
786 focus: Focus::ZaiKey,
787 primary: VendorId::Anthropic,
788 zai: KeyInput::from_config(Some("secret")),
789 openrouter: KeyInput::default(),
790 status: String::new(),
791 };
792 assert!(!s.zai.revealed);
793 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
794 assert!(s.zai.revealed);
795 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
796 assert!(!s.zai.revealed);
797 }
798
799 #[test]
800 fn handle_key_ctrl_s_attempts_save_from_any_field() {
801 let f = NamedTempFile::new().unwrap();
802 let path_str = f.path().to_string_lossy().into_owned();
803 let s = state_with("zk", "ok", VendorId::Zai);
806 save_to_path(&s, f.path()).unwrap();
807 let raw = std::fs::read_to_string(&path_str).unwrap();
808 assert!(raw.contains("api_key = \"zk\""));
809 }
810}