use crate::app::App;
use crate::input::Input;
use crate::shop::Shop;
use crate::station::{Patience, Station};
#[derive(Debug, Default)]
pub struct Popup {
pub mode: Mode,
pub tab: Tab,
pub selected: usize,
pub scroll: usize,
pub name_input: Input,
}
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
pub enum Tab {
#[default]
Station,
Help,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Closed,
Browse,
SaveAs,
}
#[derive(Debug, Clone)]
pub enum Row {
SectionHeader(&'static str),
Model,
Boldness,
Patience,
Verbosity,
SavedStation(usize), UpdateAction, SaveAsAction,
Blank,
}
pub fn rows(app: &App) -> Vec<Row> {
let mut out = vec![
Row::SectionHeader("active"),
Row::Model,
Row::Boldness,
Row::Patience,
Row::Verbosity,
Row::Blank,
Row::SectionHeader("saved"),
];
for (i, st) in app.stations.iter().enumerate() {
if st.name == "demo" {
continue;
}
out.push(Row::SavedStation(i));
}
out.push(Row::Blank);
if app.active_origin.is_some() && app.is_dirty() {
out.push(Row::UpdateAction);
}
out.push(Row::SaveAsAction);
out
}
pub fn selectable_indices(rows: &[Row]) -> Vec<usize> {
rows.iter()
.enumerate()
.filter(|(_, r)| {
!matches!(r, Row::SectionHeader(_) | Row::Blank)
})
.map(|(i, _)| i)
.collect()
}
pub fn toggle(app: &mut App) {
match app.popup.mode {
Mode::Closed => {
app.popup.mode = Mode::Browse;
app.popup.tab = Tab::Station;
app.popup.selected = first_selectable(app);
app.popup.scroll = 0;
}
Mode::Browse | Mode::SaveAs => {
close(app);
}
}
}
pub fn close(app: &mut App) {
app.popup.mode = Mode::Closed;
app.popup.tab = Tab::Station;
app.popup.name_input = Input::new();
app.popup.selected = 0;
app.popup.scroll = 0;
}
fn first_selectable(app: &App) -> usize {
let r = rows(app);
selectable_indices(&r).first().copied().unwrap_or(0)
}
pub fn move_selection(app: &mut App, delta: i32) {
let r = rows(app);
let sel = selectable_indices(&r);
if sel.is_empty() {
return;
}
let pos = sel
.iter()
.position(|&i| i == app.popup.selected)
.unwrap_or(0);
let new_pos = ((pos as i32 + delta).rem_euclid(sel.len() as i32)) as usize;
app.popup.selected = sel[new_pos];
}
pub fn adjust(app: &mut App, delta: i32) {
let r = rows(app);
let row = r.get(app.popup.selected).cloned();
match row {
Some(Row::Model) => cycle_model(app, delta),
Some(Row::Boldness) => cycle_boldness(app, delta),
Some(Row::Patience) => cycle_patience(app, delta),
Some(Row::Verbosity) => cycle_verbosity(app, delta),
_ => {}
}
}
pub fn activate(app: &mut App) {
let r = rows(app);
let row = r.get(app.popup.selected).cloned();
match row {
Some(Row::SavedStation(idx)) => {
if let Some(st) = app.stations.get(idx).cloned() {
load_station(app, st);
}
}
Some(Row::UpdateAction) => {
commit_update(app);
}
Some(Row::SaveAsAction) => {
app.popup.mode = Mode::SaveAs;
app.popup.name_input = Input::new();
}
Some(Row::Model | Row::Boldness | Row::Patience | Row::Verbosity) => {
adjust(app, 1);
}
_ => {}
}
}
pub fn commit_save_as(app: &mut App) {
let name = app.popup.name_input.text.trim().to_string();
if name.is_empty() {
app.note("name can't be empty");
return;
}
if app.stations.iter().any(|s| s.name == name) {
app.note(format!("station '{}' already exists", name));
return;
}
let new_station = Station {
name: name.clone(),
model: app.active_station.model.clone(),
dials: app.active_station.dials,
};
let Some(path) = crate::station::config_path() else {
app.note("save failed: no $HOME");
return;
};
if let Err(e) = crate::station_save::append_to_file(&path, &new_station) {
app.note(format!("save failed: {}", e));
return;
}
app.stations.push(new_station);
app.active_station.name = name.clone();
app.active_origin = Some(name.clone());
app.note(format!("saved station '{}'", name));
app.popup.mode = Mode::Browse;
app.popup.name_input = Input::new();
}
pub fn commit_update(app: &mut App) {
let Some(origin) = app.active_origin.clone() else {
app.note("nothing to update; this is an untitled session");
return;
};
let Some(path) = crate::station::config_path() else {
app.note("save failed: no $HOME");
return;
};
let updated = Station {
name: origin.clone(),
model: app.active_station.model.clone(),
dials: app.active_station.dials,
};
if let Err(e) = crate::station_save::update_in_file(&path, &updated) {
app.note(format!("update failed: {}", e));
return;
}
if let Some(saved) = app.stations.iter_mut().find(|s| s.name == origin) {
*saved = updated;
}
app.note(format!("updated station '{}'", origin));
}
fn load_station(app: &mut App, st: Station) {
let shop = crate::shop::find_for_model(&app.shops, &st.model).cloned();
if let Some(shop) = shop {
let name = st.name.clone();
app.active_origin = Some(name.clone());
app.active_station = st;
app.active_shop = shop;
app.last_response_id = None;
app.note(format!("loaded station '{}'", name));
} else {
app.note(format!(
"can't load '{}': no shop runs '{}'",
st.name, st.model
));
}
}
fn cycle_model(app: &mut App, delta: i32) {
let all_models = collect_models(&app.shops);
if all_models.is_empty() {
return;
}
let cur = all_models
.iter()
.position(|m| m == &app.active_station.model)
.unwrap_or(0);
let n = all_models.len() as i32;
let next = ((cur as i32 + delta).rem_euclid(n)) as usize;
let next_model = all_models[next].clone();
if let Some(shop) = crate::shop::find_for_model(&app.shops, &next_model).cloned() {
app.active_station.model = next_model;
app.active_shop = shop;
app.last_response_id = None;
}
}
fn collect_models(shops: &[Shop]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for s in shops {
for m in &s.models {
if !out.contains(m) {
out.push(m.clone());
}
}
}
out
}
const BOLDNESS_PRESETS: &[(&str, f32)] = &[
("mild", 0.2),
("balanced", 0.7),
("spicy", 1.2),
("wild", 1.8),
];
fn cycle_boldness(app: &mut App, delta: i32) {
let states: Vec<Option<f32>> = std::iter::once(None)
.chain(BOLDNESS_PRESETS.iter().map(|(_, v)| Some(*v)))
.collect();
let cur = states
.iter()
.position(|s| match (s, app.active_station.dials.boldness) {
(None, None) => true,
(Some(a), Some(b)) => (*a - b).abs() < 1e-6,
_ => false,
})
.unwrap_or(0);
let n = states.len() as i32;
let next = ((cur as i32 + delta).rem_euclid(n)) as usize;
app.active_station.dials.boldness = states[next];
}
pub fn boldness_label(v: Option<f32>) -> String {
match v {
None => "—".into(),
Some(x) => {
let preset = BOLDNESS_PRESETS
.iter()
.find(|(_, val)| (val - x).abs() < 1e-6);
match preset {
Some((name, _)) => format!("{} ({:.1})", name, x),
None => format!("{:.2}", x),
}
}
}
}
fn cycle_patience(app: &mut App, delta: i32) {
let states: &[Option<Patience>] = &[
None,
Some(Patience::Quick),
Some(Patience::Steady),
Some(Patience::Slow),
];
let cur = states
.iter()
.position(|s| *s == app.active_station.dials.patience)
.unwrap_or(0);
let n = states.len() as i32;
let next = ((cur as i32 + delta).rem_euclid(n)) as usize;
app.active_station.dials.patience = states[next];
}
pub fn patience_label(v: Option<Patience>) -> &'static str {
match v {
None => "—",
Some(Patience::Quick) => "quick",
Some(Patience::Steady) => "steady",
Some(Patience::Slow) => "slow",
}
}
const VERBOSITY_PRESETS: &[(&str, u32)] = &[
("small", 256),
("medium", 1024),
("large", 4096),
("heaping", 8192),
];
fn cycle_verbosity(app: &mut App, delta: i32) {
let states: Vec<Option<u32>> = std::iter::once(None)
.chain(VERBOSITY_PRESETS.iter().map(|(_, v)| Some(*v)))
.collect();
let cur = states
.iter()
.position(|s| *s == app.active_station.dials.verbosity)
.unwrap_or(0);
let n = states.len() as i32;
let next = ((cur as i32 + delta).rem_euclid(n)) as usize;
app.active_station.dials.verbosity = states[next];
}
pub fn verbosity_label(v: Option<u32>) -> String {
match v {
None => "—".into(),
Some(x) => {
let preset = VERBOSITY_PRESETS.iter().find(|(_, val)| *val == x);
match preset {
Some((name, _)) => format!("{} ({})", name, x),
None => format!("{}", x),
}
}
}
}
pub fn switch_tab(app: &mut App) {
app.popup.tab = match app.popup.tab {
Tab::Station => Tab::Help,
Tab::Help => Tab::Station,
};
app.popup.mode = Mode::Browse;
app.popup.name_input = Input::new();
app.popup.selected = 0;
app.popup.scroll = 0;
}
pub fn open_help(app: &mut App) {
if app.popup.mode == Mode::Closed {
app.popup.mode = Mode::Browse;
app.popup.selected = 0;
}
app.popup.tab = Tab::Help;
app.popup.mode = Mode::Browse;
app.popup.name_input = Input::new();
app.popup.scroll = 0;
}
pub fn scroll(app: &mut App, delta: i32) {
let r = if delta > 0 {
app.popup.scroll.saturating_add(delta as usize)
} else {
app.popup.scroll.saturating_sub((-delta) as usize)
};
app.popup.scroll = r;
}
pub fn help_rows() -> Vec<(String, String)> {
vec![
("Enter".into(), "send input".into()),
("Esc".into(), "stop a streaming reply / clear note".into()),
("Ctrl-C".into(), "quit immediately".into()),
("Ctrl-T".into(), "toggle page / scroll view".into()),
("PgUp / PgDn".into(), "page or scroll up / down".into()),
("← / →".into(), "move the input cursor".into()),
("Home / End".into(), "jump to input start / end".into()),
("Backspace / Delete".into(), "delete before / after cursor".into()),
("Ctrl-A / Ctrl-E".into(), "jump to input start / end".into()),
("Ctrl-U".into(), "kill to start of line".into()),
("Ctrl-K".into(), "kill to end of line".into()),
("Ctrl-W".into(), "kill previous word".into()),
("Ctrl-S".into(), "open / close this popup".into()),
("Tab / F1".into(), "switch Station / Help tab".into()),
("Mouse wheel".into(), "scroll in page / scroll view".into()),
("".into(), "".into()),
("In the Station tab:".into(), "".into()),
("↑ / ↓".into(), "move between selectable rows".into()),
("← / →".into(), "adjust model / dials (also Enter)".into()),
("Enter".into(), "load station / update / save-as".into()),
("PgUp / PgDn".into(), "scroll the popup body".into()),
("Esc".into(), "close popup".into()),
("".into(), "".into()),
("In the Help tab:".into(), "".into()),
("Esc / Tab".into(), "leave Help back to Station".into()),
("F1".into(), "open Help tab from anywhere".into()),
]
}