feat(tui/theme): improve Create Custom spinner UX\n\n- Add visible caret and field focus; Up/Down navigates Interval→Frames→Save/Cancel\n- Save/Cancel buttons at bottom; Enter confirms action\n- Save writes a single "Custom" spinner (upsert) and activates it, then returns to Overview\n- Titles: Overview, Overview » Change Theme, Overview » Change Spinner, Overview » Change Spinner » Create Custom\n- Ensure pane heights so Frames + Save/Cancel are visible\n- Fix Instant scheduling (use checked_add)\n- Upsert custom spinner in registry

This commit is contained in:
James Peter
2025-09-10 17:47:38 +10:00
parent a60099169c
commit 08a2f0ee50
9 changed files with 375 additions and 73 deletions
+3
View File
@@ -1435,6 +1435,9 @@ impl State {
Self {
approved_commands: self.approved_commands.clone(),
history: self.history.clone(),
// Preserve request_ordinal so reconfigurations (e.g., /reasoning)
// do not reset provider ordering mid-session.
request_ordinal: self.request_ordinal,
..Default::default()
}
}
+30
View File
@@ -426,6 +426,36 @@ pub fn set_tui_spinner_name(codex_home: &Path, spinner_name: &str) -> anyhow::Re
Ok(())
}
/// Save or update a custom spinner under `[tui.spinner.custom.<name>]` and set it active.
pub fn set_custom_spinner(
codex_home: &Path,
name: &str,
interval: u64,
frames: &[String],
) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = match std::fs::read_to_string(&config_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Write custom spinner
let node = &mut doc["tui"]["spinner"]["custom"][name];
node["interval"] = toml_edit::value(interval as i64);
let mut arr = toml_edit::Array::default();
for s in frames { arr.push(s.as_str()); }
node["frames"] = toml_edit::value(arr);
// Set as active
doc["tui"]["spinner"]["name"] = toml_edit::value(name);
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+11 -1
View File
@@ -229,16 +229,26 @@ pub struct SpinnerSelection {
/// by Codex. Defaults to "diamond".
#[serde(default = "default_spinner_name")]
pub name: String,
/// Custom spinner definitions saved by the user
#[serde(default)]
pub custom: std::collections::HashMap<String, CustomSpinner>,
}
fn default_spinner_name() -> String { "diamond".to_string() }
impl Default for SpinnerSelection {
fn default() -> Self {
Self { name: default_spinner_name() }
Self { name: default_spinner_name(), custom: Default::default() }
}
}
/// User-defined custom spinner
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct CustomSpinner {
pub interval: u64,
pub frames: Vec<String>,
}
/// Configuration for syntax highlighting in Markdown code blocks.
///
/// `theme` accepts the following values:
+1 -18
View File
@@ -229,7 +229,7 @@
},
"barScroll": {
"interval": 80,
"frames": ["▉▊▋▌▍▎▏","▊▋▌▍▎▏▉","▋▌▍▎▏▉▊","▌▍▎▏▉▊▋","▍▎▏▉▊▋▌","▎▏▉▊▋▌▍","▏▉▊▋▌▍▎","▉▊▋▌▍▎▏"],
"frames": ["▉▊▋▌▍▎▏","▏▉▊▋▌▍▎","▎▏▉▊▋▌▍","▍▎▏▉▊▋▌","▌▍▎▏▉▊▋","▋▌▍▎▏▉▊","▊▋▌▍▎▏▉","▉▊▋▌▍▎▏"],
"label": "Bar Scroll"
}
},
@@ -244,22 +244,5 @@
"frames": ["+","x","*"],
"label": "Cross Sparkle"
}
},
"Emoji": {
"earthRotation": {
"interval": 180,
"frames": ["🌍","🌎","🌏"],
"label": "Earth Rotation"
},
"moonPhases": {
"interval": 80,
"frames": ["🌑","🌒","🌓","🌔","🌕","🌖","🌗","🌘"],
"label": "Moon Phases"
},
"weatherCycle": {
"interval": 100,
"frames": ["☀️","☀️","☀️","🌤","⛅️","🌥","☁️","🌧","🌨","🌧","🌨","🌧","🌨","⛈","🌨","🌧","🌨","☁️","🌥","⛅️","🌤","☀️","☀️"],
"label": "Weather Cycle"
}
}
}
+40 -2
View File
@@ -193,10 +193,48 @@ impl ChatComposer {
let animation_flag_clone = Arc::clone(&animation_flag);
let app_event_tx_clone = self.app_event_tx.clone();
// Drive redraws at the spinner's native cadence with a
// phase‑aligned, monotonic scheduler to minimize drift and
// reduce perceived frame skipping under load. We purposely
// avoid very small intervals to keep CPU impact low.
thread::spawn(move || {
use std::time::Instant;
// Default to ~120ms if spinner state is not yet initialized
let default_ms: u64 = 120;
// Clamp to a sane floor so we never busy loop if a custom spinner
// has an extremely small interval configured.
let min_ms: u64 = 60; // ~16 FPS upper bound for this thread
// Determine the target period. If the user changes the spinner
// while running, we'll still get correct visual output because
// frames are time‑based at render; this cadence simply requests
// redraws.
let period_ms = crate::spinner::current_spinner()
.interval_ms
.max(min_ms)
.max(1);
let period = Duration::from_millis(period_ms); // fallback uses default below if needed
let mut next = Instant::now()
.checked_add(if period_ms == 0 { Duration::from_millis(default_ms) } else { period })
.unwrap_or_else(Instant::now);
while animation_flag_clone.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(200)); // Slower animation
app_event_tx_clone.send(crate::app_event::AppEvent::RequestRedraw);
let now = Instant::now();
if now < next {
let sleep_dur = next - now;
thread::sleep(sleep_dur);
} else {
// If we're late (system busy), request a redraw immediately.
app_event_tx_clone.send(crate::app_event::AppEvent::RequestRedraw);
// Step the schedule forward by whole periods to avoid
// bursty catch‑up redraws.
let mut target = next;
while target <= now {
if let Some(t) = target.checked_add(period) { target = t; } else { break; }
}
next = target;
}
}
});
@@ -187,12 +187,17 @@ impl ThemeSelectionView {
}
} else {
let names = crate::spinner::spinner_names();
if self.selected_spinner_index + 1 < names.len() {
// Allow moving onto the extra pseudo-row (Create your own…)
if self.selected_spinner_index + 1 <= names.len() {
self.selected_spinner_index += 1;
if let Some(name) = names.get(self.selected_spinner_index) {
self.current_spinner = name.clone();
self.app_event_tx
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
if self.selected_spinner_index < names.len() {
if let Some(name) = names.get(self.selected_spinner_index) {
self.current_spinner = name.clone();
self.app_event_tx
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
}
} else {
// On the pseudo-row: do not change current spinner preview
}
}
}
@@ -228,28 +233,46 @@ impl ThemeSelectionView {
}
}
Mode::Overview => {}
Mode::CreateSpinner(_) => {}
}
self.mode = Mode::Overview;
}
}
#[derive(Copy, Clone, Eq, PartialEq)]
enum Mode { Overview, Themes, Spinner }
#[derive(Clone, PartialEq)]
enum Mode { Overview, Themes, Spinner, CreateSpinner(CreateState) }
#[derive(Clone, PartialEq)]
struct CreateState {
step: CreateStep,
interval: String,
frames: String,
action_idx: usize, // 0 = Save, 1 = Cancel
}
#[derive(Clone, PartialEq)]
enum CreateStep { Interval, Frames, Action }
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn desired_height(&self, _width: u16) -> u16 {
match self.mode {
match &self.mode {
// Border (2) + inner padding (2) + 2 content rows = 6
Mode::Overview => 6,
// Detail lists: fixed 9 visible rows (max), shrink if fewer
Mode::Themes => {
let n = Self::get_theme_options().len() as u16;
4 + n.min(9)
// Border(2) + padding(2) + title(1)+space(1) + list
6 + n.min(9)
}
Mode::Spinner => {
let n = crate::spinner::spinner_names().len() as u16;
4 + n.min(9)
// +1 for the "Create your own…" pseudo-row
let n = (crate::spinner::spinner_names().len() as u16) + 1;
// Border(2) + padding(2) + title(1)+space(1) + list
6 + n.min(9)
}
// Title + spacer + 2 fields + buttons + help = 6 content rows
// plus border(2) + padding(2) = 10; add 2 rows headroom for small terminals
Mode::CreateSpinner(_) => 12,
}
}
@@ -260,11 +283,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
modifiers: KeyModifiers::NONE,
..
} => {
match self.mode {
Mode::Overview => {
self.overview_selected_index = self.overview_selected_index.saturating_sub(1) % 2;
// In create form, Up navigates fields/buttons
if let Mode::CreateSpinner(ref mut s) = self.mode {
s.step = match s.step {
CreateStep::Interval => CreateStep::Action,
CreateStep::Frames => CreateStep::Interval,
CreateStep::Action => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Action }
};
} else {
match self.mode.clone() {
Mode::Overview => {
self.overview_selected_index = self.overview_selected_index.saturating_sub(1) % 2;
}
_ => self.move_selection_up(),
}
_ => self.move_selection_up(),
}
}
KeyEvent {
@@ -272,11 +304,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
modifiers: KeyModifiers::NONE,
..
} => {
match self.mode {
Mode::Overview => {
self.overview_selected_index = (self.overview_selected_index + 1) % 2;
// In create form, Down navigates fields/buttons
if let Mode::CreateSpinner(ref mut s) = self.mode {
s.step = match s.step {
CreateStep::Interval => CreateStep::Frames,
CreateStep::Frames => CreateStep::Action,
CreateStep::Action => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Action }
};
} else {
match &self.mode.clone() {
Mode::Overview => {
self.overview_selected_index = (self.overview_selected_index + 1) % 2;
}
_ => self.move_selection_down(),
}
_ => self.move_selection_down(),
}
}
KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. } => {}
@@ -286,7 +327,9 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
modifiers: KeyModifiers::NONE,
..
} => {
match self.mode {
// Take ownership of mode to avoid borrowing self while we may assign to self.mode
let current_mode = std::mem::replace(&mut self.mode, Mode::Overview);
match current_mode {
Mode::Overview => {
if self.overview_selected_index == 0 {
self.revert_theme_on_back = self.current_theme;
@@ -300,8 +343,44 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
self.just_entered_spinner = true;
}
}
Mode::Themes => self.confirm_theme(),
Mode::Spinner => self.confirm_spinner(),
Mode::Themes => {
// confirm_theme sets self.mode back to Overview
self.confirm_theme()
}
Mode::Spinner => {
// If tail row selected (Create your own…), open create form
let names = crate::spinner::spinner_names();
if self.selected_spinner_index >= names.len() {
self.mode = Mode::CreateSpinner(CreateState { step: CreateStep::Interval, interval: String::from("120"), frames: String::new(), action_idx: 0 });
} else {
self.confirm_spinner()
}
}
Mode::CreateSpinner(mut s) => {
let mut go_overview = false;
match s.step {
CreateStep::Interval => { s.step = CreateStep::Frames; }
CreateStep::Frames => { s.step = CreateStep::Action; }
CreateStep::Action => {
if s.action_idx == 0 {
let interval = s.interval.trim().parse::<u64>().unwrap_or(120);
let frames: Vec<String> = s.frames.split(',').map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).collect();
if !frames.is_empty() {
if let Ok(home) = codex_core::config::find_codex_home() { let _ = codex_core::config::set_custom_spinner(&home, "custom", interval, &frames); }
crate::spinner::add_custom_spinner("custom".to_string(), "Custom".to_string(), interval, frames);
crate::spinner::switch_spinner("custom");
self.current_spinner = "custom".to_string();
self.revert_spinner_on_back = self.current_spinner.clone();
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("✓ Custom spinner saved".to_string()));
go_overview = true;
} else {
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("Provide at least one frame (comma-separated)".to_string()));
}
} else { /* Cancel → return to overview */ go_overview = true; }
}
}
if go_overview { self.mode = Mode::Overview; } else { self.mode = Mode::CreateSpinner(s); }
}
}
}
KeyEvent {
@@ -311,9 +390,21 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
} => {
match self.mode {
Mode::Overview => self.is_complete = true,
Mode::CreateSpinner(_) => { self.mode = Mode::Spinner; }
_ => self.cancel_detail(),
}
}
KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. } => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
match s.step { CreateStep::Interval => s.interval.push(c), CreateStep::Frames => s.frames.push(c), CreateStep::Action => {} }
}
}
KeyEvent { code: KeyCode::Backspace, .. } => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
let tgt = match s.step { CreateStep::Interval => &mut s.interval, CreateStep::Frames => &mut s.frames, CreateStep::Action => { return; } };
tgt.pop();
}
}
_ => {}
}
}
@@ -410,15 +501,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
lines.push(Line::from(spans));
}
} else if matches!(self.mode, Mode::Themes) {
// Header: Choose Theme
lines.push(Line::from(Span::styled(
"Choose Theme",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
// Compute anchored window: top until middle, then center; bottom shows end
let count = options.len();
let visible = available_height.min(9).max(1);
let visible = available_height.saturating_sub(1).min(9).max(1);
let (start, _vis, _mid) = crate::util::list_window::anchored_window(
self.selected_theme_index,
count,
visible,
);
let end = (start + visible).min(count);
let end = (start + visible).min(count + 1); // +1 for "Create your own…"
for i in start..end {
let (theme_enum, name, description) = &options[i];
let is_selected = i == self.selected_theme_index;
@@ -460,6 +556,58 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
lines.push(Line::from(spans));
}
} else if matches!(self.mode, Mode::CreateSpinner(_)) {
// Inline form for custom spinner with visible selection & caret
let theme = crate::theme::current_theme();
if let Mode::CreateSpinner(s) = &self.mode {
let mut form_lines = Vec::new();
form_lines.push(Line::from(Span::styled(
"Overview » Change Spinner » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::from(" "));
let caret = Span::styled("▏", Style::default().fg(theme.info));
// Interval line
{
let active = matches!(s.step, CreateStep::Interval);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
spans.push(Span::styled("Interval (ms): ", Style::default().fg(theme.keyword)));
if s.interval.is_empty() && active { spans.push(Span::styled("(e.g., 120)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
else if active { spans.push(Span::raw(s.interval.clone())); spans.push(caret.clone()); }
else { spans.push(Span::raw(s.interval.clone())); }
form_lines.push(Line::from(spans));
}
// Frames line
{
let active = matches!(s.step, CreateStep::Frames);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
spans.push(Span::styled("Frames (comma-separated): ", Style::default().fg(theme.keyword)));
if s.frames.is_empty() && active { spans.push(Span::styled("(e.g., ., .., …)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
else if active { spans.push(Span::raw(s.frames.clone())); spans.push(caret.clone()); }
else { spans.push(Span::raw(s.frames.clone())); }
form_lines.push(Line::from(spans));
}
// Action buttons (Save / Cancel)
{
let mut spans: Vec<Span> = Vec::new();
let active = matches!(s.step, CreateStep::Action);
let save_selected = active && s.action_idx == 0;
let cancel_selected = active && s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Save ]", sel(save_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Cancel ]", sel(cancel_selected)));
form_lines.push(Line::from(spans));
}
Paragraph::new(form_lines).alignment(Alignment::Left).render(body_area, buf);
}
return;
} else {
// Spinner: render one centered preview row per spinner, matching the composer title
use std::time::{SystemTime, UNIX_EPOCH};
@@ -468,8 +616,10 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
.unwrap_or_default()
.as_millis();
let names = crate::spinner::spinner_names();
let count = names.len();
let visible = available_height.min(9).max(1);
// Include an extra pseudo-row for "Create your own…"
let count = names.len() + 1;
// Reserve two rows (header + spacer)
let visible = available_height.saturating_sub(2).min(9).max(1);
let (start, _vis, _mid) = crate::util::list_window::anchored_window(
self.selected_spinner_index,
count,
@@ -485,12 +635,33 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
max_label_len = max_label_len.max(label.chars().count() as u16);
}
// Render header (left-aligned) and spacer row
let header_rect = Rect { x: body_area.x, y: body_area.y, width: body_area.width, height: 1 };
let header = Line::from(Span::styled(
"Overview » Change Spinner",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
));
Paragraph::new(header).alignment(Alignment::Left).render(header_rect, buf);
if header_rect.y + 1 < body_area.y + body_area.height {
let spacer = Rect { x: body_area.x, y: body_area.y + 1, width: body_area.width, height: 1 };
Paragraph::new(Line::from(" ")).render(spacer, buf);
}
for row_idx in 0..(end - start) {
let i = start + row_idx;
let y = body_area.y + row_idx as u16;
// rows start two below (header + spacer)
let y = body_area.y + 2 + row_idx as u16;
if y >= body_area.y + body_area.height { break; }
let row_rect = Rect { x: body_area.x, y, width: body_area.width, height: 1 };
if i >= names.len() {
let mut spans = Vec::new();
let is_selected = i == self.selected_spinner_index;
spans.push(Span::styled(if is_selected { "› " } else { " " }.to_string(), Style::default().fg(if is_selected { theme.keyword } else { theme.text } )));
spans.push(Span::styled("Create your own…", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)));
Paragraph::new(Line::from(spans)).render(row_rect, buf);
continue;
}
let name = &names[i];
let is_selected = i == self.selected_spinner_index;
let def = crate::spinner::find_spinner_by_name(name).unwrap_or(crate::spinner::current_spinner());
+43 -11
View File
@@ -451,7 +451,6 @@ enum SystemPlacement {
}
impl ChatWidget<'_> {
/// Compute an OrderKey for system (non‑LLM) notices in a way that avoids
/// creating multiple synthetic request buckets before the first provider turn.
fn system_order_key(
@@ -483,9 +482,21 @@ impl ChatWidget<'_> {
self.internal_seq = self.internal_seq.saturating_add(1);
match placement {
SystemPlacement::EarlyInCurrent => OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq },
SystemPlacement::EndOfCurrent => OrderKey { req, out: i32::MAX, seq: self.internal_seq },
SystemPlacement::PrePromptInCurrent => OrderKey { req, out: i32::MIN, seq: self.internal_seq },
SystemPlacement::EarlyInCurrent => OrderKey {
req,
out: i32::MIN + 2,
seq: self.internal_seq,
},
SystemPlacement::EndOfCurrent => OrderKey {
req,
out: i32::MAX,
seq: self.internal_seq,
},
SystemPlacement::PrePromptInCurrent => OrderKey {
req,
out: i32::MIN,
seq: self.internal_seq,
},
}
}
@@ -642,7 +653,11 @@ impl ChatWidget<'_> {
if k.req == req {
last_in_req = Some(match last_in_req {
Some(prev) => {
if *k > prev { *k } else { prev }
if *k > prev {
*k
} else {
prev
}
}
None => *k,
});
@@ -650,8 +665,16 @@ impl ChatWidget<'_> {
}
self.internal_seq = self.internal_seq.saturating_add(1);
match last_in_req {
Some(last) => OrderKey { req, out: last.out, seq: last.seq.saturating_add(1) },
None => OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq },
Some(last) => OrderKey {
req,
out: last.out,
seq: last.seq.saturating_add(1),
},
None => OrderKey {
req,
out: i32::MIN + 2,
seq: self.internal_seq,
},
}
}
@@ -4652,7 +4675,7 @@ impl ChatWidget<'_> {
codex_core::config_types::ThemeName::DarkPaperLightPro => "Dark - Paper Light Pro",
codex_core::config_types::ThemeName::Custom => "Custom",
};
let message = format!("✓ Theme changed to {}", theme_name);
let message = format!("Theme changed to {}", theme_name);
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_background_event(message),
@@ -4677,7 +4700,7 @@ impl ChatWidget<'_> {
}
// Confirmation message (replaceable system notice)
let message = format!("✓ Spinner changed to {}", spinner_name);
let message = format!("Spinner changed to {}", spinner_name);
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_background_event(message),
@@ -8536,7 +8559,14 @@ impl ChatWidget<'_> {
// No conflicts; create a merge commit and continue
let _ = Command::new("git")
.current_dir(&work_cwd)
.args(["commit", "-m", &format!("merge {} into {} before finalize", default_branch, branch_name)])
.args([
"commit",
"-m",
&format!(
"merge {} into {} before finalize",
default_branch, branch_name
),
])
.output()
.await;
} else {
@@ -8552,7 +8582,9 @@ impl ChatWidget<'_> {
.map(|s| s.lines().any(|l| l.starts_with('U') || l.contains("UU ")))
.unwrap_or(false);
if has_conflicts {
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
+35 -13
View File
@@ -47,7 +47,11 @@ lazy_static! {
Value::Object(map) => {
// Mixed-mode tolerant parse: for each top-level entry
for (k, v) in map.into_iter() {
if k == "Default" { continue; }
// If this is the pointer entry (Default: "name"), skip it;
// but allow a group actually named "Default" (object).
if k == "Default" {
if !v.is_string() { /* fall through to parse group */ } else { continue; }
}
match v {
Value::Object(inner) => {
if inner.get("interval").is_some() {
@@ -86,16 +90,7 @@ lazy_static! {
idx
};
static ref CURRENT_INDEX: RwLock<usize> = RwLock::new(*DEFAULT_INDEX);
static ref GLOBAL_MAX_FRAME_LEN: usize = {
let mut maxlen = 0usize;
for s in ALL_SPINNERS.iter() {
for f in &s.frames {
let l = f.chars().count();
if l > maxlen { maxlen = l; }
}
}
maxlen
};
static ref CUSTOM_SPINNERS: RwLock<Vec<Spinner>> = RwLock::new(Vec::new());
}
pub fn init_spinner(name: &str) { switch_spinner(name); }
@@ -125,6 +120,13 @@ pub fn current_spinner() -> &'static Spinner {
pub fn find_spinner_by_name(name: &str) -> Option<&'static Spinner> {
let raw = name.trim();
// custom first
if let Some(pos) = CUSTOM_SPINNERS.read().unwrap().iter().position(|s| s.name == raw) {
// Leak to 'static for shared ref (only for custom preview; safe for session lifetime)
let s = CUSTOM_SPINNERS.read().unwrap()[pos].clone();
let b = Box::leak(Box::new(s));
return Some(b);
}
ALL_SPINNERS
.iter()
.find(|s| s.name == raw)
@@ -134,7 +136,11 @@ pub fn find_spinner_by_name(name: &str) -> Option<&'static Spinner> {
})
}
pub fn spinner_names() -> Vec<String> { ALL_SPINNERS.iter().map(|s| s.name.clone()).collect() }
pub fn spinner_names() -> Vec<String> {
let mut v: Vec<String> = ALL_SPINNERS.iter().map(|s| s.name.clone()).collect();
v.extend(CUSTOM_SPINNERS.read().unwrap().iter().map(|s| s.name.clone()));
v
}
pub fn spinner_label_for(name: &str) -> String {
find_spinner_by_name(name)
@@ -209,4 +215,20 @@ fn vpush(out: &mut Vec<Spinner>, name: &str, sj: SpinnerJson, group_override: Op
out.push(Spinner { name: name.to_string(), label, group, interval_ms: sj.interval, frames: sj.frames });
}
pub fn global_max_frame_len() -> usize { *GLOBAL_MAX_FRAME_LEN }
pub fn global_max_frame_len() -> usize {
let mut maxlen = 0usize;
for s in ALL_SPINNERS.iter() { for f in &s.frames { maxlen = maxlen.max(f.chars().count()); } }
for s in CUSTOM_SPINNERS.read().unwrap().iter() { for f in &s.frames { maxlen = maxlen.max(f.chars().count()); } }
maxlen
}
pub fn set_custom_spinners(custom: Vec<Spinner>) { *CUSTOM_SPINNERS.write().unwrap() = custom; }
pub fn add_custom_spinner(name: String, label: String, interval_ms: u64, frames: Vec<String>) {
let mut v = CUSTOM_SPINNERS.write().unwrap();
if let Some(pos) = v.iter().position(|s| s.name == name) {
v[pos] = Spinner { name, label, group: "Custom".to_string(), interval_ms, frames };
} else {
v.push(Spinner { name, label, group: "Custom".to_string(), interval_ms, frames });
}
}
+14 -1
View File
@@ -50,8 +50,21 @@ impl std::fmt::Debug for TerminalInfo {
pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
// Initialize the theme based on config
crate::theme::init_theme(&config.tui.theme);
// Initialize spinner selection based on config
// Initialize spinner selection and register custom spinners from config
crate::spinner::init_spinner(&config.tui.spinner.name);
if !config.tui.spinner.custom.is_empty() {
let mut custom = Vec::new();
for (name, cs) in &config.tui.spinner.custom {
custom.push(crate::spinner::Spinner {
name: name.clone(),
label: crate::spinner::spinner_label_for(name),
group: "Custom".to_string(),
interval_ms: cs.interval,
frames: cs.frames.clone(),
});
}
crate::spinner::set_custom_spinners(custom);
}
// Initialize syntax highlighting preference from config
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);