use anyhow::Result;
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
use mold_core::{
Config, GenerateResponse, ModelInfoExtended, OutputFormat, Scheduler, ServerStatus,
SseProgressEvent,
};
use rand::Rng;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use std::collections::VecDeque;
use tokio::sync::mpsc;
use tui_textarea::TextArea;
use crate::action::{Action, View};
use crate::event::map_event;
use crate::model_info::{capabilities_for_family, family_for_model, ModelCapabilities};
use crate::ui::theme::Theme;
pub enum BackgroundEvent {
Progress(SseProgressEvent),
GenerationComplete {
response: Box<GenerateResponse>,
from_local: bool,
},
Error(String),
GalleryScanComplete(Vec<GalleryEntry>),
PullComplete(String),
ThumbnailsReady,
GalleryPreviewReady(Vec<u8>),
ServerConnected {
url: String,
models: Vec<ModelInfoExtended>,
},
ServerUnreachable(String),
ModelRemoveComplete(String),
ModelRemoveFailed(String),
UpscaleDownloadProgress(SseProgressEvent),
UpscaleProgress { tile: usize, total: usize },
UpscaleComplete {
image_data: Vec<u8>,
source_path: std::path::PathBuf,
model: String,
scale_factor: u32,
original_width: u32,
original_height: u32,
upscale_time_ms: u64,
},
UpscaleFailed(String),
ServerStatusUpdate(Option<Box<ServerStatus>>),
CatalogRefreshed(Vec<ModelInfoExtended>),
GalleryDeleteFailed(String),
ChainProgress(mold_core::ChainProgressEvent),
ChainComplete {
response: Box<mold_core::ChainResponse>,
},
ChainError(String),
}
#[derive(Debug, Clone)]
pub struct ProgressLogEntry {
pub message: String,
pub style: ProgressStyle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProgressStyle {
Done,
Info,
Warning,
Error,
}
pub(crate) const MAX_LOG_ENTRIES: usize = 500;
#[derive(Debug, Default)]
pub struct ProgressState {
pub log: Vec<ProgressLogEntry>,
pub current_stage: Option<String>,
pub denoise_step: usize,
pub denoise_total: usize,
pub denoise_elapsed_ms: u64,
pub weight_loaded: u64,
pub weight_total: u64,
pub weight_component: String,
pub download_filename: String,
pub download_bytes: u64,
pub download_total: u64,
pub download_batch_bytes: u64,
pub download_batch_total: u64,
pub download_batch_elapsed_ms: u64,
pub download_rate_bps: Option<f64>,
pub download_eta_secs: Option<f64>,
pub download_file_index: usize,
pub download_total_files: usize,
pub downloading: bool,
download_samples: VecDeque<(u64, u64)>,
pub generation_started_at: Option<std::time::Instant>,
pub stage_started_at: Option<std::time::Instant>,
pub stage_index: usize,
}
impl ProgressState {
pub fn clear(&mut self) {
self.log.clear();
self.current_stage = None;
self.denoise_step = 0;
self.denoise_total = 0;
self.denoise_elapsed_ms = 0;
self.weight_loaded = 0;
self.weight_total = 0;
self.weight_component.clear();
self.download_filename.clear();
self.download_bytes = 0;
self.download_total = 0;
self.download_batch_bytes = 0;
self.download_batch_total = 0;
self.download_batch_elapsed_ms = 0;
self.download_rate_bps = None;
self.download_eta_secs = None;
self.download_file_index = 0;
self.download_total_files = 0;
self.downloading = false;
self.download_samples.clear();
self.generation_started_at = None;
self.stage_started_at = None;
self.stage_index = 0;
}
pub fn mark_generation_start(&mut self) {
self.generation_started_at = Some(std::time::Instant::now());
self.stage_started_at = None;
self.stage_index = 0;
}
pub fn generation_elapsed(&self) -> Option<std::time::Duration> {
self.generation_started_at.map(|t| t.elapsed())
}
pub fn stage_elapsed(&self) -> Option<std::time::Duration> {
self.stage_started_at.map(|t| t.elapsed())
}
pub fn push_log(&mut self, entry: ProgressLogEntry) {
self.log.push(entry);
if self.log.len() > MAX_LOG_ENTRIES {
let overflow = self.log.len() - MAX_LOG_ENTRIES;
self.log.drain(..overflow);
}
}
fn clear_download(&mut self) {
self.download_filename.clear();
self.download_bytes = 0;
self.download_total = 0;
self.download_batch_bytes = 0;
self.download_batch_total = 0;
self.download_batch_elapsed_ms = 0;
self.download_rate_bps = None;
self.download_eta_secs = None;
self.download_file_index = 0;
self.download_total_files = 0;
self.downloading = false;
self.download_samples.clear();
}
pub fn is_downloading(&self) -> bool {
self.downloading
}
pub fn download_status_text(&self) -> &str {
if self.download_batch_total > 0 {
"Downloading..."
} else if self
.current_stage
.as_deref()
.is_some_and(|s| s.contains("Verifying"))
{
"Verifying..."
} else if self.downloading {
"Preparing..."
} else {
"Downloading..."
}
}
fn clear_weight(&mut self) {
self.weight_loaded = 0;
self.weight_total = 0;
self.weight_component.clear();
}
fn record_download_sample(&mut self, elapsed_ms: u64, position: u64) {
const MAX_SAMPLES: usize = 8;
const MIN_SAMPLE_WINDOW_MS: u64 = 1_000;
if self
.download_samples
.back()
.is_some_and(|(last_elapsed_ms, _)| *last_elapsed_ms == elapsed_ms)
{
let _ = self.download_samples.pop_back();
}
self.download_samples.push_back((elapsed_ms, position));
while self.download_samples.len() > MAX_SAMPLES {
self.download_samples.pop_front();
}
if self.download_samples.len() < 2 {
self.download_rate_bps = None;
self.download_eta_secs = None;
return;
}
let (t_old_ms, b_old) = self
.download_samples
.front()
.expect("sample window is non-empty");
let (t_new_ms, b_new) = self
.download_samples
.back()
.expect("sample window is non-empty");
let dt_ms = t_new_ms.saturating_sub(*t_old_ms);
if dt_ms < MIN_SAMPLE_WINDOW_MS {
self.download_rate_bps = None;
self.download_eta_secs = None;
return;
}
let dt = dt_ms as f64 / 1_000.0;
let rate = b_new.saturating_sub(*b_old) as f64 / dt;
if rate < 1.0 {
self.download_rate_bps = None;
self.download_eta_secs = None;
return;
}
self.download_rate_bps = Some(rate);
self.download_eta_secs =
Some(self.download_batch_total.saturating_sub(position) as f64 / rate);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenerateFocus {
Navigation,
Prompt,
NegativePrompt,
Parameters,
}
impl GenerateFocus {
pub fn next(self, has_negative: bool) -> Self {
match self {
Self::Navigation => Self::Prompt,
Self::Prompt if has_negative => Self::NegativePrompt,
Self::Prompt => Self::Parameters,
Self::NegativePrompt => Self::Parameters,
Self::Parameters => Self::Prompt,
}
}
pub fn prev(self, has_negative: bool) -> Self {
match self {
Self::Navigation => Self::Parameters,
Self::Prompt => Self::Parameters,
Self::NegativePrompt => Self::Prompt,
Self::Parameters if has_negative => Self::NegativePrompt,
Self::Parameters => Self::Prompt,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamField {
Model,
Width,
Height,
Steps,
Guidance,
Seed,
SeedValue,
Batch,
Format,
Mode,
Host,
Scheduler,
Lora,
Expand,
Offload,
SourceImage,
Strength,
MaskImage,
Frames,
Fps,
ControlImage,
ControlModel,
ControlScale,
ResetDefaults,
UnloadModel,
}
impl ParamField {
pub fn visible_fields(caps: &ModelCapabilities, mode: InferenceMode) -> Vec<ParamField> {
let mut fields = vec![
ParamField::Model,
ParamField::Width,
ParamField::Height,
ParamField::Steps,
ParamField::Guidance,
ParamField::Seed,
ParamField::SeedValue,
ParamField::Batch,
ParamField::Format,
ParamField::Mode,
];
if mode != InferenceMode::Local {
fields.push(ParamField::Host);
}
if caps.supports_scheduler {
fields.push(ParamField::Scheduler);
}
if caps.supports_lora {
fields.push(ParamField::Lora);
}
fields.push(ParamField::Expand);
fields.push(ParamField::Offload);
if caps.supports_video {
fields.push(ParamField::Frames);
fields.push(ParamField::Fps);
}
if caps.supports_source_image {
fields.push(ParamField::SourceImage);
}
if caps.supports_strength {
fields.push(ParamField::Strength);
}
if caps.supports_mask {
fields.push(ParamField::MaskImage);
}
if caps.supports_controlnet {
fields.push(ParamField::ControlImage);
fields.push(ParamField::ControlModel);
fields.push(ParamField::ControlScale);
}
fields.push(ParamField::ResetDefaults);
fields.push(ParamField::UnloadModel);
fields
}
pub fn label(&self) -> &'static str {
match self {
Self::Model => "Model",
Self::Width => "Width",
Self::Height => "Height",
Self::Steps => "Steps",
Self::Guidance => "Guidance",
Self::Seed => "Seed",
Self::SeedValue => "",
Self::Batch => "Batch",
Self::Format => "Format",
Self::Mode => "Mode",
Self::Host => "Host",
Self::Scheduler => "Scheduler",
Self::Lora => "LoRA",
Self::Expand => "Expand",
Self::Offload => "Offload",
Self::SourceImage => "Source",
Self::Strength => "Strength",
Self::MaskImage => "Mask",
Self::ControlImage => "Control",
Self::ControlModel => "CNet Mdl",
Self::Frames => "Frames",
Self::Fps => "FPS",
Self::ControlScale => "Scale",
Self::ResetDefaults => "\u{21ba} Reset",
Self::UnloadModel => "\u{23cf} Unload",
}
}
pub fn section_header(&self) -> Option<&'static str> {
match self {
Self::Scheduler => Some("Advanced"),
Self::Frames => Some("Video"),
Self::SourceImage => Some("img2img"),
Self::ControlImage => Some("ControlNet"),
Self::ResetDefaults => Some("Actions"),
Self::UnloadModel => None,
_ => None,
}
}
}
fn qwen_image_edit_dimensions_for_path(path: &str) -> Option<(u32, u32)> {
const TARGET_AREA: u32 = 1024 * 1024;
const ALIGN: u32 = 16;
let bytes = std::fs::read(path).ok()?;
let img = image::load_from_memory(&bytes).ok()?;
let orig_w = img.width().max(1);
let orig_h = img.height().max(1);
Some(mold_core::fit_to_target_area(
orig_w,
orig_h,
TARGET_AREA,
ALIGN,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SeedMode {
#[default]
Random,
Fixed,
Increment,
}
impl SeedMode {
pub fn label(self) -> &'static str {
match self {
Self::Random => "random",
Self::Fixed => "fixed",
Self::Increment => "increment",
}
}
pub fn next(self) -> Self {
match self {
Self::Random => Self::Fixed,
Self::Fixed => Self::Increment,
Self::Increment => Self::Random,
}
}
pub fn resolve(self, current: Option<u64>) -> u64 {
match self {
Self::Random => rand::thread_rng().gen_range(0..u64::MAX),
Self::Fixed => current.unwrap_or_else(|| rand::thread_rng().gen_range(0..u64::MAX)),
Self::Increment => current
.map(|s| s.wrapping_add(1))
.unwrap_or_else(|| rand::thread_rng().gen_range(0..u64::MAX)),
}
}
pub fn advance(self, used_seed: u64) -> Option<u64> {
match self {
Self::Random => None,
Self::Fixed => Some(used_seed),
Self::Increment => Some(used_seed),
}
}
}
#[derive(Debug, Clone)]
pub struct GenerateParams {
pub model: String,
pub width: u32,
pub height: u32,
pub steps: u32,
pub guidance: f64,
pub seed: Option<u64>,
pub seed_mode: SeedMode,
pub batch: u32,
pub format: OutputFormat,
pub scheduler: Option<Scheduler>,
pub inference_mode: InferenceMode,
pub host: Option<String>,
pub lora_path: Option<String>,
pub lora_scale: f64,
pub expand: bool,
pub offload: bool,
pub source_image_path: Option<String>,
pub strength: f64,
pub mask_image_path: Option<String>,
pub frames: u32,
pub fps: u32,
pub control_image_path: Option<String>,
pub control_model: Option<String>,
pub control_scale: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InferenceMode {
#[default]
Auto,
Local,
Remote,
}
impl InferenceMode {
pub fn label(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Local => "local",
Self::Remote => "remote",
}
}
pub fn next(self) -> Self {
match self {
Self::Auto => Self::Local,
Self::Local => Self::Remote,
Self::Remote => Self::Auto,
}
}
}
impl GenerateParams {
pub fn from_config(config: &Config) -> Self {
let model = config.resolved_default_model();
let model_cfg = config.resolved_model_config(&model);
Self {
width: model_cfg.effective_width(config),
height: model_cfg.effective_height(config),
steps: model_cfg.effective_steps(config),
guidance: model_cfg.effective_guidance(),
model,
seed: None,
seed_mode: SeedMode::Random,
batch: 1,
format: OutputFormat::Png,
scheduler: None,
inference_mode: InferenceMode::Auto,
host: None,
lora_path: None,
lora_scale: 1.0,
expand: false,
offload: false,
source_image_path: None,
strength: 0.75,
mask_image_path: None,
frames: 25,
fps: 24,
control_image_path: None,
control_model: None,
control_scale: 1.0,
}
}
pub fn display_value(&self, field: &ParamField) -> String {
match field {
ParamField::Model => self.model.clone(),
ParamField::Width => self.width.to_string(),
ParamField::Height => self.height.to_string(),
ParamField::Steps => self.steps.to_string(),
ParamField::Guidance => format!("{:.1}", self.guidance),
ParamField::Seed => self.seed_mode.label().to_string(),
ParamField::SeedValue => self
.seed
.map(|s| s.to_string())
.unwrap_or_else(|| "\u{27e8}random\u{27e9}".to_string()),
ParamField::Batch => self.batch.to_string(),
ParamField::Format => format!("{:?}", self.format).to_uppercase(),
ParamField::Mode => self.inference_mode.label().to_string(),
ParamField::Host => self.host.as_deref().unwrap_or("localhost:7680").to_string(),
ParamField::Scheduler => self
.scheduler
.as_ref()
.map(|s| format!("{s:?}"))
.unwrap_or_else(|| "\u{2014}".to_string()),
ParamField::Lora => self
.lora_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
})
.unwrap_or_else(|| "\u{27e8}none\u{27e9}".to_string()),
ParamField::Expand => if self.expand { "on" } else { "off" }.to_string(),
ParamField::Offload => if self.offload { "on" } else { "off" }.to_string(),
ParamField::SourceImage => self
.source_image_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
})
.unwrap_or_else(|| "\u{27e8}none\u{27e9}".to_string()),
ParamField::Strength => format!("{:.2}", self.strength),
ParamField::MaskImage => self
.mask_image_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
})
.unwrap_or_else(|| "\u{27e8}none\u{27e9}".to_string()),
ParamField::ControlImage => self
.control_image_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
})
.unwrap_or_else(|| "\u{27e8}none\u{27e9}".to_string()),
ParamField::ControlModel => self
.control_model
.as_deref()
.unwrap_or("\u{27e8}none\u{27e9}")
.to_string(),
ParamField::Frames => self.frames.to_string(),
ParamField::Fps => self.fps.to_string(),
ParamField::ControlScale => format!("{:.1}", self.control_scale),
ParamField::ResetDefaults => "restore model defaults".to_string(),
ParamField::UnloadModel => "free GPU memory".to_string(),
}
}
}
pub struct GenerateState {
pub prompt: TextArea<'static>,
pub negative_prompt: TextArea<'static>,
pub params: GenerateParams,
pub focus: GenerateFocus,
pub param_index: usize,
pub visible_fields: Vec<ParamField>,
pub capabilities: ModelCapabilities,
pub progress: ProgressState,
pub preview_image: Option<image::DynamicImage>,
pub image_state: Option<StatefulProtocol>,
pub animation: Option<crate::animation::AnimationState>,
pub generating: bool,
pub batch_remaining: u32,
pub last_seed: Option<u64>,
pub last_generation_time_ms: Option<u64>,
pub error_message: Option<String>,
pub model_description: String,
pub negative_collapsed: bool,
}
impl GenerateState {
pub fn negative_visible(&self) -> bool {
self.capabilities.supports_negative_prompt && !self.negative_collapsed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GalleryViewMode {
#[default]
Grid,
Detail,
}
pub struct GalleryState {
pub entries: Vec<GalleryEntry>,
pub selected: usize,
pub preview_image: Option<image::DynamicImage>,
pub image_state: Option<StatefulProtocol>,
pub animation: Option<crate::animation::AnimationState>,
pub scanning: bool,
pub view_mode: GalleryViewMode,
pub thumbnail_states: Vec<Option<StatefulProtocol>>,
pub thumb_dimensions: Vec<Option<(u32, u32)>>,
pub thumb_fixed_cache: Vec<Option<(u16, u16, ratatui_image::protocol::Protocol)>>,
pub grid_cols: usize,
pub grid_scroll: usize,
}
#[derive(Debug, Clone)]
pub struct GalleryEntry {
pub path: std::path::PathBuf,
pub metadata: mold_core::OutputMetadata,
pub generation_time_ms: Option<u64>,
pub timestamp: u64,
pub server_url: Option<String>,
}
impl GalleryEntry {
pub fn filename(&self) -> String {
self.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".into())
}
}
pub struct ModelsState {
pub catalog: Vec<ModelInfoExtended>,
pub selected: usize,
pub filter: String,
pub filtering: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsKey {
DefaultModel,
ModelsDir,
OutputDir,
ServerPort,
DefaultWidth,
DefaultHeight,
DefaultSteps,
EmbedMetadata,
T5Variant,
Qwen3Variant,
DefaultNegativePrompt,
ExpandEnabled,
ExpandBackend,
ExpandModel,
ExpandApiModel,
ExpandTemperature,
ExpandTopP,
ExpandMaxTokens,
ExpandThinking,
LogLevel,
LogFile,
LogDir,
LogMaxDays,
ModelSelector,
ModelSteps,
ModelGuidance,
ModelWidth,
ModelHeight,
ModelScheduler,
ModelNegativePrompt,
ModelLora,
ModelLoraScale,
ModelTransformer,
ModelVae,
}
#[derive(Debug, Clone)]
pub enum SettingsFieldType {
Text,
Number { min: f64, max: f64, step: f64 },
Toggle { options: Vec<&'static str> },
Bool,
Path,
ReadOnly,
}
#[derive(Debug, Clone)]
pub enum SettingsRow {
SectionHeader {
name: String,
},
Field {
key: SettingsKey,
label: &'static str,
field_type: SettingsFieldType,
},
}
impl SettingsRow {
pub fn is_field(&self) -> bool {
matches!(self, SettingsRow::Field { .. })
}
pub fn is_read_only(&self) -> bool {
matches!(
self,
SettingsRow::Field {
field_type: SettingsFieldType::ReadOnly,
..
}
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsFocus {
Appearance,
#[default]
Configuration,
}
#[derive(Default)]
pub struct SettingsState {
pub row_index: usize,
pub scroll_offset: usize,
pub selected_model: Option<String>,
pub save_error: Option<String>,
pub theme_preset: crate::ui::theme::ThemePreset,
pub focus: SettingsFocus,
#[cfg(test)]
pub skip_save: bool,
}
pub enum Popup {
Help,
ModelSelector {
filter: String,
selected: usize,
filtered: Vec<String>,
},
HostInput {
input: String,
},
SeedInput {
input: String,
},
HistorySearch {
filter: String,
selected: usize,
results: Vec<String>,
},
Confirm {
message: String,
on_confirm: ConfirmAction,
},
SettingsInput {
key: SettingsKey,
input: String,
label: String,
},
Info {
message: String,
},
UpscaleModelSelector {
filter: String,
selected: usize,
filtered: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub enum ConfirmAction {
DeleteGalleryImage,
RemoveModel(String),
DeleteScriptStage,
}
pub struct App {
pub active_view: View,
pub generate: GenerateState,
pub gallery: GalleryState,
pub models: ModelsState,
pub settings: SettingsState,
pub script: crate::ui::script_composer::ScriptComposerState,
pub config: Config,
pub server_url: Option<String>,
pub picker: Picker,
pub theme: Theme,
pub popup: Option<Popup>,
pub should_quit: bool,
pub bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
pub bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
pub tokio_handle: tokio::runtime::Handle,
pub resource_info: crate::ui::info::ResourceInfo,
pub history: crate::history::PromptHistory,
pub layout: LayoutAreas,
pub server_process: Option<std::process::Child>,
pub upscale_in_progress: bool,
pub upscale_task: Option<tokio::task::JoinHandle<()>>,
pub upscale_tile_progress: Option<(usize, usize)>,
pub upscale_progress: ProgressState,
pub connecting: bool,
}
#[derive(Debug, Default, Clone)]
pub struct LayoutAreas {
pub tab_bar: ratatui::layout::Rect,
pub prompt: ratatui::layout::Rect,
pub negative_prompt: ratatui::layout::Rect,
pub parameters: ratatui::layout::Rect,
pub preview: ratatui::layout::Rect,
pub progress: ratatui::layout::Rect,
pub gallery_grid: ratatui::layout::Rect,
pub models_table: ratatui::layout::Rect,
}
fn check_server_health(url: &str) -> bool {
let health_url = format!("{url}/health");
let agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(2)))
.build()
.new_agent();
agent.get(&health_url).call().is_ok()
}
fn start_background_server(port: u16) -> Option<std::process::Child> {
let exe = std::env::current_exe().ok()?;
let mut cmd = std::process::Command::new(exe);
configure_background_server_command(&mut cmd, port);
cmd.spawn().ok()
}
pub(crate) fn tab_at_column(col: u16, tab_bar_x: u16) -> Option<View> {
let content_start = tab_bar_x.saturating_add(1);
if col < content_start {
return Some(View::ALL[0]);
}
let x = (col - content_start) as usize;
let mut offset = 0usize;
let last = View::ALL.len() - 1;
for (i, view) in View::ALL.iter().enumerate() {
let tab_width = view.label().len() + 6;
let zone_width = if i == last { tab_width } else { tab_width + 1 };
if x < offset + zone_width {
return Some(*view);
}
offset += zone_width;
}
None
}
pub(crate) fn configure_background_server_command(cmd: &mut std::process::Command, port: u16) {
cmd.args(["serve", "--port", &port.to_string(), "--log-file"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
fn wait_for_server_health(url: &str, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if check_server_health(url) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
false
}
impl App {
pub fn new(host: Option<String>, local: bool, picker: Picker) -> Result<Self> {
let config = Config::load_or_default();
let env_host = std::env::var("MOLD_HOST").ok();
let port = config.server_port;
let local_url = format!("http://localhost:{port}");
let mut server_process: Option<std::process::Child> = None;
let (server_url, initial_mode) = if local {
(None, InferenceMode::Local)
} else if let Some(h) = host {
let url = mold_core::client::normalize_host(&h);
if check_server_health(&url) {
(Some(url), InferenceMode::Auto)
} else {
(None, InferenceMode::Local)
}
} else if let Some(h) = env_host {
let url = mold_core::client::normalize_host(&h);
if check_server_health(&url) {
(Some(url), InferenceMode::Auto)
} else {
(None, InferenceMode::Local)
}
} else {
if check_server_health(&local_url) {
tracing::info!(%local_url, "connected to existing server");
(Some(local_url.clone()), InferenceMode::Auto)
} else {
match start_background_server(port) {
Some(mut child) => {
if wait_for_server_health(&local_url, 8) {
tracing::info!(pid = child.id(), "started background server");
server_process = Some(child);
(Some(local_url.clone()), InferenceMode::Auto)
} else {
let _ = child.kill();
let _ = child.wait();
(None, InferenceMode::Local)
}
}
None => (None, InferenceMode::Local),
}
}
};
let mut params = GenerateParams::from_config(&config);
params.inference_mode = initial_mode;
if let Some(ref url) = server_url {
params.host = Some(url.clone());
}
let family = family_for_model(¶ms.model, &config);
let mut capabilities = capabilities_for_family(&family);
let mut visible_fields = ParamField::visible_fields(&capabilities, initial_mode);
let catalog = if let Some(ref url) = server_url {
let rt = tokio::runtime::Handle::current();
let url_clone = url.clone();
std::thread::spawn(move || {
rt.block_on(async {
let client = mold_core::MoldClient::new(&url_clone);
client.list_models_extended().await.ok()
})
})
.join()
.ok()
.flatten()
.unwrap_or_else(|| mold_core::build_model_catalog(&config, None, false))
} else {
mold_core::build_model_catalog(&config, None, false)
};
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let session = crate::session::TuiSession::load();
let model_found = if !session.last_model.is_empty() {
let exact_in_catalog = catalog.iter().any(|m| m.name == session.last_model);
if config.manifest_model_is_downloaded(&session.last_model) || exact_in_catalog {
Some(session.last_model.clone())
} else {
let resolved = mold_core::manifest::resolve_model_name(&session.last_model);
let resolved_in_catalog = catalog.iter().any(|m| m.name == resolved);
if config.manifest_model_is_downloaded(&resolved) || resolved_in_catalog {
Some(resolved)
} else {
None
}
}
} else {
None
};
if let Some(model_name) = model_found {
params.model = model_name;
session.apply_to_params(&mut params);
let fam = family_for_model(¶ms.model, &config);
let caps = capabilities_for_family(&fam);
visible_fields = ParamField::visible_fields(&caps, initial_mode);
capabilities = caps;
} else {
session.apply_non_model_params(&mut params);
}
let model_description = mold_core::manifest::find_manifest(¶ms.model)
.and_then(|m| {
let mc = config.resolved_model_config(¶ms.model);
mc.description.or(Some(m.name.clone()))
})
.unwrap_or_default();
let mut prompt = TextArea::default();
prompt.set_cursor_line_style(ratatui::style::Style::default());
prompt.set_placeholder_text("Enter your prompt...");
if session.has_prompt() {
prompt = TextArea::new(session.last_prompt.lines().map(String::from).collect());
prompt.set_cursor_line_style(ratatui::style::Style::default());
}
let mut negative_prompt = TextArea::default();
negative_prompt.set_cursor_line_style(ratatui::style::Style::default());
negative_prompt.set_placeholder_text("Negative prompt (what to avoid)...");
if !session.last_negative.is_empty() {
negative_prompt =
TextArea::new(session.last_negative.lines().map(String::from).collect());
negative_prompt.set_cursor_line_style(ratatui::style::Style::default());
}
let history = crate::history::PromptHistory::load();
let initial_preset = session
.theme
.as_deref()
.map(crate::ui::theme::ThemePreset::from_slug)
.unwrap_or_default();
let app = Ok(Self {
active_view: View::Generate,
generate: GenerateState {
prompt,
negative_prompt,
params,
focus: GenerateFocus::Prompt,
param_index: 0,
visible_fields,
capabilities,
progress: ProgressState::default(),
preview_image: None,
image_state: None,
animation: None,
generating: false,
batch_remaining: 0,
last_seed: None,
last_generation_time_ms: None,
error_message: None,
model_description,
negative_collapsed: session.negative_collapsed.unwrap_or(false),
},
gallery: GalleryState {
entries: Vec::new(),
selected: 0,
preview_image: None,
image_state: None,
animation: None,
scanning: false,
view_mode: GalleryViewMode::Grid,
thumbnail_states: Vec::new(),
thumb_dimensions: Vec::new(),
thumb_fixed_cache: Vec::new(),
grid_cols: 3,
grid_scroll: 0,
},
models: ModelsState {
catalog,
selected: 0,
filter: String::new(),
filtering: false,
},
settings: {
let first_model = config.models.keys().next().cloned();
SettingsState {
selected_model: first_model,
row_index: 1, theme_preset: initial_preset,
..Default::default()
}
},
script: crate::ui::script_composer::ScriptComposerState::default(),
config,
server_url,
picker,
theme: initial_preset.build(),
popup: None,
should_quit: false,
bg_tx,
bg_rx,
tokio_handle: tokio::runtime::Handle::current(),
resource_info: crate::ui::info::ResourceInfo::default(),
history,
layout: LayoutAreas::default(),
server_process,
upscale_in_progress: false,
upscale_task: None,
upscale_tile_progress: None,
upscale_progress: ProgressState::default(),
connecting: false,
});
if let Ok(ref app) = app {
app.spawn_gallery_scan();
}
app
}
pub fn spawn_gallery_scan(&self) {
let tx = self.bg_tx.clone();
let server_url = self.server_url.clone();
self.tokio_handle.spawn(async move {
let entries = if let Some(ref url) = server_url {
crate::gallery_scan::scan_images_from_server(url).await
} else {
tokio::task::spawn_blocking(crate::gallery_scan::scan_images_local)
.await
.unwrap_or_default()
};
let _ = tx.send(BackgroundEvent::GalleryScanComplete(entries));
});
}
pub fn should_poll_remote(&self) -> bool {
self.server_url.is_some() && self.generate.params.inference_mode != InferenceMode::Local
}
pub fn should_save_output_locally(&self) -> bool {
if self.config.is_output_disabled() {
return false;
}
!self.should_poll_remote()
}
pub fn should_persist_response_locally(&self, from_local: bool) -> bool {
if self.config.is_output_disabled() {
return false;
}
if from_local {
return true;
}
!self.should_poll_remote()
}
fn sync_resource_info_mode(&mut self) {
if self.generate.params.inference_mode == InferenceMode::Local {
self.resource_info.clear_server_status();
self.resource_info.refresh_local();
} else if self.server_url.is_some() {
self.spawn_server_status_fetch();
}
}
pub fn spawn_server_status_fetch(&self) {
let Some(ref url) = self.server_url else {
return;
};
let tx = self.bg_tx.clone();
let url = url.clone();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
match client.server_status().await {
Ok(status) => {
let _ = tx.send(BackgroundEvent::ServerStatusUpdate(Some(Box::new(status))));
}
Err(_) => {
let _ = tx.send(BackgroundEvent::ServerStatusUpdate(None));
}
}
});
}
fn apply_remote_model_defaults(&mut self, catalog: &[ModelInfoExtended]) {
let model_name = &self.generate.params.model;
if let Some(entry) = catalog.iter().find(|m| &m.name == model_name) {
self.generate.params.steps = entry.defaults.default_steps;
self.generate.params.guidance = entry.defaults.default_guidance;
self.generate.params.width = entry.defaults.default_width;
self.generate.params.height = entry.defaults.default_height;
if !entry.defaults.description.is_empty() {
self.generate.model_description = entry.defaults.description.clone();
}
}
}
fn spawn_upscale(&mut self, model_name: String) {
let entry = match self.gallery.entries.get(self.gallery.selected) {
Some(e) => e.clone(),
None => return,
};
self.upscale_in_progress = true;
self.upscale_tile_progress = None;
self.upscale_progress.clear();
if self.gallery.view_mode == GalleryViewMode::Detail {
self.gallery.view_mode = GalleryViewMode::Grid;
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
}
let tx = self.bg_tx.clone();
let server_url = self.server_url.clone();
let config = self.config.clone();
let source_path = entry.path.clone();
let handle = self.tokio_handle.spawn(async move {
let image_bytes = if let Some(ref url) = entry.server_url {
let filename = entry.filename();
match crate::gallery_scan::fetch_and_cache_image(url, &filename).await {
Some(cached_path) => match tokio::fs::read(&cached_path).await {
Ok(bytes) => bytes,
Err(e) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(format!(
"Failed to read cached image: {e}"
)));
return;
}
},
None => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(
"Failed to fetch image from server".into(),
));
return;
}
}
} else {
match tokio::fs::read(&entry.path).await {
Ok(bytes) => bytes,
Err(e) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(format!(
"Failed to read image: {e}"
)));
return;
}
}
};
let req = mold_core::UpscaleRequest {
model: model_name.clone(),
image: image_bytes,
output_format: mold_core::OutputFormat::Png,
tile_size: None,
};
if let Some(ref url) = server_url {
let client = mold_core::MoldClient::new(url);
let (progress_tx, mut progress_rx) =
tokio::sync::mpsc::unbounded_channel::<mold_core::SseProgressEvent>();
let tx_sse = tx.clone();
tokio::spawn(async move {
while let Some(event) = progress_rx.recv().await {
match &event {
mold_core::SseProgressEvent::DenoiseStep { step, total, .. } => {
let _ = tx_sse.send(BackgroundEvent::UpscaleProgress {
tile: *step,
total: *total,
});
}
mold_core::SseProgressEvent::DownloadProgress { .. }
| mold_core::SseProgressEvent::DownloadDone { .. }
| mold_core::SseProgressEvent::PullComplete { .. }
| mold_core::SseProgressEvent::StageStart { .. }
| mold_core::SseProgressEvent::Info { .. } => {
let _ =
tx_sse.send(BackgroundEvent::UpscaleDownloadProgress(event));
}
_ => {}
}
}
});
match client.upscale_stream(&req, progress_tx).await {
Ok(Some(resp)) => {
let _ = tx.send(BackgroundEvent::UpscaleComplete {
image_data: resp.image.data,
source_path,
model: resp.model,
scale_factor: resp.scale_factor,
original_width: resp.original_width,
original_height: resp.original_height,
upscale_time_ms: resp.upscale_time_ms,
});
return;
}
Ok(None) => {
match client.upscale(&req).await {
Ok(resp) => {
let _ = tx.send(BackgroundEvent::UpscaleComplete {
image_data: resp.image.data,
source_path,
model: resp.model,
scale_factor: resp.scale_factor,
original_width: resp.original_width,
original_height: resp.original_height,
upscale_time_ms: resp.upscale_time_ms,
});
return;
}
Err(e) if mold_core::MoldClient::is_connection_error(&e) => {}
Err(e) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(format!(
"Server error: {e}"
)));
return;
}
}
}
Err(e) if mold_core::MoldClient::is_connection_error(&e) => {
}
Err(e) => {
let _ =
tx.send(BackgroundEvent::UpscaleFailed(format!("Server error: {e}")));
return;
}
}
}
let resolved = mold_core::manifest::resolve_model_name(&model_name);
let mut config = config;
if config
.models
.get(&resolved)
.and_then(|c| c.transformer.as_ref())
.is_none()
{
let (remap_tx, mut remap_rx) = tokio::sync::mpsc::unbounded_channel();
let tx_remap = tx.clone();
let remap_task = tokio::spawn(async move {
while let Some(event) = remap_rx.recv().await {
let remapped = match event {
BackgroundEvent::Progress(sse) => {
BackgroundEvent::UpscaleDownloadProgress(sse)
}
other => other,
};
let _ = tx_remap.send(remapped);
}
});
match crate::backend::auto_pull_model(&resolved, &remap_tx).await {
Ok(updated_config) => {
config = updated_config;
}
Err(msg) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(msg));
return;
}
}
drop(remap_tx);
let _ = remap_task.await;
}
let model_name_local = resolved;
let tx_progress = tx.clone();
let result = tokio::task::spawn_blocking(move || {
let weights_path = config
.models
.get(&model_name_local)
.and_then(|c| c.transformer.as_ref())
.map(std::path::PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!("Upscaler model '{}' not configured", model_name_local)
})?;
let mut engine = mold_inference::create_upscale_engine(
model_name_local.clone(),
weights_path,
mold_inference::LoadStrategy::Eager,
0,
)?;
engine.set_on_progress(Box::new(move |event| {
if let mold_inference::ProgressEvent::DenoiseStep { step, total, .. } = event {
let _ = tx_progress
.send(BackgroundEvent::UpscaleProgress { tile: step, total });
}
}));
engine.upscale(&req)
})
.await;
match result {
Ok(Ok(resp)) => {
let _ = tx.send(BackgroundEvent::UpscaleComplete {
image_data: resp.image.data,
source_path,
model: resp.model,
scale_factor: resp.scale_factor,
original_width: resp.original_width,
original_height: resp.original_height,
upscale_time_ms: resp.upscale_time_ms,
});
}
Ok(Err(e)) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(format!("{e}")));
}
Err(e) => {
let _ = tx.send(BackgroundEvent::UpscaleFailed(format!(
"Task panicked: {e}"
)));
}
}
});
self.upscale_task = Some(handle);
}
pub fn shutdown(&mut self) {
self.save_session();
if let Some(ref mut child) = self.server_process {
tracing::info!(pid = child.id(), "stopping background server");
let _ = child.kill();
let _ = child.wait();
}
self.server_process = None;
}
pub fn save_session(&self) {
let prompt_text = self.generate.prompt.lines().join("\n").trim().to_string();
let neg_text = self
.generate
.negative_prompt
.lines()
.join("\n")
.trim()
.to_string();
let session =
crate::session::TuiSession::from_params(&prompt_text, &neg_text, &self.generate.params)
.with_theme(self.settings.theme_preset)
.with_negative_collapsed(self.generate.negative_collapsed);
session.save();
}
pub fn apply_theme_preset(&mut self, preset: crate::ui::theme::ThemePreset) {
self.settings.theme_preset = preset;
self.theme = preset.build();
self.save_session();
}
pub fn update_model(&mut self, model_name: &str) {
let model_name = model_name.to_string();
let outgoing_model = self.generate.params.model.clone();
if outgoing_model == model_name {
return;
}
if !outgoing_model.is_empty() {
self.save_prefs_for_model(&outgoing_model);
}
self.generate.params.model = model_name.clone();
let used_remote = if self.should_poll_remote() {
if let Some(entry) = self.models.catalog.iter().find(|m| m.name == model_name) {
self.generate.params.steps = entry.defaults.default_steps;
self.generate.params.guidance = entry.defaults.default_guidance;
self.generate.params.width = entry.defaults.default_width;
self.generate.params.height = entry.defaults.default_height;
if !entry.defaults.description.is_empty() {
self.generate.model_description = entry.defaults.description.clone();
}
true
} else {
false
}
} else {
false
};
if !used_remote {
let model_cfg = self.config.resolved_model_config(&model_name);
self.generate.params.steps = model_cfg.effective_steps(&self.config);
self.generate.params.guidance = model_cfg.effective_guidance();
self.generate.params.width = model_cfg.effective_width(&self.config);
self.generate.params.height = model_cfg.effective_height(&self.config);
self.generate.model_description = mold_core::manifest::find_manifest(&model_name)
.and_then(|m| {
let mc = self.config.resolved_model_config(&model_name);
mc.description.or(Some(m.name.clone()))
})
.unwrap_or_default();
}
let family = family_for_model(&model_name, &self.config);
if family == "qwen-image-edit" {
if let Some(path) = self.generate.params.source_image_path.as_deref() {
if let Some((width, height)) = qwen_image_edit_dimensions_for_path(path) {
self.generate.params.width = width;
self.generate.params.height = height;
}
}
}
self.generate.capabilities = capabilities_for_family(&family);
self.generate.visible_fields = ParamField::visible_fields(
&self.generate.capabilities,
self.generate.params.inference_mode,
);
self.generate.param_index = 0;
self.apply_prefs_for_model(&model_name);
}
fn save_prefs_for_model(&self, model: &str) {
let db = match mold_db::open_default() {
Ok(Some(db)) => db,
_ => return,
};
let p = &self.generate.params;
let prefs = mold_db::ModelPrefs {
width: Some(p.width),
height: Some(p.height),
steps: Some(p.steps),
guidance: Some(p.guidance),
scheduler: p.scheduler.map(|s| s.to_string()),
seed_mode: Some(p.seed_mode.label().to_string()),
batch: Some(p.batch),
format: Some(format!("{:?}", p.format).to_lowercase()),
lora_path: p.lora_path.clone(),
lora_scale: Some(p.lora_scale),
expand: Some(p.expand),
offload: Some(p.offload),
strength: Some(p.strength),
control_scale: Some(p.control_scale),
frames: None,
fps: None,
last_prompt: None,
last_negative: None,
};
if let Err(e) = prefs.save(&db, model) {
tracing::warn!(error = %e, model, "save_prefs_for_model failed");
}
}
fn apply_prefs_for_model(&mut self, model: &str) {
let db = match mold_db::open_default() {
Ok(Some(db)) => db,
_ => return,
};
let Some(prefs) = mold_db::ModelPrefs::load(&db, model).ok().flatten() else {
return;
};
let p = &mut self.generate.params;
if let Some(w) = prefs.width {
p.width = w;
}
if let Some(h) = prefs.height {
p.height = h;
}
if let Some(s) = prefs.steps {
p.steps = s;
}
if let Some(g) = prefs.guidance {
p.guidance = g;
}
if let Some(ref sched) = prefs.scheduler {
p.scheduler = sched.parse().ok();
}
if let Some(ref sm) = prefs.seed_mode {
p.seed_mode = match sm.as_str() {
"fixed" => SeedMode::Fixed,
"increment" => SeedMode::Increment,
_ => SeedMode::Random,
};
}
if let Some(b) = prefs.batch {
p.batch = b;
}
if let Some(ref f) = prefs.format {
p.format = match f.as_str() {
"jpeg" => mold_core::OutputFormat::Jpeg,
_ => mold_core::OutputFormat::Png,
};
}
if prefs.lora_path.is_some() {
p.lora_path = prefs.lora_path.clone();
}
if let Some(ls) = prefs.lora_scale {
p.lora_scale = ls;
}
if let Some(e) = prefs.expand {
p.expand = e;
}
if let Some(o) = prefs.offload {
p.offload = o;
}
if let Some(s) = prefs.strength {
p.strength = s;
}
if let Some(cs) = prefs.control_scale {
p.control_scale = cs;
}
}
pub fn handle_crossterm_event(&mut self, event: CrosstermEvent) {
if let CrosstermEvent::Mouse(mouse) = event {
self.handle_mouse(mouse);
return;
}
if self.popup.is_some() {
self.handle_popup_event(event);
return;
}
if self.active_view == View::Generate {
let in_text_field = matches!(
self.generate.focus,
GenerateFocus::Prompt | GenerateFocus::NegativePrompt
);
if in_text_field {
if let CrosstermEvent::Key(key) = &event {
match (key.code, key.modifiers) {
(KeyCode::Tab, KeyModifiers::NONE)
| (KeyCode::BackTab, KeyModifiers::SHIFT)
| (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Char('g'), KeyModifiers::CONTROL) | (KeyCode::Char('m'), KeyModifiers::CONTROL) | (KeyCode::Char('r'), KeyModifiers::CONTROL) | (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Esc, KeyModifiers::NONE) => { }
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
let textarea = match self.generate.focus {
GenerateFocus::Prompt => &self.generate.prompt,
GenerateFocus::NegativePrompt => &self.generate.negative_prompt,
_ => unreachable!(),
};
if textarea.cursor().0 == 0 {
} else {
let ta = match self.generate.focus {
GenerateFocus::Prompt => &mut self.generate.prompt,
GenerateFocus::NegativePrompt => {
&mut self.generate.negative_prompt
}
_ => unreachable!(),
};
ta.input(event);
return;
}
}
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
let textarea = match self.generate.focus {
GenerateFocus::Prompt => &self.generate.prompt,
GenerateFocus::NegativePrompt => &self.generate.negative_prompt,
_ => unreachable!(),
};
let last_line = textarea.lines().len().saturating_sub(1);
if textarea.cursor().0 >= last_line {
} else {
let ta = match self.generate.focus {
GenerateFocus::Prompt => &mut self.generate.prompt,
GenerateFocus::NegativePrompt => {
&mut self.generate.negative_prompt
}
_ => unreachable!(),
};
ta.input(event);
return;
}
}
(KeyCode::Up, KeyModifiers::NONE) => {
let textarea = match self.generate.focus {
GenerateFocus::Prompt => &self.generate.prompt,
GenerateFocus::NegativePrompt => &self.generate.negative_prompt,
_ => unreachable!(),
};
if self.generate.focus == GenerateFocus::Prompt
&& textarea.cursor().0 == 0
{
let current = self.generate.prompt.lines().join("\n");
if let Some(prompt) = self.history.prev(¤t) {
self.generate.prompt = TextArea::new(
prompt.lines().map(String::from).collect(),
);
self.generate
.prompt
.set_cursor_line_style(ratatui::style::Style::default());
}
return;
}
let ta = match self.generate.focus {
GenerateFocus::Prompt => &mut self.generate.prompt,
GenerateFocus::NegativePrompt => {
&mut self.generate.negative_prompt
}
_ => unreachable!(),
};
ta.input(event);
return;
}
(KeyCode::Down, KeyModifiers::NONE) => {
let textarea = match self.generate.focus {
GenerateFocus::Prompt => &self.generate.prompt,
GenerateFocus::NegativePrompt => &self.generate.negative_prompt,
_ => unreachable!(),
};
let last_line = textarea.lines().len().saturating_sub(1);
if self.generate.focus == GenerateFocus::Prompt
&& textarea.cursor().0 >= last_line
{
let current = self.generate.prompt.lines().join("\n");
if let Some(prompt) = self.history.next(¤t) {
self.generate.prompt = TextArea::new(
prompt.lines().map(String::from).collect(),
);
self.generate
.prompt
.set_cursor_line_style(ratatui::style::Style::default());
}
return;
}
let ta = match self.generate.focus {
GenerateFocus::Prompt => &mut self.generate.prompt,
GenerateFocus::NegativePrompt => {
&mut self.generate.negative_prompt
}
_ => unreachable!(),
};
ta.input(event);
return;
}
(KeyCode::Char('1'), KeyModifiers::ALT)
| (KeyCode::Char('2'), KeyModifiers::ALT)
| (KeyCode::Char('3'), KeyModifiers::ALT)
| (KeyCode::Char('4'), KeyModifiers::ALT)
| (KeyCode::Char('5'), KeyModifiers::ALT)
| (KeyCode::Left, KeyModifiers::ALT)
| (KeyCode::Right, KeyModifiers::ALT) => {
}
(KeyCode::Char('n'), KeyModifiers::ALT)
| (KeyCode::Char('N'), KeyModifiers::ALT) => {
}
_ => {
let textarea = match self.generate.focus {
GenerateFocus::Prompt => &mut self.generate.prompt,
GenerateFocus::NegativePrompt => &mut self.generate.negative_prompt,
_ => unreachable!(),
};
textarea.input(event);
self.history.reset_cursor();
return;
}
}
}
}
}
let action = map_event(&event, self);
self.dispatch_action(action);
}
fn close_popup(&mut self) {
self.popup = None;
self.refresh_preview_protocol();
}
fn refresh_preview_protocol(&mut self) {
if let Some(ref img) = self.generate.preview_image {
self.generate.image_state = Some(self.picker.new_resize_protocol(img.clone()));
}
if let Some(ref img) = self.gallery.preview_image {
self.gallery.image_state = Some(self.picker.new_resize_protocol(img.clone()));
}
}
fn handle_popup_event(&mut self, event: CrosstermEvent) {
if let CrosstermEvent::Key(key) = event {
match &mut self.popup {
Some(Popup::Help) => {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')
) {
self.close_popup();
}
}
Some(Popup::ModelSelector {
filter,
selected,
filtered,
}) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
if let Some(model) = filtered.get(*selected).cloned() {
self.close_popup();
self.update_model(&model);
}
}
KeyCode::Up | KeyCode::Char('k') if *selected > 0 => {
*selected -= 1;
}
KeyCode::Down | KeyCode::Char('j') if *selected + 1 < filtered.len() => {
*selected += 1;
}
KeyCode::Char(c) => {
filter.push(c);
self.update_model_selector_filter();
}
KeyCode::Backspace => {
filter.pop();
self.update_model_selector_filter();
}
_ => {}
},
Some(Popup::UpscaleModelSelector {
filter,
selected,
filtered,
}) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
if let Some(model) = filtered.get(*selected).cloned() {
self.close_popup();
self.spawn_upscale(model);
}
}
KeyCode::Up | KeyCode::Char('k') if *selected > 0 => {
*selected -= 1;
}
KeyCode::Down | KeyCode::Char('j') if *selected + 1 < filtered.len() => {
*selected += 1;
}
KeyCode::Char(c) => {
filter.push(c);
self.update_upscale_model_filter();
}
KeyCode::Backspace => {
filter.pop();
self.update_upscale_model_filter();
}
_ => {}
},
Some(Popup::HostInput { input }) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
let host = input.trim().to_string();
self.close_popup();
if host.is_empty() {
self.generate.params.host = None;
self.generate.params.inference_mode = InferenceMode::Local;
self.server_url = None;
self.generate.visible_fields = ParamField::visible_fields(
&self.generate.capabilities,
self.generate.params.inference_mode,
);
self.resource_info.clear_server_status();
self.resource_info.refresh_local();
self.models.catalog =
mold_core::build_model_catalog(&self.config, None, false);
self.gallery.scanning = true;
self.spawn_gallery_scan();
} else {
let url = mold_core::client::normalize_host(&host);
self.generate.params.host = Some(url.clone());
self.connecting = true;
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Connecting to {url}..."),
style: ProgressStyle::Info,
});
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
match client.list_models_extended().await {
Ok(models) => {
let _ = tx
.send(BackgroundEvent::ServerConnected { url, models });
}
Err(e) => {
let _ = tx.send(BackgroundEvent::ServerUnreachable(
format!("{url}: {e}"),
));
}
}
});
}
}
KeyCode::Char(c) => input.push(c),
KeyCode::Backspace => {
input.pop();
}
_ => {}
},
Some(Popup::SeedInput { input }) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
let text = input.trim().to_string();
self.close_popup();
if text.is_empty() {
self.generate.params.seed = None;
} else if let Ok(val) = text.parse::<u64>() {
self.generate.params.seed = Some(val);
if self.generate.params.seed_mode == SeedMode::Random {
self.generate.params.seed_mode = SeedMode::Fixed;
}
}
self.generate.focus = GenerateFocus::Prompt;
}
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
KeyCode::Backspace => {
input.pop();
}
_ => {}
},
Some(Popup::HistorySearch {
filter,
selected,
results,
}) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
if let Some(prompt) = results.get(*selected).cloned() {
self.close_popup();
self.generate.prompt =
TextArea::new(prompt.lines().map(String::from).collect());
self.generate
.prompt
.set_cursor_line_style(ratatui::style::Style::default());
self.generate.focus = GenerateFocus::Prompt;
}
}
KeyCode::Up | KeyCode::Char('k')
if (key.modifiers == KeyModifiers::NONE || key.code == KeyCode::Up)
&& *selected > 0 =>
{
*selected -= 1;
}
KeyCode::Down | KeyCode::Char('j')
if (key.modifiers == KeyModifiers::NONE || key.code == KeyCode::Down)
&& *selected + 1 < results.len() =>
{
*selected += 1;
}
KeyCode::Char(c) => {
filter.push(c);
*results = self
.history
.search(filter)
.into_iter()
.map(|e| e.prompt.clone())
.collect();
if *selected >= results.len() {
*selected = results.len().saturating_sub(1);
}
}
KeyCode::Backspace => {
filter.pop();
*results = self
.history
.search(filter)
.into_iter()
.map(|e| e.prompt.clone())
.collect();
if *selected >= results.len() {
*selected = results.len().saturating_sub(1);
}
}
_ => {}
},
Some(Popup::Confirm { on_confirm, .. }) => match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
let action = on_confirm.clone();
self.close_popup();
self.handle_confirm_action(action);
}
_ => self.close_popup(),
},
Some(Popup::Info { .. }) => {
self.close_popup();
}
Some(Popup::SettingsInput { key: sk, input, .. }) => match key.code {
KeyCode::Esc => self.close_popup(),
KeyCode::Enter => {
let k = *sk;
let val = input.trim().to_string();
self.close_popup();
self.settings_apply_input(k, val);
}
KeyCode::Char(c) => input.push(c),
KeyCode::Backspace => {
input.pop();
}
_ => {}
},
None => {}
}
}
}
fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
use crossterm::event::{MouseButton, MouseEventKind};
let col = mouse.column;
let row = mouse.row;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.popup.is_some() {
self.close_popup();
return;
}
if self.layout.tab_bar.contains((col, row).into()) {
if let Some(view) = tab_at_column(col, self.layout.tab_bar.x) {
self.active_view = view;
return;
}
return;
}
if self.active_view == View::Generate {
let pos: ratatui::layout::Position = (col, row).into();
if self.layout.prompt.contains(pos) {
self.generate.focus = GenerateFocus::Prompt;
} else if self.layout.negative_prompt.contains(pos)
&& self.generate.negative_visible()
{
self.generate.focus = GenerateFocus::NegativePrompt;
} else if self.layout.parameters.contains(pos) {
self.generate.focus = GenerateFocus::Parameters;
let relative_row =
(row - self.layout.parameters.y).saturating_sub(1) as usize;
if relative_row < self.generate.visible_fields.len() {
self.generate.param_index = relative_row;
self.activate_current_param();
}
} else {
self.generate.focus = GenerateFocus::Navigation;
}
}
if self.active_view == View::Gallery
&& self.gallery.view_mode == GalleryViewMode::Grid
{
let pos: ratatui::layout::Position = (col, row).into();
if self.layout.gallery_grid.contains(pos) {
let cell_w = crate::ui::gallery::CELL_W;
let cell_h = crate::ui::gallery::CELL_H;
let cols = self.gallery.grid_cols.max(1);
let rel_x = col.saturating_sub(self.layout.gallery_grid.x);
let rel_y = row.saturating_sub(self.layout.gallery_grid.y);
let grid_col = (rel_x / cell_w) as usize;
let grid_row = (rel_y / cell_h) as usize + self.gallery.grid_scroll;
let idx = grid_row * cols + grid_col;
if idx < self.gallery.entries.len() {
if self.gallery.selected == idx {
self.gallery.view_mode = GalleryViewMode::Detail;
self.load_gallery_preview();
} else {
self.gallery.selected = idx;
}
}
}
}
if self.active_view == View::Models {
let pos: ratatui::layout::Position = (col, row).into();
if self.layout.models_table.contains(pos) {
let relative_row =
(row - self.layout.models_table.y).saturating_sub(2) as usize;
if relative_row < self.models.catalog.len() {
let was_selected = self.models.selected == relative_row;
self.models.selected = relative_row;
if was_selected {
let name = self.models.catalog[relative_row].name.clone();
self.update_model(&name);
self.active_view = View::Generate;
self.generate.focus = GenerateFocus::Prompt;
}
}
}
}
}
MouseEventKind::ScrollUp => {
match &mut self.popup {
Some(Popup::ModelSelector { selected, .. })
| Some(Popup::UpscaleModelSelector { selected, .. }) => {
if *selected > 0 {
*selected -= 1;
}
}
Some(Popup::HistorySearch { selected, .. }) => {
if *selected > 0 {
*selected -= 1;
}
}
_ => self.dispatch_action(Action::Up),
}
}
MouseEventKind::ScrollDown => match &mut self.popup {
Some(Popup::ModelSelector {
selected, filtered, ..
})
| Some(Popup::UpscaleModelSelector {
selected, filtered, ..
}) => {
if *selected + 1 < filtered.len() {
*selected += 1;
}
}
Some(Popup::HistorySearch {
selected, results, ..
}) => {
if *selected + 1 < results.len() {
*selected += 1;
}
}
_ => self.dispatch_action(Action::Down),
},
_ => {}
}
}
fn available_upscaler_models(&self) -> Vec<String> {
let mut models: Vec<String> = mold_core::manifest::known_manifests()
.iter()
.filter(|m| m.is_upscaler())
.map(|m| m.name.clone())
.collect();
let config = &self.config;
models.sort_by_key(|name| {
let resolved = mold_core::manifest::resolve_model_name(name);
let downloaded =
config.models.contains_key(&resolved) || config.manifest_model_is_downloaded(name);
if downloaded {
0
} else {
1
}
});
models
}
fn update_upscale_model_filter(&mut self) {
let all = self.available_upscaler_models();
if let Some(Popup::UpscaleModelSelector {
filter,
selected,
filtered,
}) = &mut self.popup
{
let query = filter.to_lowercase();
*filtered = all
.into_iter()
.filter(|name| name.to_lowercase().contains(&query))
.collect();
if *selected >= filtered.len() {
*selected = filtered.len().saturating_sub(1);
}
}
}
fn update_model_selector_filter(&mut self) {
if let Some(Popup::ModelSelector {
filter,
selected,
filtered,
}) = &mut self.popup
{
let query = filter.to_lowercase();
*filtered = self
.models
.catalog
.iter()
.filter(|m| m.is_generation_model() && m.name.to_lowercase().contains(&query))
.map(|m| m.name.clone())
.collect();
if *selected >= filtered.len() {
*selected = filtered.len().saturating_sub(1);
}
}
}
pub fn dispatch_action(&mut self, action: Action) {
match action {
Action::Quit => self.should_quit = true,
Action::SwitchView(view) => self.active_view = view,
Action::ViewNext => {
self.active_view = match self.active_view {
View::Generate => View::Gallery,
View::Gallery => View::Models,
View::Models => View::Queue,
View::Queue => View::Settings,
View::Settings => View::Script,
View::Script => View::Generate,
};
}
Action::ViewPrev => {
self.active_view = match self.active_view {
View::Generate => View::Script,
View::Gallery => View::Generate,
View::Models => View::Gallery,
View::Queue => View::Models,
View::Settings => View::Queue,
View::Script => View::Settings,
};
}
Action::FocusNext if self.active_view == View::Generate => {
self.generate.focus = self.generate.focus.next(self.generate.negative_visible());
}
Action::FocusPrev if self.active_view == View::Generate => {
self.generate.focus = self.generate.focus.prev(self.generate.negative_visible());
}
Action::Up => match self.active_view {
View::Generate => {
if self.generate.focus == GenerateFocus::Parameters
&& self.generate.param_index > 0
{
self.generate.param_index -= 1;
}
}
View::Gallery => {
let cols = self.gallery.grid_cols.max(1);
match self.gallery.view_mode {
GalleryViewMode::Grid => {
if self.gallery.selected >= cols {
self.gallery.selected -= cols;
}
}
GalleryViewMode::Detail => {
if self.gallery.selected > 0 {
self.gallery.selected -= 1;
self.load_gallery_preview();
}
}
}
}
View::Models => {
if self.models.selected > 0 {
self.models.selected -= 1;
}
}
View::Queue => {}
View::Settings => self.settings_navigate(-1),
View::Script => {}
},
Action::Down => match self.active_view {
View::Generate => {
if self.generate.focus == GenerateFocus::Parameters
&& self.generate.param_index + 1 < self.generate.visible_fields.len()
{
self.generate.param_index += 1;
}
}
View::Gallery => {
let cols = self.gallery.grid_cols.max(1);
let len = self.gallery.entries.len();
match self.gallery.view_mode {
GalleryViewMode::Grid => {
let next = self.gallery.selected + cols;
if next < len {
self.gallery.selected = next;
}
}
GalleryViewMode::Detail => {
if self.gallery.selected + 1 < len {
self.gallery.selected += 1;
self.load_gallery_preview();
}
}
}
}
View::Models => {
if self.models.selected + 1 < self.models.catalog.len() {
self.models.selected += 1;
}
}
View::Queue => {}
View::Settings => self.settings_navigate(1),
View::Script => {}
},
Action::Increment => {
if self.active_view == View::Settings {
self.settings_increment(1);
} else {
self.increment_param(1);
}
}
Action::Decrement => {
if self.active_view == View::Settings {
self.settings_increment(-1);
} else {
self.increment_param(-1);
}
}
Action::Generate if self.active_view == View::Generate && !self.generate.generating => {
self.start_generation();
}
Action::ToggleNegativePrompt => {
self.generate.negative_collapsed = !self.generate.negative_collapsed;
if self.generate.negative_collapsed
&& self.generate.focus == GenerateFocus::NegativePrompt
{
self.generate.focus = GenerateFocus::Prompt;
}
}
Action::Confirm => match self.active_view {
View::Generate => {
if self.generate.focus == GenerateFocus::Parameters {
self.activate_current_param();
} else if !self.generate.generating {
self.start_generation();
}
}
View::Gallery => match self.gallery.view_mode {
GalleryViewMode::Grid => {
if !self.gallery.entries.is_empty() {
self.gallery.view_mode = GalleryViewMode::Detail;
self.load_gallery_preview();
}
}
GalleryViewMode::Detail => {
self.open_gallery_file();
}
},
View::Models => {
if let Some(model) = self.models.catalog.get(self.models.selected) {
let name = model.name.clone();
self.update_model(&name);
self.active_view = View::Generate;
self.generate.focus = GenerateFocus::Prompt;
}
}
View::Queue => {}
View::Settings => {
if self.settings.focus == SettingsFocus::Configuration {
self.settings_confirm();
}
}
View::Script => {}
},
Action::PullModel if self.active_view == View::Models => {
if let Some(model) = self.models.catalog.get(self.models.selected) {
let model_name = model.name.clone();
let tx = self.bg_tx.clone();
if self.should_poll_remote() {
let url = self.server_url.clone().unwrap();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
let (progress_tx, mut progress_rx) =
mpsc::unbounded_channel::<SseProgressEvent>();
let tx_fwd = tx.clone();
tokio::spawn(async move {
while let Some(event) = progress_rx.recv().await {
let _ = tx_fwd.send(BackgroundEvent::Progress(event));
}
});
match client.pull_model_stream(&model_name, progress_tx).await {
Ok(()) => {
let _ = tx.send(BackgroundEvent::PullComplete(model_name));
}
Err(e) => {
let _ = tx.send(BackgroundEvent::Error(format!(
"Server pull failed: {e}"
)));
}
}
});
} else {
self.tokio_handle.spawn(async move {
if let Err(msg) =
crate::backend::auto_pull_model(&model_name, &tx).await
{
let _ = tx.send(BackgroundEvent::Error(msg));
}
});
}
}
}
Action::UnloadModel => {
if let Some(ref url) = self.server_url {
let url = url.clone();
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
match client.unload_model().await {
Ok(_) => {
let _ =
tx.send(BackgroundEvent::Progress(SseProgressEvent::Info {
message: "Model unloaded".to_string(),
}));
}
Err(e) => {
let _ =
tx.send(BackgroundEvent::Error(format!("Unload failed: {e}")));
}
}
});
}
}
Action::OpenModelSelector => {
self.open_model_selector();
}
Action::RandomizeSeed => {
self.generate.params.seed_mode = self.generate.params.seed_mode.next();
if self.generate.params.seed_mode == SeedMode::Fixed
&& self.generate.params.seed.is_none()
{
self.generate.params.seed = Some(rand::thread_rng().gen_range(0..u64::MAX));
}
}
Action::ToggleMode => {
self.generate.params.inference_mode = self.generate.params.inference_mode.next();
self.sync_resource_info_mode();
}
Action::ShowHelp => {
self.popup = Some(Popup::Help);
}
Action::Cancel => {
if self.active_view == View::Gallery && self.upscale_in_progress {
if let Some(handle) = self.upscale_task.take() {
handle.abort();
}
self.upscale_in_progress = false;
self.upscale_tile_progress = None;
self.upscale_progress.clear();
self.generate.progress.push_log(ProgressLogEntry {
message: "Upscale cancelled".into(),
style: ProgressStyle::Warning,
});
} else if self.active_view == View::Gallery
&& self.gallery.view_mode == GalleryViewMode::Detail
{
self.gallery.view_mode = GalleryViewMode::Grid;
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
} else {
self.generate.error_message = None;
}
}
Action::HistoryPrev
if self.active_view == View::Generate
&& self.generate.focus == GenerateFocus::Prompt =>
{
let current = self.generate.prompt.lines().join("\n");
if let Some(prompt) = self.history.prev(¤t) {
self.generate.prompt =
TextArea::new(prompt.lines().map(String::from).collect());
self.generate
.prompt
.set_cursor_line_style(ratatui::style::Style::default());
}
}
Action::HistoryNext
if self.active_view == View::Generate
&& self.generate.focus == GenerateFocus::Prompt =>
{
let current = self.generate.prompt.lines().join("\n");
if let Some(prompt) = self.history.next(¤t) {
self.generate.prompt =
TextArea::new(prompt.lines().map(String::from).collect());
self.generate
.prompt
.set_cursor_line_style(ratatui::style::Style::default());
}
}
Action::SearchHistory => {
let all: Vec<String> = self
.history
.search("")
.into_iter()
.map(|e| e.prompt.clone())
.collect();
self.popup = Some(Popup::HistorySearch {
filter: String::new(),
selected: 0,
results: all,
});
}
Action::Unfocus if self.active_view == View::Generate => {
self.generate.focus = GenerateFocus::Navigation;
}
Action::GridLeft
if self.active_view == View::Gallery
&& self.gallery.view_mode == GalleryViewMode::Grid
&& self.gallery.selected > 0 =>
{
self.gallery.selected -= 1;
}
Action::GridRight
if self.active_view == View::Gallery
&& self.gallery.view_mode == GalleryViewMode::Grid
&& self.gallery.selected + 1 < self.gallery.entries.len() =>
{
self.gallery.selected += 1;
}
Action::EditAndGenerate if self.active_view == View::Gallery => {
self.load_gallery_into_generate();
}
Action::Regenerate if self.active_view == View::Gallery => {
self.load_gallery_into_generate();
if !self.generate.generating {
self.start_generation();
}
}
Action::DeleteImage if self.active_view == View::Gallery => {
if let Some(entry) = self.gallery.entries.get(self.gallery.selected) {
let filename = entry.filename();
self.popup = Some(Popup::Confirm {
message: format!("Delete {filename}?"),
on_confirm: ConfirmAction::DeleteGalleryImage,
});
}
}
Action::OpenFile => {
self.open_gallery_file();
}
Action::UpscaleImage
if self.active_view == View::Gallery
&& !self.upscale_in_progress
&& self.gallery.entries.get(self.gallery.selected).is_some() =>
{
let models = self.available_upscaler_models();
self.popup = Some(Popup::UpscaleModelSelector {
filter: String::new(),
selected: 0,
filtered: models,
});
}
Action::RemoveModel if self.active_view == View::Models => {
if let Some(model) = self.models.catalog.get(self.models.selected) {
if !model.downloaded {
return;
}
let name = model.info.name.clone();
if self.generate.generating && self.generate.params.model == name {
self.generate.error_message =
Some("Cannot remove a model while generating".to_string());
return;
}
if mold_core::download::has_pulling_marker(&name) {
self.generate.error_message =
Some("Cannot remove a model while it is being pulled".to_string());
return;
}
let message = self.build_remove_model_message(&name);
self.popup = Some(Popup::Confirm {
message,
on_confirm: ConfirmAction::RemoveModel(name),
});
}
}
Action::ScriptMoveDown => self.script.move_down(),
Action::ScriptMoveUp => self.script.move_up(),
Action::ScriptReorderDown => self.script.reorder_down(),
Action::ScriptReorderUp => self.script.reorder_up(),
Action::ScriptAddAfter => self.script.add_stage_after(),
Action::ScriptAddBefore => self.script.add_stage_before(),
Action::ScriptDelete if self.script.script.stages.len() > 1 => {
self.popup = Some(Popup::Confirm {
message: format!("Delete stage {}?", self.script.selected + 1,),
on_confirm: ConfirmAction::DeleteScriptStage,
});
}
Action::ScriptCycleTransition => self.script.cycle_transition(),
Action::ScriptSave => self.script.open_save_dialog(),
Action::ScriptLoad => self.script.open_load_dialog(),
Action::ScriptSubmit if !self.generate.generating => {
let req = self.script.build_chain_request();
self.generate.generating = true;
self.generate.error_message = None;
self.generate.progress.clear();
self.generate.progress.mark_generation_start();
self.generate.preview_image = None;
self.generate.image_state = None;
self.generate.animation = None;
let tx = self.bg_tx.clone();
let server_url = self.server_url.clone();
self.tokio_handle.spawn(async move {
crate::backend::run_chain_generation(server_url, req, tx).await;
});
}
Action::ScriptOpenPromptEditor => self.script.open_prompt_editor(),
Action::ScriptOpenFramesEditor => self.script.open_frames_editor(),
Action::ScriptModalSubmit => {
use crate::ui::script_composer::ScriptModal;
match self.script.modal {
ScriptModal::PromptEdit { .. } => self.script.commit_prompt(),
ScriptModal::FramesEdit { .. } => self.script.commit_frames(),
ScriptModal::SavePath { .. } => self.script.save_to_path(),
ScriptModal::LoadPath { .. } => self.script.load_from_path(),
ScriptModal::Closed => {}
}
}
Action::ScriptModalCancel => self.script.cancel_modal(),
Action::ScriptModalChar(c) => {
use crate::ui::script_composer::ScriptModal;
match &mut self.script.modal {
ScriptModal::PromptEdit { buffer } => buffer.push(c),
ScriptModal::FramesEdit { buffer, error }
| ScriptModal::SavePath { buffer, error }
| ScriptModal::LoadPath { buffer, error } => {
buffer.push(c);
*error = None;
}
ScriptModal::Closed => {}
}
}
Action::ScriptModalBackspace => {
use crate::ui::script_composer::ScriptModal;
match &mut self.script.modal {
ScriptModal::PromptEdit { buffer } => {
buffer.pop();
}
ScriptModal::FramesEdit { buffer, error }
| ScriptModal::SavePath { buffer, error }
| ScriptModal::LoadPath { buffer, error } => {
buffer.pop();
*error = None;
}
ScriptModal::Closed => {}
}
}
Action::ScriptModalNewline => {
use crate::ui::script_composer::ScriptModal;
if let ScriptModal::PromptEdit { buffer } = &mut self.script.modal {
buffer.push('\n');
}
}
_ => {}
}
}
fn increment_param(&mut self, delta: i32) {
if self.active_view != View::Generate || self.generate.focus != GenerateFocus::Parameters {
return;
}
let field = match self.generate.visible_fields.get(self.generate.param_index) {
Some(f) => *f,
None => return,
};
let p = &mut self.generate.params;
match field {
ParamField::Width => {
p.width = (p.width as i32 + delta * 64).clamp(256, 4096) as u32;
}
ParamField::Height => {
p.height = (p.height as i32 + delta * 64).clamp(256, 4096) as u32;
}
ParamField::Steps => {
p.steps = (p.steps as i32 + delta).clamp(1, 200) as u32;
}
ParamField::Guidance => {
p.guidance = (p.guidance + delta as f64 * 0.5).clamp(0.0, 30.0);
}
ParamField::Batch => {
p.batch = (p.batch as i32 + delta).max(1) as u32;
}
ParamField::Strength => {
p.strength = (p.strength + delta as f64 * 0.05).clamp(0.0, 1.0);
}
ParamField::Frames => {
p.frames = (p.frames as i32 + delta * 8).clamp(9, 257) as u32;
}
ParamField::Fps => {
p.fps = (p.fps as i32 + delta).clamp(1, 60) as u32;
}
ParamField::ControlScale => {
p.control_scale = (p.control_scale + delta as f64 * 0.1).clamp(0.0, 2.0);
}
ParamField::Format => {
p.format = match p.format {
OutputFormat::Png => OutputFormat::Jpeg,
OutputFormat::Jpeg => OutputFormat::Gif,
OutputFormat::Gif => OutputFormat::Apng,
OutputFormat::Apng => OutputFormat::Webp,
OutputFormat::Webp => OutputFormat::Mp4,
OutputFormat::Mp4 => OutputFormat::Png,
};
}
ParamField::Mode => {
p.inference_mode = p.inference_mode.next();
}
ParamField::Expand => {
p.expand = !p.expand;
}
ParamField::Offload => {
p.offload = !p.offload;
}
_ => {}
}
if field == ParamField::Mode {
self.generate.visible_fields = ParamField::visible_fields(
&self.generate.capabilities,
self.generate.params.inference_mode,
);
self.sync_resource_info_mode();
}
}
fn load_gallery_preview(&mut self) {
if let Some(entry) = self.gallery.entries.get(self.gallery.selected) {
if let Some(ref server_url) = entry.server_url {
let url = server_url.clone();
let filename = entry.filename();
let is_video = crate::gallery_scan::is_video_filename(&filename);
if is_video {
let preview_cache = crate::gallery_scan::preview_cache_path(&filename);
if preview_cache.is_file() && self.try_install_gallery_animation(&preview_cache)
{
return;
}
let tx = self.bg_tx.clone();
let fetch_url = url.clone();
let fetch_name = filename.clone();
self.tokio_handle.spawn(async move {
if let Some(data) =
crate::gallery_scan::fetch_and_cache_preview(&fetch_url, &fetch_name)
.await
{
let _ = tx.send(BackgroundEvent::GalleryPreviewReady(data));
return;
}
let client = mold_core::MoldClient::new(&fetch_url);
if let Ok(thumb) = client.get_gallery_thumbnail(&fetch_name).await {
let _ = tx.send(BackgroundEvent::GalleryPreviewReady(thumb));
}
});
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
return;
}
let cache_path = crate::gallery_scan::image_cache_dir().join(&filename);
if cache_path.is_file() {
if self.try_install_gallery_animation(&cache_path) {
return;
}
if let Ok(img) = image::open(&cache_path) {
let protocol = self.picker.new_resize_protocol(img.clone());
self.gallery.preview_image = Some(img);
self.gallery.image_state = Some(protocol);
self.gallery.animation = None;
return;
}
}
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
if let Some(cached) =
crate::gallery_scan::fetch_and_cache_image(&url, &filename).await
{
let data = tokio::fs::read(&cached).await.unwrap_or_default();
let _ = tx.send(BackgroundEvent::GalleryPreviewReady(data));
}
});
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
} else if entry.path.exists() && entry.path.is_file() {
let gif_path = crate::thumbnails::preview_gif_path(&entry.path);
let load_path = if gif_path.is_file() {
gif_path.clone()
} else {
entry.path.clone()
};
if self.try_install_gallery_animation(&load_path) {
return;
}
if let Ok(img) = image::open(&load_path) {
let protocol = self.picker.new_resize_protocol(img.clone());
self.gallery.preview_image = Some(img);
self.gallery.image_state = Some(protocol);
self.gallery.animation = None;
return;
}
}
}
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
}
fn try_install_gallery_animation(&mut self, path: &std::path::Path) -> bool {
let frames = match crate::animation::decode_animation_path(path) {
Ok(f) => f,
Err(_) => return false,
};
let state = match crate::animation::AnimationState::new(frames) {
Some(s) => s,
None => return false,
};
let first = state.current_image().clone();
let protocol = self.picker.new_resize_protocol(first.clone());
self.gallery.preview_image = Some(first);
self.gallery.image_state = Some(protocol);
self.gallery.animation = Some(state);
true
}
pub fn tick_animations(&mut self) {
if let Some(anim) = self.gallery.animation.as_mut() {
if anim.tick() {
let img = anim.current_image().clone();
self.gallery.preview_image = Some(img.clone());
self.gallery.image_state = Some(self.picker.new_resize_protocol(img));
}
}
if let Some(anim) = self.generate.animation.as_mut() {
if anim.tick() {
let img = anim.current_image().clone();
self.generate.preview_image = Some(img.clone());
self.generate.image_state = Some(self.picker.new_resize_protocol(img));
}
}
}
fn load_gallery_into_generate(&mut self) {
let entry = match self.gallery.entries.get(self.gallery.selected) {
Some(e) => e.clone(),
None => return,
};
let meta = &entry.metadata;
self.generate.prompt = tui_textarea::TextArea::from(meta.prompt.lines());
if let Some(ref neg) = meta.negative_prompt {
self.generate.negative_prompt = tui_textarea::TextArea::from(neg.lines());
} else {
self.generate.negative_prompt = tui_textarea::TextArea::default();
}
self.update_model(&meta.model);
self.generate.params.seed = Some(meta.seed);
self.generate.params.seed_mode = SeedMode::Fixed;
self.generate.params.steps = meta.steps;
self.generate.params.guidance = meta.guidance;
self.generate.params.width = meta.width;
self.generate.params.height = meta.height;
if let Some(strength) = meta.strength {
self.generate.params.strength = strength;
}
self.generate.params.scheduler = meta.scheduler;
if let Some(ref lora) = meta.lora {
self.generate.params.lora_path = Some(lora.clone());
self.generate.params.lora_scale = meta.lora_scale.unwrap_or(1.0);
} else {
self.generate.params.lora_path = None;
}
self.active_view = View::Generate;
self.generate.focus = GenerateFocus::Prompt;
}
pub fn apply_gallery_scan(&mut self, entries: Vec<GalleryEntry>) {
let previous_selected = self.gallery.selected;
let previous_filename = self
.gallery
.entries
.get(previous_selected)
.map(|e| e.filename());
self.gallery.thumbnail_states = vec![None; entries.len()];
self.gallery.thumb_dimensions = vec![None; entries.len()];
self.gallery.thumb_fixed_cache = vec![None; entries.len()];
self.gallery.entries = entries;
self.gallery.scanning = false;
self.gallery.selected = if self.gallery.entries.is_empty() {
0
} else if let Some(idx) = previous_filename.as_deref().and_then(|name| {
self.gallery
.entries
.iter()
.position(|e| e.filename() == name)
}) {
idx
} else {
previous_selected.min(self.gallery.entries.len() - 1)
};
}
pub fn apply_delete_failure(&mut self, err: &str) {
self.generate.error_message = Some(format!("Delete failed: {err}"));
if self.server_url.is_some() {
self.gallery.scanning = true;
self.spawn_gallery_scan();
}
}
fn delete_selected_gallery_image(&mut self) {
if self.gallery.entries.is_empty() {
return;
}
let idx = self.gallery.selected;
if idx >= self.gallery.entries.len() {
return;
}
let entry = &self.gallery.entries[idx];
let thumb_path = crate::thumbnails::thumbnail_path(&entry.path);
let _ = std::fs::remove_file(&thumb_path);
let cache_path = crate::gallery_scan::image_cache_dir().join(entry.filename());
let _ = std::fs::remove_file(&cache_path);
if let Some(ref url) = entry.server_url {
let url = url.clone();
let filename = entry.filename();
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
if let Err(e) = client.delete_gallery_image(&filename).await {
let _ = tx.send(BackgroundEvent::GalleryDeleteFailed(e.to_string()));
}
});
}
if entry.path.is_file() {
let _ = std::fs::remove_file(&entry.path);
}
self.gallery.entries.remove(idx);
if idx < self.gallery.thumbnail_states.len() {
self.gallery.thumbnail_states.remove(idx);
}
if idx < self.gallery.thumb_dimensions.len() {
self.gallery.thumb_dimensions.remove(idx);
}
if idx < self.gallery.thumb_fixed_cache.len() {
self.gallery.thumb_fixed_cache.remove(idx);
}
self.gallery.preview_image = None;
self.gallery.image_state = None;
self.gallery.animation = None;
if !self.gallery.entries.is_empty() {
self.gallery.selected = idx.min(self.gallery.entries.len() - 1);
if self.gallery.view_mode == GalleryViewMode::Detail {
self.load_gallery_preview();
}
} else {
self.gallery.selected = 0;
self.gallery.view_mode = GalleryViewMode::Grid;
}
}
fn open_gallery_file(&mut self) {
let entry = match self.gallery.entries.get(self.gallery.selected) {
Some(e) => e.clone(),
None => return,
};
if entry.server_url.is_none() && entry.path.is_file() {
let _ = open::that(&entry.path);
} else if let Some(ref url) = entry.server_url {
let url = url.clone();
let filename = entry.filename();
self.tokio_handle.spawn(async move {
if let Some(cached) =
crate::gallery_scan::fetch_and_cache_image(&url, &filename).await
{
let _ = open::that(&cached);
}
});
}
}
fn build_remove_model_message(&self, model_name: &str) -> String {
let mut lines = vec![format!("Remove model '{model_name}'?")];
let ref_counts = crate::backend::build_ref_counts(&self.config);
let mut unique_bytes: u64 = 0;
let mut shared_warnings: Vec<String> = Vec::new();
if let Some(model_config) = self.config.models.get(model_name) {
for path in model_config.all_file_paths() {
let refs = ref_counts.get(&path).cloned().unwrap_or_default();
let others: Vec<String> = refs
.into_iter()
.filter(|n| n.as_str() != model_name)
.collect();
if others.is_empty() {
unique_bytes += std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
} else {
let filename = std::path::Path::new(&path)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
shared_warnings.push(format!(
" {} (shared with {})",
filename,
others.join(", ")
));
}
}
}
if unique_bytes > 0 {
lines.push(format!(
"Disk space to free: ~{}",
crate::ui::progress::format_bytes(unique_bytes)
));
}
if !shared_warnings.is_empty() {
lines.push(String::new());
lines.push("Shared files (kept):".to_string());
lines.extend(shared_warnings);
}
lines.join("\n")
}
fn handle_confirm_action(&mut self, action: ConfirmAction) {
match action {
ConfirmAction::DeleteGalleryImage => {
self.delete_selected_gallery_image();
}
ConfirmAction::RemoveModel(name) => {
let tx = self.bg_tx.clone();
let model_name = name.clone();
self.tokio_handle.spawn_blocking(move || {
crate::backend::remove_model(model_name, tx);
});
}
ConfirmAction::DeleteScriptStage => {
self.script.delete_stage();
}
}
}
fn open_model_selector(&mut self) {
let mut models: Vec<String> = self
.models
.catalog
.iter()
.filter(|m| m.is_generation_model())
.map(|m| m.name.clone())
.collect();
let config = &self.config;
models.sort_by_key(|name| {
let resolved = mold_core::manifest::resolve_model_name(name);
let downloaded =
config.models.contains_key(&resolved) || config.manifest_model_is_downloaded(name);
if downloaded {
0
} else {
1
}
});
self.popup = Some(Popup::ModelSelector {
filter: String::new(),
selected: 0,
filtered: models,
});
}
fn activate_current_param(&mut self) {
let field = match self.generate.visible_fields.get(self.generate.param_index) {
Some(f) => *f,
None => return,
};
match field {
ParamField::Model => self.open_model_selector(),
ParamField::Expand => self.generate.params.expand = !self.generate.params.expand,
ParamField::Offload => self.generate.params.offload = !self.generate.params.offload,
ParamField::Mode => {
self.generate.params.inference_mode = self.generate.params.inference_mode.next();
self.generate.visible_fields = ParamField::visible_fields(
&self.generate.capabilities,
self.generate.params.inference_mode,
);
self.sync_resource_info_mode();
}
ParamField::Format => {
self.generate.params.format = match self.generate.params.format {
OutputFormat::Png => OutputFormat::Jpeg,
OutputFormat::Jpeg => OutputFormat::Gif,
OutputFormat::Gif => OutputFormat::Apng,
OutputFormat::Apng => OutputFormat::Webp,
OutputFormat::Webp => OutputFormat::Mp4,
OutputFormat::Mp4 => OutputFormat::Png,
};
}
ParamField::Scheduler => {
self.generate.params.scheduler = match self.generate.params.scheduler {
None => Some(Scheduler::Ddim),
Some(Scheduler::Ddim) => Some(Scheduler::EulerAncestral),
Some(Scheduler::EulerAncestral) => Some(Scheduler::UniPc),
Some(Scheduler::UniPc) => None,
};
}
ParamField::Seed => {
self.generate.params.seed_mode = self.generate.params.seed_mode.next();
if self.generate.params.seed_mode == SeedMode::Fixed
&& self.generate.params.seed.is_none()
{
self.generate.params.seed = Some(rand::thread_rng().gen_range(0..u64::MAX));
}
}
ParamField::SeedValue => {
let current = self
.generate
.params
.seed
.map(|s| s.to_string())
.unwrap_or_default();
self.popup = Some(Popup::SeedInput { input: current });
}
ParamField::Host => {
let current = self.generate.params.host.clone().unwrap_or_default();
self.popup = Some(Popup::HostInput { input: current });
}
ParamField::UnloadModel => {
self.dispatch_action(Action::UnloadModel);
}
ParamField::ResetDefaults => {
let model = self.generate.params.model.clone();
if let Some(entry) = if self.should_poll_remote() {
self.models.catalog.iter().find(|m| m.name == model)
} else {
None
} {
self.generate.params.width = entry.defaults.default_width;
self.generate.params.height = entry.defaults.default_height;
self.generate.params.steps = entry.defaults.default_steps;
self.generate.params.guidance = entry.defaults.default_guidance;
} else {
let mc = self.config.resolved_model_config(&model);
self.generate.params.width = mc.effective_width(&self.config);
self.generate.params.height = mc.effective_height(&self.config);
self.generate.params.steps = mc.effective_steps(&self.config);
self.generate.params.guidance = mc.effective_guidance();
}
self.generate.params.seed = None;
self.generate.params.seed_mode = SeedMode::Random;
self.generate.params.batch = 1;
self.generate.params.format = OutputFormat::Png;
self.generate.params.scheduler = None;
self.generate.params.lora_path = None;
self.generate.params.lora_scale = 1.0;
self.generate.params.expand = false;
self.generate.params.offload = false;
self.generate.params.frames = 25;
self.generate.params.fps = 24;
self.generate.params.strength = 0.75;
self.generate.params.source_image_path = None;
self.generate.params.mask_image_path = None;
self.generate.params.control_image_path = None;
self.generate.params.control_model = None;
self.generate.params.control_scale = 1.0;
}
_ => {}
}
}
#[allow(clippy::vec_init_then_push)]
pub fn build_settings_rows(&self) -> Vec<SettingsRow> {
let mut rows = Vec::new();
rows.push(SettingsRow::SectionHeader {
name: "General".into(),
});
rows.push(SettingsRow::Field {
key: SettingsKey::DefaultModel,
label: "Model",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelsDir,
label: "Models Dir",
field_type: SettingsFieldType::Path,
});
rows.push(SettingsRow::Field {
key: SettingsKey::OutputDir,
label: "Output Dir",
field_type: SettingsFieldType::Path,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ServerPort,
label: "Port",
field_type: SettingsFieldType::Number {
min: 1.0,
max: 65535.0,
step: 1.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::DefaultWidth,
label: "Width",
field_type: SettingsFieldType::Number {
min: 64.0,
max: 8192.0,
step: 64.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::DefaultHeight,
label: "Height",
field_type: SettingsFieldType::Number {
min: 64.0,
max: 8192.0,
step: 64.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::DefaultSteps,
label: "Steps",
field_type: SettingsFieldType::Number {
min: 1.0,
max: 1000.0,
step: 1.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::EmbedMetadata,
label: "Metadata",
field_type: SettingsFieldType::Bool,
});
rows.push(SettingsRow::Field {
key: SettingsKey::T5Variant,
label: "T5 Variant",
field_type: SettingsFieldType::Toggle {
options: vec!["auto", "fp16", "q8", "q6", "q5", "q4", "q3"],
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::Qwen3Variant,
label: "Qwen3 Var.",
field_type: SettingsFieldType::Toggle {
options: vec!["auto", "bf16", "q8", "q6", "iq4", "q3"],
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::DefaultNegativePrompt,
label: "Neg. Prompt",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::SectionHeader {
name: "Expand".into(),
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandEnabled,
label: "Enabled",
field_type: SettingsFieldType::Bool,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandBackend,
label: "Backend",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandModel,
label: "Model",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandApiModel,
label: "API Model",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandTemperature,
label: "Temp.",
field_type: SettingsFieldType::Number {
min: 0.0,
max: 2.0,
step: 0.1,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandTopP,
label: "Top P",
field_type: SettingsFieldType::Number {
min: 0.0,
max: 1.0,
step: 0.05,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandMaxTokens,
label: "Max Tokens",
field_type: SettingsFieldType::Number {
min: 1.0,
max: 65535.0,
step: 64.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ExpandThinking,
label: "Thinking",
field_type: SettingsFieldType::Bool,
});
rows.push(SettingsRow::SectionHeader {
name: "Logging".into(),
});
rows.push(SettingsRow::Field {
key: SettingsKey::LogLevel,
label: "Level",
field_type: SettingsFieldType::Toggle {
options: vec!["trace", "debug", "info", "warn", "error"],
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::LogFile,
label: "File Log",
field_type: SettingsFieldType::Bool,
});
rows.push(SettingsRow::Field {
key: SettingsKey::LogDir,
label: "Log Dir",
field_type: SettingsFieldType::Path,
});
rows.push(SettingsRow::Field {
key: SettingsKey::LogMaxDays,
label: "Max Days",
field_type: SettingsFieldType::Number {
min: 1.0,
max: 3650.0,
step: 1.0,
},
});
if !self.config.models.is_empty() {
let model_name = self.settings.selected_model.clone().unwrap_or_else(|| {
self.config
.models
.keys()
.next()
.cloned()
.unwrap_or_default()
});
rows.push(SettingsRow::SectionHeader {
name: format!("Model Defaults \u{2500}\u{2500} {model_name} "),
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelSelector,
label: "Model",
field_type: SettingsFieldType::Toggle {
options: Vec::new(), },
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelSteps,
label: "Steps",
field_type: SettingsFieldType::Number {
min: 1.0,
max: 1000.0,
step: 1.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelGuidance,
label: "Guidance",
field_type: SettingsFieldType::Number {
min: 0.0,
max: 100.0,
step: 0.5,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelWidth,
label: "Width",
field_type: SettingsFieldType::Number {
min: 64.0,
max: 8192.0,
step: 64.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelHeight,
label: "Height",
field_type: SettingsFieldType::Number {
min: 64.0,
max: 8192.0,
step: 64.0,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelScheduler,
label: "Scheduler",
field_type: SettingsFieldType::Toggle {
options: vec!["(none)", "ddim", "euler-ancestral", "uni-pc"],
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelNegativePrompt,
label: "Neg. Prompt",
field_type: SettingsFieldType::Text,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelLora,
label: "LoRA",
field_type: SettingsFieldType::Path,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelLoraScale,
label: "LoRA Scale",
field_type: SettingsFieldType::Number {
min: 0.0,
max: 2.0,
step: 0.1,
},
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelTransformer,
label: "Transformer",
field_type: SettingsFieldType::ReadOnly,
});
rows.push(SettingsRow::Field {
key: SettingsKey::ModelVae,
label: "VAE",
field_type: SettingsFieldType::ReadOnly,
});
}
rows
}
pub fn settings_display_value(&self, key: &SettingsKey) -> String {
let cfg = &self.config;
let resolved_model = self
.settings
.selected_model
.as_ref()
.map(|name| cfg.resolved_model_config(name));
let model_cfg = self
.settings
.selected_model
.as_ref()
.and_then(|name| cfg.models.get(name));
match key {
SettingsKey::DefaultModel => cfg.default_model.clone(),
SettingsKey::ModelsDir => cfg.models_dir.clone(),
SettingsKey::OutputDir => cfg
.output_dir
.as_deref()
.unwrap_or("~/.mold/output")
.to_string(),
SettingsKey::ServerPort => cfg.server_port.to_string(),
SettingsKey::DefaultWidth => cfg.default_width.to_string(),
SettingsKey::DefaultHeight => cfg.default_height.to_string(),
SettingsKey::DefaultSteps => cfg.default_steps.to_string(),
SettingsKey::EmbedMetadata => if cfg.embed_metadata { "on" } else { "off" }.into(),
SettingsKey::T5Variant => cfg.t5_variant.as_deref().unwrap_or("auto").to_string(),
SettingsKey::Qwen3Variant => cfg.qwen3_variant.as_deref().unwrap_or("auto").to_string(),
SettingsKey::DefaultNegativePrompt => cfg
.default_negative_prompt
.as_deref()
.unwrap_or("(none)")
.to_string(),
SettingsKey::ExpandEnabled => if cfg.expand.enabled { "on" } else { "off" }.into(),
SettingsKey::ExpandBackend => cfg.expand.backend.clone(),
SettingsKey::ExpandModel => cfg.expand.model.clone(),
SettingsKey::ExpandApiModel => cfg.expand.api_model.clone(),
SettingsKey::ExpandTemperature => format!("{:.1}", cfg.expand.temperature),
SettingsKey::ExpandTopP => format!("{:.2}", cfg.expand.top_p),
SettingsKey::ExpandMaxTokens => cfg.expand.max_tokens.to_string(),
SettingsKey::ExpandThinking => if cfg.expand.thinking { "on" } else { "off" }.into(),
SettingsKey::LogLevel => cfg.logging.level.clone(),
SettingsKey::LogFile => if cfg.logging.file { "on" } else { "off" }.into(),
SettingsKey::LogDir => cfg
.logging
.dir
.as_deref()
.unwrap_or("~/.mold/logs")
.to_string(),
SettingsKey::LogMaxDays => cfg.logging.max_days.to_string(),
SettingsKey::ModelSelector => self
.settings
.selected_model
.as_deref()
.unwrap_or("(none)")
.to_string(),
SettingsKey::ModelSteps => resolved_model
.as_ref()
.and_then(|m| m.default_steps)
.map(|v| v.to_string())
.unwrap_or_else(|| cfg.default_steps.to_string()),
SettingsKey::ModelGuidance => resolved_model
.as_ref()
.and_then(|m| m.default_guidance)
.map(|v| format!("{v:.1}"))
.unwrap_or_else(|| "0.0".into()),
SettingsKey::ModelWidth => resolved_model
.as_ref()
.and_then(|m| m.default_width)
.map(|v| v.to_string())
.unwrap_or_else(|| cfg.default_width.to_string()),
SettingsKey::ModelHeight => resolved_model
.as_ref()
.and_then(|m| m.default_height)
.map(|v| v.to_string())
.unwrap_or_else(|| cfg.default_height.to_string()),
SettingsKey::ModelScheduler => resolved_model
.as_ref()
.and_then(|m| m.scheduler)
.map(|s| s.to_string())
.unwrap_or_else(|| "(none)".into()),
SettingsKey::ModelNegativePrompt => model_cfg
.and_then(|m| m.negative_prompt.as_deref())
.unwrap_or("(none)")
.to_string(),
SettingsKey::ModelLora => model_cfg
.and_then(|m| m.lora.as_deref())
.unwrap_or("(none)")
.to_string(),
SettingsKey::ModelLoraScale => model_cfg
.and_then(|m| m.lora_scale)
.map(|v| format!("{v:.1}"))
.unwrap_or_else(|| "1.0".into()),
SettingsKey::ModelTransformer => model_cfg
.and_then(|m| m.transformer.as_deref())
.unwrap_or("(not set)")
.to_string(),
SettingsKey::ModelVae => model_cfg
.and_then(|m| m.vae.as_deref())
.unwrap_or("(not set)")
.to_string(),
}
}
pub fn settings_env_override(key: &SettingsKey) -> Option<&'static str> {
let var = match key {
SettingsKey::DefaultModel => "MOLD_DEFAULT_MODEL",
SettingsKey::ModelsDir => "MOLD_MODELS_DIR",
SettingsKey::OutputDir => "MOLD_OUTPUT_DIR",
SettingsKey::EmbedMetadata => "MOLD_EMBED_METADATA",
SettingsKey::T5Variant => "MOLD_T5_VARIANT",
SettingsKey::Qwen3Variant => "MOLD_QWEN3_VARIANT",
SettingsKey::ExpandEnabled => "MOLD_EXPAND",
SettingsKey::ExpandBackend => "MOLD_EXPAND_BACKEND",
SettingsKey::ExpandModel | SettingsKey::ExpandApiModel => "MOLD_EXPAND_MODEL",
SettingsKey::ExpandTemperature => "MOLD_EXPAND_TEMPERATURE",
SettingsKey::ExpandThinking => "MOLD_EXPAND_THINKING",
_ => return None,
};
if std::env::var(var).is_ok() {
Some(var)
} else {
None
}
}
fn settings_navigate(&mut self, delta: i32) {
if self.settings.focus == SettingsFocus::Appearance {
if delta > 0 {
self.settings.focus = SettingsFocus::Configuration;
}
return;
}
let rows = self.build_settings_rows();
if rows.is_empty() {
return;
}
let len = rows.len();
let mut next = self.settings.row_index;
loop {
let candidate = next as i32 + delta;
if candidate < 0 || candidate >= len as i32 {
if delta < 0 {
self.settings.focus = SettingsFocus::Appearance;
}
break;
}
next = candidate as usize;
if rows[next].is_field() {
self.settings.row_index = next;
break;
}
}
}
fn settings_cycle_theme(&mut self, delta: i32) {
use crate::ui::theme::ThemePreset;
let current = self.settings.theme_preset;
let len = ThemePreset::ALL.len() as i32;
let current_idx = ThemePreset::ALL
.iter()
.position(|p| *p == current)
.unwrap_or(0) as i32;
let next_idx = ((current_idx + delta).rem_euclid(len)) as usize;
self.apply_theme_preset(ThemePreset::ALL[next_idx]);
}
fn settings_increment(&mut self, delta: i32) {
if self.settings.focus == SettingsFocus::Appearance {
self.settings_cycle_theme(delta);
return;
}
let rows = self.build_settings_rows();
let row = match rows.get(self.settings.row_index) {
Some(r) => r,
None => return,
};
let (key, field_type) = match row {
SettingsRow::Field {
key, field_type, ..
} => (*key, field_type.clone()),
_ => return,
};
if key == SettingsKey::ModelSelector {
self.settings_cycle_model(delta);
return;
}
match field_type {
SettingsFieldType::Number { min, max, step } => {
self.settings_adjust_number(key, delta as f64 * step, min, max);
}
SettingsFieldType::Toggle { options } if !options.is_empty() => {
self.settings_cycle_toggle(key, &options, delta);
}
SettingsFieldType::Bool => {
self.settings_toggle_bool(key);
}
_ => {}
}
}
fn settings_adjust_number(&mut self, key: SettingsKey, delta: f64, min: f64, max: f64) {
let cfg = &mut self.config;
match key {
SettingsKey::ServerPort => {
cfg.server_port = (cfg.server_port as f64 + delta).clamp(min, max) as u16;
}
SettingsKey::DefaultWidth => {
cfg.default_width = (cfg.default_width as f64 + delta).clamp(min, max) as u32;
}
SettingsKey::DefaultHeight => {
cfg.default_height = (cfg.default_height as f64 + delta).clamp(min, max) as u32;
}
SettingsKey::DefaultSteps => {
cfg.default_steps = (cfg.default_steps as f64 + delta).clamp(min, max) as u32;
}
SettingsKey::ExpandTemperature => {
cfg.expand.temperature = (cfg.expand.temperature + delta).clamp(min, max);
}
SettingsKey::ExpandTopP => {
cfg.expand.top_p = (cfg.expand.top_p + delta).clamp(min, max);
}
SettingsKey::ExpandMaxTokens => {
cfg.expand.max_tokens =
(cfg.expand.max_tokens as f64 + delta).clamp(min, max) as u32;
}
SettingsKey::LogMaxDays => {
cfg.logging.max_days = (cfg.logging.max_days as f64 + delta).clamp(min, max) as u32;
}
SettingsKey::ModelSteps => {
if let Some(name) = &self.settings.selected_model {
let resolved = self.config.resolved_model_config(name);
let cur = resolved.effective_steps(&self.config) as f64;
if let Some(mc) = self.config.models.get_mut(name) {
mc.default_steps = Some((cur + delta).clamp(min, max) as u32);
}
}
self.save_config();
return;
}
SettingsKey::ModelGuidance => {
if let Some(name) = &self.settings.selected_model {
let resolved = self.config.resolved_model_config(name);
let cur = resolved.effective_guidance();
if let Some(mc) = self.config.models.get_mut(name) {
mc.default_guidance = Some((cur + delta).clamp(min, max));
}
}
self.save_config();
return;
}
SettingsKey::ModelWidth => {
if let Some(name) = &self.settings.selected_model {
let resolved = self.config.resolved_model_config(name);
let cur = resolved.effective_width(&self.config) as f64;
if let Some(mc) = self.config.models.get_mut(name) {
mc.default_width = Some((cur + delta).clamp(min, max) as u32);
}
}
self.save_config();
return;
}
SettingsKey::ModelHeight => {
if let Some(name) = &self.settings.selected_model {
let resolved = self.config.resolved_model_config(name);
let cur = resolved.effective_height(&self.config) as f64;
if let Some(mc) = self.config.models.get_mut(name) {
mc.default_height = Some((cur + delta).clamp(min, max) as u32);
}
}
self.save_config();
return;
}
SettingsKey::ModelLoraScale => {
if let Some(name) = &self.settings.selected_model {
if let Some(mc) = self.config.models.get_mut(name) {
let cur = mc.lora_scale.unwrap_or(1.0);
mc.lora_scale = Some((cur + delta).clamp(min, max));
}
}
self.save_config();
return;
}
_ => return,
}
self.save_config();
}
fn settings_cycle_toggle(&mut self, key: SettingsKey, options: &[&str], delta: i32) {
let current = self.settings_display_value(&key);
let idx = options.iter().position(|&o| o == current).unwrap_or(0);
let next = (idx as i32 + delta).rem_euclid(options.len() as i32) as usize;
let value = options[next].to_string();
match key {
SettingsKey::T5Variant => {
self.config.t5_variant = if value == "auto" { None } else { Some(value) };
}
SettingsKey::Qwen3Variant => {
self.config.qwen3_variant = if value == "auto" { None } else { Some(value) };
}
SettingsKey::LogLevel => {
self.config.logging.level = value;
}
SettingsKey::ModelScheduler => {
if let Some(name) = &self.settings.selected_model {
if let Some(mc) = self.config.models.get_mut(name) {
mc.scheduler = match options[next] {
"ddim" => Some(Scheduler::Ddim),
"euler-ancestral" => Some(Scheduler::EulerAncestral),
"uni-pc" => Some(Scheduler::UniPc),
_ => None,
};
}
}
}
_ => return,
}
self.save_config();
}
fn settings_toggle_bool(&mut self, key: SettingsKey) {
match key {
SettingsKey::EmbedMetadata => self.config.embed_metadata = !self.config.embed_metadata,
SettingsKey::ExpandEnabled => self.config.expand.enabled = !self.config.expand.enabled,
SettingsKey::ExpandThinking => {
self.config.expand.thinking = !self.config.expand.thinking;
}
SettingsKey::LogFile => self.config.logging.file = !self.config.logging.file,
_ => return,
}
self.save_config();
}
fn settings_cycle_model(&mut self, delta: i32) {
let names: Vec<String> = self.config.models.keys().cloned().collect();
if names.is_empty() {
return;
}
let idx = self
.settings
.selected_model
.as_ref()
.and_then(|current| names.iter().position(|n| n == current))
.unwrap_or(0);
let next = (idx as i32 + delta).rem_euclid(names.len() as i32) as usize;
self.settings.selected_model = Some(names[next].clone());
}
fn settings_confirm(&mut self) {
let rows = self.build_settings_rows();
let row = match rows.get(self.settings.row_index) {
Some(r) => r,
None => return,
};
let (key, field_type) = match row {
SettingsRow::Field {
key, field_type, ..
} => (*key, field_type.clone()),
_ => return,
};
match field_type {
SettingsFieldType::Text | SettingsFieldType::Path => {
let label = match row {
SettingsRow::Field { label, .. } => *label,
_ => "",
};
let current = self.settings_display_value(&key);
let input = if current == "(none)" || current == "(not set)" {
String::new()
} else {
current
};
self.popup = Some(Popup::SettingsInput {
key,
input,
label: label.to_string(),
});
}
SettingsFieldType::Bool => {
self.settings_toggle_bool(key);
}
SettingsFieldType::Toggle { ref options } => {
if key == SettingsKey::ModelSelector {
self.settings_cycle_model(1);
} else if !options.is_empty() {
self.settings_cycle_toggle(key, options, 1);
}
}
SettingsFieldType::Number { .. } => {
}
SettingsFieldType::ReadOnly => {}
}
}
fn settings_apply_input(&mut self, key: SettingsKey, value: String) {
let val = if value.is_empty() { None } else { Some(value) };
match key {
SettingsKey::DefaultModel => {
if let Some(v) = val {
self.config.default_model = v;
}
}
SettingsKey::ModelsDir => {
if let Some(v) = val {
self.config.models_dir = v;
}
}
SettingsKey::OutputDir => {
self.config.output_dir = val;
}
SettingsKey::DefaultNegativePrompt => {
self.config.default_negative_prompt = val;
}
SettingsKey::ExpandBackend => {
if let Some(v) = val {
self.config.expand.backend = v;
}
}
SettingsKey::ExpandModel => {
if let Some(v) = val {
self.config.expand.model = v;
}
}
SettingsKey::ExpandApiModel => {
if let Some(v) = val {
self.config.expand.api_model = v;
}
}
SettingsKey::LogDir => {
self.config.logging.dir = val;
}
SettingsKey::ModelNegativePrompt => {
if let Some(name) = &self.settings.selected_model {
if let Some(mc) = self.config.models.get_mut(name) {
mc.negative_prompt = val;
}
}
}
SettingsKey::ModelLora => {
if let Some(name) = &self.settings.selected_model {
if let Some(mc) = self.config.models.get_mut(name) {
mc.lora = val;
}
}
}
_ => return,
}
self.save_config();
}
fn save_config(&mut self) {
#[cfg(test)]
if self.settings.skip_save {
return;
}
if let Err(e) = self.config.save() {
self.settings.save_error = Some(format!("Save failed: {e}"));
} else {
self.settings.save_error = None;
}
}
fn start_generation(&mut self) {
let prompt_text = self.generate.prompt.lines().join("\n").trim().to_string();
if prompt_text.is_empty() {
self.generate.error_message = Some("Prompt is empty".to_string());
return;
}
self.generate.generating = true;
self.generate.batch_remaining = self.generate.params.batch;
self.generate.error_message = None;
self.generate.progress.clear();
self.generate.progress.mark_generation_start();
self.generate.preview_image = None;
self.generate.image_state = None;
self.generate.animation = None;
let neg = self
.generate
.negative_prompt
.lines()
.join("\n")
.trim()
.to_string();
let negative_prompt = if neg.is_empty() { None } else { Some(neg) };
let resolved_seed = self
.generate
.params
.seed_mode
.resolve(self.generate.params.seed);
let mut params = self.generate.params.clone();
params.seed = Some(resolved_seed);
let tx = self.bg_tx.clone();
let server_url = self.server_url.clone();
self.tokio_handle.spawn(async move {
crate::backend::run_generation(server_url, params, prompt_text, negative_prompt, tx)
.await;
});
}
pub fn process_background_events(&mut self) {
while let Ok(event) = self.bg_rx.try_recv() {
match event {
BackgroundEvent::Progress(sse) => self.handle_progress(sse),
BackgroundEvent::GenerationComplete {
response,
from_local,
} => {
self.generate.batch_remaining = self.generate.batch_remaining.saturating_sub(1);
if self.generate.batch_remaining == 0 {
self.generate.generating = false;
self.generate.progress.generation_started_at = None;
self.generate.progress.stage_started_at = None;
}
self.generate.last_seed = Some(response.seed_used);
self.generate.last_generation_time_ms = Some(response.generation_time_ms);
let actual_model = response.model.clone();
self.generate.params.seed =
self.generate.params.seed_mode.advance(response.seed_used);
let output_dir = if self.should_persist_response_locally(from_local) {
let dir = self.config.effective_output_dir();
let _ = std::fs::create_dir_all(&dir);
Some(dir)
} else {
None
};
let mut saved_path = std::path::PathBuf::new();
let ts_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let prompt_text = self.generate.prompt.lines().join("\n").trim().to_string();
let neg_text = self
.generate
.negative_prompt
.lines()
.join("\n")
.trim()
.to_string();
for (i, img_data) in response.images.iter().enumerate() {
let ext = img_data.format.extension();
let filename = mold_core::default_output_filename(
&actual_model,
ts_secs,
ext,
response.images.len() as u32,
i as u32,
);
if let Some(ref dir) = output_dir {
let path = dir.join(&filename);
if std::fs::write(&path, &img_data.data).is_ok() && i == 0 {
saved_path = path;
}
}
if i == 0 {
if let Ok(img) = image::load_from_memory(&img_data.data) {
let protocol = self.picker.new_resize_protocol(img.clone());
self.generate.preview_image = Some(img);
self.generate.image_state = Some(protocol);
self.generate.animation = None;
}
}
}
if let Some(ref video) = response.video {
let ext = video.format.extension();
let filename =
mold_core::default_output_filename(&actual_model, ts_secs, ext, 1, 0);
if let Some(ref dir) = output_dir {
let path = dir.join(&filename);
if std::fs::write(&path, &video.data).is_ok() {
saved_path = path.clone();
if !video.gif_preview.is_empty() {
crate::thumbnails::save_preview_gif(&video.gif_preview, &path)
.ok();
}
if !video.thumbnail.is_empty() {
crate::thumbnails::save_thumbnail_bytes(
&video.thumbnail,
&path,
)
.ok();
}
}
}
if !video.gif_preview.is_empty() {
if let Ok(frames) = crate::animation::decode_animation_bytes(
&video.gif_preview,
Some("gif"),
) {
if let Some(state) = crate::animation::AnimationState::new(frames) {
let first = state.current_image().clone();
let protocol = self.picker.new_resize_protocol(first.clone());
self.generate.preview_image = Some(first);
self.generate.image_state = Some(protocol);
self.generate.animation = Some(state);
} else if let Ok(img) = image::load_from_memory(&video.gif_preview)
{
let protocol = self.picker.new_resize_protocol(img.clone());
self.generate.preview_image = Some(img);
self.generate.image_state = Some(protocol);
self.generate.animation = None;
}
} else if let Ok(img) = image::load_from_memory(&video.gif_preview) {
let protocol = self.picker.new_resize_protocol(img.clone());
self.generate.preview_image = Some(img);
self.generate.image_state = Some(protocol);
self.generate.animation = None;
}
}
}
let saved_name = saved_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
self.generate.progress.push_log(ProgressLogEntry {
message: if saved_name.is_empty() {
format!(
"Done in {:.1}s (seed: {})",
response.generation_time_ms as f64 / 1000.0,
response.seed_used
)
} else {
format!(
"Saved {} ({:.1}s)",
saved_name,
response.generation_time_ms as f64 / 1000.0,
)
},
style: ProgressStyle::Done,
});
self.save_session();
mold_db::settings::record_last_model(&actual_model);
let neg = if neg_text.is_empty() {
None
} else {
Some(neg_text.clone())
};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.history.push(crate::history::HistoryEntry {
prompt: prompt_text.clone(),
negative: neg,
model: actual_model.clone(),
timestamp: ts,
});
let (entry_width, entry_height) = if let Some(img) = response.images.first() {
(img.width, img.height)
} else if let Some(ref video) = response.video {
(video.width, video.height)
} else {
(self.generate.params.width, self.generate.params.height)
};
if saved_path.as_os_str().is_empty() && self.should_poll_remote() && !from_local
{
self.gallery.scanning = true;
self.spawn_gallery_scan();
}
if (!response.images.is_empty() || response.video.is_some())
&& !saved_path.as_os_str().is_empty()
{
let meta = mold_core::OutputMetadata {
prompt: prompt_text,
negative_prompt: if neg_text.is_empty() {
None
} else {
Some(neg_text)
},
original_prompt: None,
model: actual_model,
seed: response.seed_used,
steps: self.generate.params.steps,
guidance: self.generate.params.guidance,
width: entry_width,
height: entry_height,
strength: if self.generate.params.source_image_path.is_some() {
Some(self.generate.params.strength)
} else {
None
},
scheduler: self.generate.params.scheduler,
output_format: Some(self.generate.params.format),
cfg_plus: None,
lora: self.generate.params.lora_path.clone(),
lora_scale: self
.generate
.params
.lora_path
.as_ref()
.map(|_| self.generate.params.lora_scale),
loras: self.generate.params.lora_path.as_ref().map(|path| {
vec![mold_core::LoraWeight {
path: path.clone(),
scale: self.generate.params.lora_scale,
}]
}),
control_model: self.generate.params.control_model.clone(),
control_scale: self
.generate
.params
.control_image_path
.as_ref()
.and(self.generate.params.control_model.as_ref())
.map(|_| self.generate.params.control_scale),
upscale_model: None,
gif_preview: response.video.as_ref().map(|_| true),
enable_audio: None,
audio_file_path: None,
source_video_path: None,
pipeline: None,
retake_range: None,
spatial_upscale: None,
temporal_upscale: None,
version: mold_core::build_info::VERSION.to_string(),
frames: response.video.as_ref().map(|v| v.frames),
fps: response.video.as_ref().map(|v| v.fps),
};
self.gallery.entries.insert(
0,
GalleryEntry {
path: saved_path.clone(),
metadata: meta,
generation_time_ms: Some(response.generation_time_ms),
timestamp: ts,
server_url: None,
},
);
self.gallery.thumbnail_states.insert(0, None);
self.gallery.thumb_dimensions.insert(0, None);
self.gallery.thumb_fixed_cache.insert(0, None);
self.tokio_handle.spawn(async move {
tokio::task::spawn_blocking(move || {
crate::thumbnails::generate_thumbnail(&saved_path).ok();
})
.await
.ok();
});
}
}
BackgroundEvent::Error(msg) => {
self.generate.generating = false;
self.generate.batch_remaining = 0;
self.generate.error_message = Some(msg);
self.generate.progress.generation_started_at = None;
self.generate.progress.stage_started_at = None;
}
BackgroundEvent::GalleryScanComplete(entries) => {
self.apply_gallery_scan(entries);
let entries_info: Vec<(std::path::PathBuf, Option<String>)> = self
.gallery
.entries
.iter()
.map(|e| (e.path.clone(), e.server_url.clone()))
.collect();
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
let mut handles = Vec::new();
for (path, server_url) in entries_info {
if crate::thumbnails::thumbnail_exists(&path) {
continue;
}
let handle = tokio::spawn(async move {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(url) = server_url {
let client = mold_core::MoldClient::new(&url);
let fetched = if let Ok(data) =
client.get_gallery_thumbnail(&filename).await
{
let key = path.clone();
tokio::task::spawn_blocking(move || {
crate::thumbnails::save_thumbnail_bytes(&data, &key)
.ok();
})
.await
.ok();
true
} else {
false
};
if !fetched {
let cache_path =
crate::gallery_scan::image_cache_dir().join(&filename);
if cache_path.is_file() {
let key = path;
tokio::task::spawn_blocking(move || {
crate::thumbnails::generate_thumbnail_from_cached(
&cache_path,
&key,
)
.ok();
})
.await
.ok();
}
}
} else {
tokio::task::spawn_blocking(move || {
crate::thumbnails::generate_thumbnail(&path).ok();
})
.await
.ok();
}
});
handles.push(handle);
}
for h in handles {
let _ = h.await;
}
let _ = tx.send(BackgroundEvent::ThumbnailsReady);
});
}
BackgroundEvent::GalleryPreviewReady(data) => {
let mut installed_animation = false;
if crate::animation::is_animated_bytes(&data) {
if let Ok(frames) = crate::animation::decode_animation_bytes(&data, None) {
if let Some(state) = crate::animation::AnimationState::new(frames) {
let first = state.current_image().clone();
let protocol = self.picker.new_resize_protocol(first.clone());
self.gallery.preview_image = Some(first);
self.gallery.image_state = Some(protocol);
self.gallery.animation = Some(state);
installed_animation = true;
}
}
}
if !installed_animation {
if let Ok(img) = image::load_from_memory(&data) {
let protocol = self.picker.new_resize_protocol(img.clone());
self.gallery.preview_image = Some(img);
self.gallery.image_state = Some(protocol);
self.gallery.animation = None;
}
}
}
BackgroundEvent::ThumbnailsReady => {
let len = self.gallery.entries.len();
self.gallery.thumbnail_states = vec![None; len];
self.gallery.thumb_dimensions = vec![None; len];
self.gallery.thumb_fixed_cache = vec![None; len];
}
BackgroundEvent::ServerConnected { url, models } => {
self.connecting = false;
self.server_url = Some(url.clone());
self.models.catalog = models.clone();
self.models.selected = 0;
if self.generate.params.inference_mode == InferenceMode::Local {
self.generate.params.inference_mode = InferenceMode::Auto;
}
self.generate.visible_fields = ParamField::visible_fields(
&self.generate.capabilities,
self.generate.params.inference_mode,
);
self.apply_remote_model_defaults(&models);
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Connected to {url}"),
style: ProgressStyle::Done,
});
self.gallery.scanning = true;
self.spawn_gallery_scan();
self.spawn_server_status_fetch();
}
BackgroundEvent::ServerUnreachable(msg) => {
self.connecting = false;
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Server unreachable: {msg}"),
style: ProgressStyle::Error,
});
self.generate.params.host = self.server_url.clone();
self.resource_info.clear_server_status();
self.resource_info.refresh_local();
}
BackgroundEvent::PullComplete(model) => {
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Pull complete: {model}"),
style: ProgressStyle::Done,
});
if self.should_poll_remote() {
let url = self.server_url.clone().unwrap();
let tx = self.bg_tx.clone();
self.tokio_handle.spawn(async move {
let client = mold_core::MoldClient::new(&url);
if let Ok(models) = client.list_models_extended().await {
let _ = tx.send(BackgroundEvent::CatalogRefreshed(models));
}
});
} else {
self.config = Config::load_or_default();
self.models.catalog =
mold_core::build_model_catalog(&self.config, None, false);
}
}
BackgroundEvent::ModelRemoveComplete(model) => {
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Removed model: {model}"),
style: ProgressStyle::Done,
});
self.config = Config::load_or_default();
self.models.catalog = mold_core::build_model_catalog(&self.config, None, false);
if !self.models.catalog.is_empty()
&& self.models.selected >= self.models.catalog.len()
{
self.models.selected = self.models.catalog.len() - 1;
}
}
BackgroundEvent::ModelRemoveFailed(msg) => {
self.generate.progress.push_log(ProgressLogEntry {
message: format!("Remove failed: {msg}"),
style: ProgressStyle::Error,
});
}
BackgroundEvent::UpscaleDownloadProgress(event) => {
reduce_progress_state(&mut self.upscale_progress, event);
}
BackgroundEvent::UpscaleProgress { tile, total } => {
self.upscale_tile_progress = Some((tile, total));
}
BackgroundEvent::UpscaleComplete {
image_data,
source_path,
model,
scale_factor,
original_width,
original_height,
upscale_time_ms,
} => {
self.upscale_in_progress = false;
self.upscale_task = None;
self.upscale_tile_progress = None;
self.upscale_progress.clear();
let upscaled_w = original_width * scale_factor;
let upscaled_h = original_height * scale_factor;
let output_dir = if self.config.is_output_disabled() {
None
} else {
let dir = self.config.effective_output_dir();
let _ = std::fs::create_dir_all(&dir);
Some(dir)
};
let stem = source_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let filename = format!("{stem}_upscaled_{scale_factor}x.png");
let saved_path = if let Some(ref dir) = output_dir {
let path = dir.join(&filename);
if let Err(e) = std::fs::write(&path, &image_data) {
self.generate.error_message =
Some(format!("Failed to save upscaled image: {e}"));
return;
}
path
} else {
self.generate.progress.push_log(ProgressLogEntry {
message: format!(
"Upscaled {original_width}x{original_height} -> {upscaled_w}x{upscaled_h} ({scale_factor}x, {:.1}s) — output dir disabled",
upscale_time_ms as f64 / 1000.0
),
style: ProgressStyle::Warning,
});
return;
};
self.generate.progress.push_log(ProgressLogEntry {
message: format!(
"Upscaled {original_width}x{original_height} -> {upscaled_w}x{upscaled_h} ({scale_factor}x, {:.1}s)",
upscale_time_ms as f64 / 1000.0
),
style: ProgressStyle::Done,
});
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let source_meta = self
.gallery
.entries
.iter()
.find(|e| e.path == source_path)
.map(|e| e.metadata.clone());
let meta = mold_core::OutputMetadata {
prompt: source_meta
.as_ref()
.map(|m| m.prompt.clone())
.unwrap_or_default(),
negative_prompt: source_meta
.as_ref()
.and_then(|m| m.negative_prompt.clone()),
original_prompt: source_meta
.as_ref()
.and_then(|m| m.original_prompt.clone()),
model,
seed: source_meta.as_ref().map(|m| m.seed).unwrap_or(0),
steps: source_meta.as_ref().map(|m| m.steps).unwrap_or(0),
guidance: source_meta.as_ref().map(|m| m.guidance).unwrap_or(0.0),
width: upscaled_w,
height: upscaled_h,
strength: None,
scheduler: source_meta.as_ref().and_then(|m| m.scheduler),
output_format: Some(mold_core::OutputFormat::Png),
cfg_plus: source_meta.as_ref().and_then(|m| m.cfg_plus),
lora: source_meta.as_ref().and_then(|m| m.lora.clone()),
lora_scale: source_meta.as_ref().and_then(|m| m.lora_scale),
loras: source_meta.as_ref().and_then(|m| m.loras.clone()),
control_model: source_meta.as_ref().and_then(|m| m.control_model.clone()),
control_scale: source_meta.as_ref().and_then(|m| m.control_scale),
upscale_model: source_meta.as_ref().and_then(|m| m.upscale_model.clone()),
gif_preview: None,
enable_audio: source_meta.as_ref().and_then(|m| m.enable_audio),
audio_file_path: source_meta
.as_ref()
.and_then(|m| m.audio_file_path.clone()),
source_video_path: source_meta
.as_ref()
.and_then(|m| m.source_video_path.clone()),
pipeline: source_meta.as_ref().and_then(|m| m.pipeline),
retake_range: source_meta.as_ref().and_then(|m| m.retake_range.clone()),
spatial_upscale: source_meta.as_ref().and_then(|m| m.spatial_upscale),
temporal_upscale: source_meta.as_ref().and_then(|m| m.temporal_upscale),
frames: None,
fps: None,
version: mold_core::build_info::VERSION.to_string(),
};
self.gallery.entries.insert(
0,
GalleryEntry {
path: saved_path.clone(),
metadata: meta,
generation_time_ms: Some(upscale_time_ms),
timestamp: ts,
server_url: None,
},
);
self.gallery.thumbnail_states.insert(0, None);
self.gallery.thumb_dimensions.insert(0, None);
self.gallery.thumb_fixed_cache.insert(0, None);
self.gallery.selected = 0;
self.tokio_handle.spawn(async move {
tokio::task::spawn_blocking(move || {
crate::thumbnails::generate_thumbnail(&saved_path).ok();
})
.await
.ok();
});
}
BackgroundEvent::UpscaleFailed(msg) => {
self.upscale_in_progress = false;
self.upscale_task = None;
self.upscale_tile_progress = None;
self.upscale_progress.clear();
self.generate.error_message = Some(format!("Upscale failed: {msg}"));
}
BackgroundEvent::ServerStatusUpdate(Some(status)) => {
self.resource_info.update_from_server_status(*status);
}
BackgroundEvent::ServerStatusUpdate(None) => {
self.resource_info.clear_server_status();
}
BackgroundEvent::CatalogRefreshed(models) => {
self.models.catalog = models;
if !self.models.catalog.is_empty()
&& self.models.selected >= self.models.catalog.len()
{
self.models.selected = self.models.catalog.len() - 1;
}
}
BackgroundEvent::ChainProgress(event) => {
use mold_core::ChainProgressEvent;
let msg = match &event {
ChainProgressEvent::ChainStart {
stage_count,
estimated_total_frames,
} => {
format!("Chain: {stage_count} stages, ~{estimated_total_frames} frames")
}
ChainProgressEvent::StageStart { stage_idx } => {
format!(
"Stage {}/{} started",
stage_idx + 1,
self.script.script.stages.len()
)
}
ChainProgressEvent::DenoiseStep {
stage_idx,
step,
total,
} => {
format!("Stage {} step {}/{}", stage_idx + 1, step, total)
}
ChainProgressEvent::StageDone {
stage_idx,
frames_emitted,
} => {
format!("Stage {} done ({} frames)", stage_idx + 1, frames_emitted)
}
ChainProgressEvent::Stitching { total_frames } => {
format!("Stitching {total_frames} frames...")
}
};
self.generate.progress.push_log(ProgressLogEntry {
message: msg,
style: ProgressStyle::Info,
});
}
BackgroundEvent::ChainComplete { response } => {
self.generate.generating = false;
self.generate.progress.generation_started_at = None;
self.generate.progress.stage_started_at = None;
self.generate.progress.push_log(ProgressLogEntry {
message: format!(
"Chain complete: {} stages, GPU {}",
response.stage_count,
response
.gpu
.map(|g| g.to_string())
.unwrap_or_else(|| "unknown".into()),
),
style: ProgressStyle::Done,
});
}
BackgroundEvent::ChainError(msg) => {
self.generate.generating = false;
self.generate.progress.generation_started_at = None;
self.generate.progress.stage_started_at = None;
self.generate.error_message = Some(msg);
}
BackgroundEvent::GalleryDeleteFailed(msg) => {
self.apply_delete_failure(&msg);
}
}
}
}
fn handle_progress(&mut self, event: SseProgressEvent) {
let refresh_catalog = reduce_progress_state(&mut self.generate.progress, event);
if refresh_catalog {
self.config = Config::load_or_default();
self.models.catalog = mold_core::build_model_catalog(&self.config, None, false);
}
}
}
fn reduce_progress_state(progress: &mut ProgressState, event: SseProgressEvent) -> bool {
match event {
SseProgressEvent::StageStart { name } => {
progress.current_stage = Some(name);
progress.stage_index = progress.stage_index.saturating_add(1);
progress.stage_started_at = Some(std::time::Instant::now());
progress.clear_download();
progress.clear_weight();
}
SseProgressEvent::StageDone { name, elapsed_ms } => {
progress.current_stage = None;
progress.stage_started_at = None;
progress.push_log(ProgressLogEntry {
message: format!("{name} [{:.1}s]", elapsed_ms as f64 / 1000.0),
style: ProgressStyle::Done,
});
}
SseProgressEvent::Info { message } => {
if message.contains("pulling") || message.contains("Checking") {
progress.downloading = true;
progress.current_stage = Some(message);
} else if message.contains("Verifying") {
progress.downloading = true;
progress.current_stage = Some(message.clone());
progress.push_log(ProgressLogEntry {
message,
style: ProgressStyle::Info,
});
} else {
progress.push_log(ProgressLogEntry {
message,
style: ProgressStyle::Info,
});
}
}
SseProgressEvent::CacheHit { resource } => {
progress.push_log(ProgressLogEntry {
message: format!("{resource} [cache hit]"),
style: ProgressStyle::Done,
});
}
SseProgressEvent::DenoiseStep {
step,
total,
elapsed_ms,
} => {
progress.denoise_step = step;
progress.denoise_total = total;
progress.denoise_elapsed_ms = elapsed_ms;
}
SseProgressEvent::WeightLoad {
bytes_loaded,
bytes_total,
component,
} => {
progress.weight_loaded = bytes_loaded;
progress.weight_total = bytes_total;
progress.weight_component = component;
}
SseProgressEvent::DownloadProgress {
filename,
bytes_downloaded,
bytes_total,
batch_bytes_downloaded,
batch_bytes_total,
batch_elapsed_ms,
file_index,
total_files,
} => {
progress.downloading = true;
if progress.current_stage.is_some() {
progress.current_stage = None;
}
progress.download_filename = filename;
progress.download_bytes = bytes_downloaded;
progress.download_total = bytes_total;
progress.download_batch_bytes = batch_bytes_downloaded;
progress.download_batch_total = batch_bytes_total;
progress.download_batch_elapsed_ms = batch_elapsed_ms;
progress.record_download_sample(batch_elapsed_ms, batch_bytes_downloaded);
progress.download_file_index = file_index;
if total_files > 0 {
progress.download_total_files = total_files;
}
}
SseProgressEvent::DownloadDone {
filename,
file_index,
total_files,
batch_bytes_downloaded,
batch_bytes_total,
batch_elapsed_ms,
} => {
progress.push_log(ProgressLogEntry {
message: format!("[{}/{}] {filename}", file_index + 1, total_files),
style: ProgressStyle::Done,
});
if file_index + 1 < total_files {
progress.download_filename.clear();
progress.download_bytes = 0;
progress.download_total = 0;
progress.download_batch_bytes = batch_bytes_downloaded;
progress.download_batch_total = batch_bytes_total;
progress.download_batch_elapsed_ms = batch_elapsed_ms;
progress.download_file_index = file_index + 1;
progress.current_stage = Some(format!(
"Preparing file [{}/{}]...",
file_index + 2,
total_files
));
} else {
progress.clear_download();
}
}
SseProgressEvent::PullComplete { model } => {
progress.clear_download();
progress.push_log(ProgressLogEntry {
message: format!("Pull complete: {model}"),
style: ProgressStyle::Done,
});
return true;
}
SseProgressEvent::Queued { position, .. } => {
progress.current_stage = Some(format!("Queued (position {position})"));
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inference_mode_cycle() {
assert_eq!(InferenceMode::Auto.next(), InferenceMode::Local);
assert_eq!(InferenceMode::Local.next(), InferenceMode::Remote);
assert_eq!(InferenceMode::Remote.next(), InferenceMode::Auto);
}
#[test]
fn inference_mode_labels() {
assert_eq!(InferenceMode::Auto.label(), "auto");
assert_eq!(InferenceMode::Local.label(), "local");
assert_eq!(InferenceMode::Remote.label(), "remote");
}
#[test]
fn visible_fields_hides_host_in_local_mode() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Local);
assert!(!fields.contains(&ParamField::Host));
}
#[test]
fn visible_fields_shows_host_in_auto_mode() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert!(fields.contains(&ParamField::Host));
}
#[test]
fn visible_fields_shows_host_in_remote_mode() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Remote);
assert!(fields.contains(&ParamField::Host));
}
#[test]
fn visible_fields_includes_scheduler_for_sd15() {
let caps = crate::model_info::capabilities_for_family("sd15");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert!(fields.contains(&ParamField::Scheduler));
}
#[test]
fn visible_fields_excludes_scheduler_for_flux() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert!(!fields.contains(&ParamField::Scheduler));
}
#[test]
fn visible_fields_includes_lora_for_flux() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert!(fields.contains(&ParamField::Lora));
}
#[test]
fn visible_fields_excludes_lora_for_unsupported_family() {
let caps = crate::model_info::capabilities_for_family("wuerstchen");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert!(!fields.contains(&ParamField::Lora));
}
#[test]
fn generate_params_display_host_default() {
let config = Config::load_or_default();
let params = GenerateParams::from_config(&config);
assert_eq!(params.display_value(&ParamField::Host), "localhost:7680");
}
#[test]
fn generate_params_display_host_custom() {
let config = Config::load_or_default();
let mut params = GenerateParams::from_config(&config);
params.host = Some("http://gpu-server:7680".to_string());
assert_eq!(
params.display_value(&ParamField::Host),
"http://gpu-server:7680"
);
}
#[test]
fn generate_params_display_mode() {
let config = Config::load_or_default();
let mut params = GenerateParams::from_config(&config);
params.inference_mode = InferenceMode::Auto;
assert_eq!(params.display_value(&ParamField::Mode), "auto");
params.inference_mode = InferenceMode::Local;
assert_eq!(params.display_value(&ParamField::Mode), "local");
params.inference_mode = InferenceMode::Remote;
assert_eq!(params.display_value(&ParamField::Mode), "remote");
}
#[test]
fn focus_navigation_next_enters_prompt() {
assert_eq!(GenerateFocus::Navigation.next(false), GenerateFocus::Prompt);
assert_eq!(GenerateFocus::Navigation.next(true), GenerateFocus::Prompt);
}
#[test]
fn focus_cycle_skips_negative_when_unsupported() {
assert_eq!(GenerateFocus::Prompt.next(false), GenerateFocus::Parameters);
assert_eq!(GenerateFocus::Parameters.prev(false), GenerateFocus::Prompt);
}
#[test]
fn focus_cycle_includes_negative_when_supported() {
assert_eq!(
GenerateFocus::Prompt.next(true),
GenerateFocus::NegativePrompt
);
assert_eq!(
GenerateFocus::Parameters.prev(true),
GenerateFocus::NegativePrompt
);
}
#[test]
fn param_field_labels_not_empty() {
let caps = crate::model_info::capabilities_for_family("sd15");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
for field in &fields {
if *field == ParamField::SeedValue {
continue;
}
assert!(
!field.label().is_empty(),
"field {:?} has empty label",
field
);
}
}
#[test]
fn progress_state_clear_resets_all() {
let mut state = ProgressState {
denoise_step: 10,
denoise_total: 20,
weight_loaded: 1000,
download_filename: "test.gguf".to_string(),
download_bytes: 500,
download_batch_bytes: 750,
download_batch_total: 1500,
download_batch_elapsed_ms: 250,
download_file_index: 2,
download_total_files: 5,
..Default::default()
};
state.push_log(ProgressLogEntry {
message: "test".to_string(),
style: ProgressStyle::Done,
});
state.clear();
assert_eq!(state.denoise_step, 0);
assert_eq!(state.denoise_total, 0);
assert_eq!(state.weight_loaded, 0);
assert_eq!(state.download_bytes, 0);
assert_eq!(state.download_batch_bytes, 0);
assert_eq!(state.download_batch_total, 0);
assert_eq!(state.download_batch_elapsed_ms, 0);
assert!(state.download_filename.is_empty());
assert_eq!(state.download_file_index, 0);
assert_eq!(state.download_total_files, 0);
assert!(state.log.is_empty());
}
#[test]
fn progress_state_download_tracks_file_index() {
let mut state = ProgressState {
download_filename: "model.safetensors".to_string(),
download_bytes: 16_384,
download_total: 2_900_000_000,
download_file_index: 1,
download_total_files: 5,
..Default::default()
};
assert_eq!(state.download_file_index, 1);
assert_eq!(state.download_total_files, 5);
state.download_bytes = 0;
state.download_total = 0;
state.download_filename.clear();
assert_eq!(state.download_file_index, 1);
}
#[test]
fn progress_state_default_has_zero_file_counters() {
let state = ProgressState::default();
assert_eq!(state.download_file_index, 0);
assert_eq!(state.download_total_files, 0);
}
#[test]
fn download_progress_preserves_total_file_count_across_chunk_updates() {
let mut state = ProgressState::default();
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "text_encoder_2/model.safetensors".to_string(),
bytes_downloaded: 0,
bytes_total: 2_600_000_000,
batch_bytes_downloaded: 3_000_000_000,
batch_bytes_total: 8_800_000_000,
batch_elapsed_ms: 60_000,
file_index: 2,
total_files: 6,
},
);
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "text_encoder_2/model.safetensors".to_string(),
bytes_downloaded: 16_384,
bytes_total: 2_600_000_000,
batch_bytes_downloaded: 3_000_016_384,
batch_bytes_total: 8_800_000_000,
batch_elapsed_ms: 60_100,
file_index: 2,
total_files: 0,
},
);
assert_eq!(state.download_filename, "text_encoder_2/model.safetensors");
assert_eq!(state.download_bytes, 16_384);
assert_eq!(state.download_total, 2_600_000_000);
assert_eq!(state.download_batch_bytes, 3_000_016_384);
assert_eq!(state.download_batch_total, 8_800_000_000);
assert_eq!(state.download_batch_elapsed_ms, 60_100);
assert!(state.download_rate_bps.is_none());
assert!(state.download_eta_secs.is_none());
assert_eq!(state.download_file_index, 2);
assert_eq!(state.download_total_files, 6);
}
#[test]
fn download_rate_and_eta_require_multiple_samples() {
let mut state = ProgressState::default();
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "model.safetensors".to_string(),
bytes_downloaded: 128,
bytes_total: 1024,
batch_bytes_downloaded: 128,
batch_bytes_total: 4096,
batch_elapsed_ms: 100,
file_index: 0,
total_files: 2,
},
);
assert!(state.download_rate_bps.is_none());
assert!(state.download_eta_secs.is_none());
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "model.safetensors".to_string(),
bytes_downloaded: 256,
bytes_total: 1024,
batch_bytes_downloaded: 256,
batch_bytes_total: 4096,
batch_elapsed_ms: 300,
file_index: 0,
total_files: 0,
},
);
assert!(state.download_rate_bps.is_none());
assert!(state.download_eta_secs.is_none());
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "model.safetensors".to_string(),
bytes_downloaded: 1_024,
bytes_total: 1024,
batch_bytes_downloaded: 1_536,
batch_bytes_total: 4096,
batch_elapsed_ms: 1_300,
file_index: 0,
total_files: 0,
},
);
assert!(state.download_rate_bps.is_some());
assert!(state.download_eta_secs.is_some());
}
#[test]
fn stage_start_clears_stale_download_bar_from_previous_pull() {
let mut state = ProgressState::default();
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "vae/model.safetensors".to_string(),
bytes_downloaded: 512,
bytes_total: 1024,
batch_bytes_downloaded: 2048,
batch_bytes_total: 8192,
batch_elapsed_ms: 500,
file_index: 0,
total_files: 3,
},
);
reduce_progress_state(
&mut state,
SseProgressEvent::StageStart {
name: "Loading model".to_string(),
},
);
assert_eq!(state.current_stage.as_deref(), Some("Loading model"));
assert!(state.download_filename.is_empty());
assert_eq!(state.download_bytes, 0);
assert_eq!(state.download_total, 0);
assert_eq!(state.download_batch_bytes, 0);
assert_eq!(state.download_batch_total, 0);
assert_eq!(state.download_batch_elapsed_ms, 0);
assert_eq!(state.download_file_index, 0);
assert_eq!(state.download_total_files, 0);
}
#[test]
fn pull_complete_clears_active_download_bar() {
let mut state = ProgressState::default();
reduce_progress_state(
&mut state,
SseProgressEvent::DownloadProgress {
filename: "diffusion_pytorch_model.safetensors".to_string(),
bytes_downloaded: 2048,
bytes_total: 4096,
batch_bytes_downloaded: 2048,
batch_bytes_total: 4096,
batch_elapsed_ms: 250,
file_index: 0,
total_files: 1,
},
);
let refresh_catalog = reduce_progress_state(
&mut state,
SseProgressEvent::PullComplete {
model: "flux2-klein:q8".to_string(),
},
);
assert!(refresh_catalog);
assert!(state.download_filename.is_empty());
assert_eq!(state.download_bytes, 0);
assert_eq!(state.download_total, 0);
assert_eq!(state.download_batch_bytes, 0);
assert_eq!(state.download_batch_total, 0);
assert_eq!(state.download_batch_elapsed_ms, 0);
assert_eq!(state.download_total_files, 0);
assert!(state
.log
.iter()
.any(|entry| entry.message == "Pull complete: flux2-klein:q8"));
}
#[test]
fn seed_mode_cycle() {
assert_eq!(SeedMode::Random.next(), SeedMode::Fixed);
assert_eq!(SeedMode::Fixed.next(), SeedMode::Increment);
assert_eq!(SeedMode::Increment.next(), SeedMode::Random);
}
#[test]
fn seed_mode_labels() {
assert_eq!(SeedMode::Random.label(), "random");
assert_eq!(SeedMode::Fixed.label(), "fixed");
assert_eq!(SeedMode::Increment.label(), "increment");
}
#[test]
fn seed_mode_random_generates_value() {
let _ = SeedMode::Random.resolve(None);
}
#[test]
fn seed_mode_fixed_keeps_seed() {
let seed = SeedMode::Fixed.resolve(Some(42));
assert_eq!(seed, 42);
}
#[test]
fn seed_mode_fixed_generates_if_none() {
let seed = SeedMode::Fixed.resolve(None);
let _ = seed; }
#[test]
fn seed_mode_increment_adds_one() {
let seed = SeedMode::Increment.resolve(Some(42));
assert_eq!(seed, 43);
}
#[test]
fn seed_mode_increment_wraps_at_max() {
let seed = SeedMode::Increment.resolve(Some(u64::MAX));
assert_eq!(seed, 0); }
#[test]
fn seed_mode_increment_generates_if_none() {
let seed = SeedMode::Increment.resolve(None);
let _ = seed;
}
#[test]
fn seed_mode_advance_random_returns_none() {
assert_eq!(SeedMode::Random.advance(42), None);
}
#[test]
fn seed_mode_advance_fixed_returns_same() {
assert_eq!(SeedMode::Fixed.advance(42), Some(42));
}
#[test]
fn seed_mode_advance_increment_returns_same() {
assert_eq!(SeedMode::Increment.advance(42), Some(42));
}
#[test]
fn seed_display_shows_mode() {
let config = Config::load_or_default();
let mut params = GenerateParams::from_config(&config);
assert_eq!(params.display_value(&ParamField::Seed), "random");
params.seed_mode = SeedMode::Fixed;
assert_eq!(params.display_value(&ParamField::Seed), "fixed");
params.seed_mode = SeedMode::Increment;
assert_eq!(params.display_value(&ParamField::Seed), "increment");
}
#[test]
fn seed_value_display_with_number() {
let config = Config::load_or_default();
let mut params = GenerateParams::from_config(&config);
params.seed = Some(12345);
assert_eq!(params.display_value(&ParamField::SeedValue), "12345");
}
#[test]
fn seed_value_display_random_when_none() {
let config = Config::load_or_default();
let params = GenerateParams::from_config(&config);
let display = params.display_value(&ParamField::SeedValue);
assert!(display.contains("random"));
}
#[test]
fn seed_value_shows_long_numbers_untruncated() {
let config = Config::load_or_default();
let mut params = GenerateParams::from_config(&config);
params.seed = Some(11275518943372801901);
let display = params.display_value(&ParamField::SeedValue);
assert_eq!(display, "11275518943372801901");
}
#[test]
fn history_nav_only_from_prompt_focus() {
let mut history = crate::history::PromptHistory::empty();
history.push_entry(crate::history::HistoryEntry {
prompt: "old prompt".to_string(),
negative: None,
model: "test".to_string(),
timestamp: 0,
});
let result = history.prev("current");
assert!(result.is_some());
history.reset_cursor();
}
#[test]
fn unimplemented_actions_exist() {
let unimplemented = vec![
Action::ZoomIn,
Action::ZoomOut,
Action::PanLeft,
Action::PanRight,
Action::FilterModels,
Action::ExpandPrompt,
Action::SaveImage,
Action::CompareModels,
];
for action in &unimplemented {
assert_ne!(*action, Action::Quit);
}
}
#[test]
fn model_actions_are_implemented() {
let implemented = vec![Action::PullModel, Action::RemoveModel, Action::UnloadModel];
for action in &implemented {
assert_ne!(*action, Action::Quit);
}
}
#[test]
fn gallery_actions_are_implemented() {
let implemented = vec![
Action::Regenerate,
Action::EditAndGenerate,
Action::DeleteImage,
Action::OpenFile,
Action::GridLeft,
Action::GridRight,
];
for action in &implemented {
assert_ne!(*action, Action::Quit);
}
}
#[test]
fn visible_fields_ends_with_tools_section() {
let caps = crate::model_info::capabilities_for_family("flux");
let fields = ParamField::visible_fields(&caps, InferenceMode::Auto);
assert_eq!(*fields.last().unwrap(), ParamField::UnloadModel);
let reset_pos = fields
.iter()
.position(|f| *f == ParamField::ResetDefaults)
.unwrap();
let unload_pos = fields
.iter()
.position(|f| *f == ParamField::UnloadModel)
.unwrap();
assert_eq!(unload_pos, reset_pos + 1);
}
#[test]
fn reset_defaults_display_value() {
let config = Config::load_or_default();
let params = GenerateParams::from_config(&config);
let display = params.display_value(&ParamField::ResetDefaults);
assert_eq!(display, "restore model defaults");
}
#[test]
fn unload_model_display_value() {
let config = Config::load_or_default();
let params = GenerateParams::from_config(&config);
let display = params.display_value(&ParamField::UnloadModel);
assert_eq!(display, "free GPU memory");
}
#[test]
fn unload_model_label() {
assert!(ParamField::UnloadModel.label().contains("Unload"));
}
#[test]
fn unload_model_has_no_section_header() {
assert!(ParamField::UnloadModel.section_header().is_none());
}
#[test]
fn reset_defaults_starts_actions_section() {
assert_eq!(ParamField::ResetDefaults.section_header(), Some("Actions"));
}
#[test]
fn unload_model_always_visible() {
for family in &["flux", "sd15", "sdxl", "sd3", "flux2"] {
for mode in &[
InferenceMode::Auto,
InferenceMode::Local,
InferenceMode::Remote,
] {
let caps = crate::model_info::capabilities_for_family(family);
let fields = ParamField::visible_fields(&caps, *mode);
assert!(
fields.contains(&ParamField::UnloadModel),
"UnloadModel missing for family={} mode={:?}",
family,
mode
);
}
}
}
#[test]
fn gallery_view_mode_default_is_grid() {
assert_eq!(GalleryViewMode::default(), GalleryViewMode::Grid);
}
#[test]
fn gallery_entry_filename_extracts_name() {
let entry = GalleryEntry {
path: std::path::PathBuf::from("/home/user/.mold/output/mold-flux-1234.png"),
metadata: mold_core::OutputMetadata {
prompt: "test".to_string(),
negative_prompt: None,
original_prompt: None,
model: "flux:q8".to_string(),
seed: 42,
steps: 20,
guidance: 7.5,
width: 1024,
height: 1024,
strength: None,
scheduler: None,
output_format: Some(mold_core::OutputFormat::Png),
cfg_plus: None,
lora: None,
lora_scale: None,
loras: None,
control_model: None,
control_scale: None,
upscale_model: None,
gif_preview: None,
enable_audio: None,
audio_file_path: None,
source_video_path: None,
pipeline: None,
retake_range: None,
spatial_upscale: None,
temporal_upscale: None,
version: "0.3.1".to_string(),
frames: None,
fps: None,
},
generation_time_ms: Some(5000),
timestamp: 1234,
server_url: None,
};
assert_eq!(entry.filename(), "mold-flux-1234.png");
}
#[test]
fn gallery_entry_filename_unknown_for_empty_path() {
let entry = GalleryEntry {
path: std::path::PathBuf::new(),
metadata: mold_core::OutputMetadata {
prompt: "test".to_string(),
negative_prompt: None,
original_prompt: None,
model: "test".to_string(),
seed: 0,
steps: 1,
guidance: 0.0,
width: 512,
height: 512,
strength: None,
scheduler: None,
output_format: Some(mold_core::OutputFormat::Png),
cfg_plus: None,
lora: None,
lora_scale: None,
loras: None,
control_model: None,
control_scale: None,
upscale_model: None,
gif_preview: None,
enable_audio: None,
audio_file_path: None,
source_video_path: None,
pipeline: None,
retake_range: None,
spatial_upscale: None,
temporal_upscale: None,
version: "0.0.0".to_string(),
frames: None,
fps: None,
},
generation_time_ms: None,
timestamp: 0,
server_url: None,
};
assert_eq!(entry.filename(), "unknown");
}
#[test]
fn gallery_grid_nav_up_moves_by_cols() {
let selected: usize = 5;
let cols: usize = 3;
let result = if selected >= cols {
selected - cols
} else {
selected
};
assert_eq!(result, 2);
}
#[test]
fn gallery_grid_nav_down_moves_by_cols() {
let selected: usize = 2;
let cols: usize = 3;
let len: usize = 9;
let next = selected + cols;
let result = if next < len { next } else { selected };
assert_eq!(result, 5);
}
#[test]
fn gallery_grid_nav_clamps_at_top() {
let selected: usize = 1;
let cols: usize = 3;
let result = if selected >= cols {
selected - cols
} else {
selected
};
assert_eq!(result, 1); }
#[test]
fn gallery_grid_nav_left_right() {
let selected: usize = 3;
let len: usize = 10;
assert_eq!(selected.saturating_sub(1), 2);
assert_eq!((selected + 1).min(len - 1), 4);
}
#[test]
fn confirm_action_delete_gallery_image_variant_exists() {
let action = ConfirmAction::DeleteGalleryImage;
match action {
ConfirmAction::DeleteGalleryImage => {}
_ => panic!("expected DeleteGalleryImage"),
}
}
#[test]
fn confirm_action_remove_model_variant_exists() {
let action = ConfirmAction::RemoveModel("test".to_string());
match action {
ConfirmAction::RemoveModel(name) => assert_eq!(name, "test"),
_ => panic!("expected RemoveModel"),
}
}
fn make_test_metadata() -> mold_core::OutputMetadata {
mold_core::OutputMetadata {
prompt: "a test prompt".to_string(),
negative_prompt: Some("blurry".to_string()),
original_prompt: None,
model: "flux:q8".to_string(),
seed: 42,
steps: 20,
guidance: 7.5,
width: 1024,
height: 1024,
strength: Some(0.75),
scheduler: None,
output_format: Some(mold_core::OutputFormat::Png),
cfg_plus: None,
lora: Some("/path/to/adapter.safetensors".to_string()),
lora_scale: Some(0.8),
loras: Some(vec![mold_core::LoraWeight {
path: "/path/to/adapter.safetensors".to_string(),
scale: 0.8,
}]),
control_model: None,
control_scale: None,
upscale_model: None,
gif_preview: None,
enable_audio: None,
audio_file_path: None,
source_video_path: None,
pipeline: None,
retake_range: None,
spatial_upscale: None,
temporal_upscale: None,
version: "0.3.1".to_string(),
frames: None,
fps: None,
}
}
fn make_test_entry() -> GalleryEntry {
GalleryEntry {
path: std::path::PathBuf::from("/home/user/.mold/output/mold-flux-1234.png"),
metadata: make_test_metadata(),
generation_time_ms: Some(5000),
timestamp: 1234,
server_url: None,
}
}
#[test]
fn gallery_entry_metadata_accessible() {
let entry = make_test_entry();
assert_eq!(entry.metadata.prompt, "a test prompt");
assert_eq!(entry.metadata.model, "flux:q8");
assert_eq!(entry.metadata.seed, 42);
assert_eq!(entry.metadata.steps, 20);
assert_eq!(entry.metadata.width, 1024);
assert_eq!(entry.metadata.negative_prompt, Some("blurry".to_string()));
assert_eq!(entry.metadata.strength, Some(0.75));
assert_eq!(entry.metadata.lora_scale, Some(0.8));
}
#[test]
fn gallery_entry_clone() {
let entry = make_test_entry();
let cloned = entry.clone();
assert_eq!(cloned.filename(), entry.filename());
assert_eq!(cloned.metadata.prompt, entry.metadata.prompt);
assert_eq!(cloned.timestamp, entry.timestamp);
}
#[test]
fn gallery_grid_nav_down_clamps_at_bottom() {
let selected: usize = 7;
let cols: usize = 3;
let len: usize = 9;
let next = selected + cols;
let result = if next < len { next } else { selected };
assert_eq!(result, 7);
}
#[test]
fn gallery_grid_nav_right_clamps_at_end() {
let selected: usize = 8;
let len: usize = 9;
let result = (selected + 1).min(len - 1);
assert_eq!(result, 8); }
#[test]
fn gallery_grid_nav_left_clamps_at_zero() {
let selected: usize = 0;
assert_eq!(selected.saturating_sub(1), 0);
}
#[test]
fn seed_activate_toggles_mode() {
let mode = SeedMode::Random;
let next = mode.next();
assert_eq!(next, SeedMode::Fixed);
let next2 = next.next();
assert_eq!(next2, SeedMode::Increment);
let next3 = next2.next();
assert_eq!(next3, SeedMode::Random);
}
#[test]
fn gallery_view_mode_equality() {
assert_eq!(GalleryViewMode::Grid, GalleryViewMode::Grid);
assert_eq!(GalleryViewMode::Detail, GalleryViewMode::Detail);
assert_ne!(GalleryViewMode::Grid, GalleryViewMode::Detail);
}
#[test]
fn gallery_state_default_grid_cols() {
let state = GalleryState {
entries: Vec::new(),
selected: 0,
preview_image: None,
image_state: None,
animation: None,
scanning: false,
view_mode: GalleryViewMode::Grid,
thumbnail_states: Vec::new(),
thumb_dimensions: Vec::new(),
thumb_fixed_cache: Vec::new(),
grid_cols: 3,
grid_scroll: 0,
};
assert_eq!(state.grid_cols, 3);
assert_eq!(state.grid_scroll, 0);
assert!(state.thumbnail_states.is_empty());
}
#[test]
fn gallery_thumbnail_states_sync_with_entries() {
let entries = [make_test_entry(), make_test_entry()];
let thumb_states: Vec<Option<StatefulProtocol>> = vec![None; entries.len()];
assert_eq!(thumb_states.len(), entries.len());
}
#[test]
fn default_output_dir_path() {
let dir = crate::gallery_scan::default_gallery_dir();
let s = dir.to_string_lossy();
assert!(
s.ends_with("output"),
"expected path ending in 'output': {s}"
);
}
#[test]
fn background_event_thumbnails_ready_variant() {
let event = BackgroundEvent::ThumbnailsReady;
match event {
BackgroundEvent::ThumbnailsReady => {}
_ => panic!("expected ThumbnailsReady"),
}
}
fn make_settings_test_app() -> App {
crate::test_env::disable_db_for_non_isolated_tests();
let mut config = Config {
default_model: "flux2-klein:q8".to_string(),
..Default::default()
};
config.models.insert(
"test-model:q8".to_string(),
mold_core::config::ModelConfig {
transformer: Some("/path/to/transformer.gguf".into()),
vae: Some("/path/to/vae.safetensors".into()),
default_steps: Some(20),
default_guidance: Some(3.5),
default_width: Some(1024),
default_height: Some(1024),
lora: Some("/path/to/lora.safetensors".into()),
lora_scale: Some(0.8),
negative_prompt: Some("blurry, low quality".into()),
scheduler: Some(Scheduler::EulerAncestral),
..Default::default()
},
);
let picker = ratatui_image::picker::Picker::from_fontsize((8, 16));
let params = GenerateParams::from_config(&config);
let family = crate::model_info::family_for_model(¶ms.model, &config);
let caps = crate::model_info::capabilities_for_family(&family);
let visible = ParamField::visible_fields(&caps, params.inference_mode);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
App {
active_view: View::Settings,
generate: GenerateState {
prompt: TextArea::default(),
negative_prompt: TextArea::default(),
params,
focus: GenerateFocus::Navigation,
param_index: 0,
visible_fields: visible,
capabilities: caps,
progress: ProgressState::default(),
preview_image: None,
image_state: None,
animation: None,
generating: false,
batch_remaining: 0,
last_seed: None,
last_generation_time_ms: None,
error_message: None,
model_description: String::new(),
negative_collapsed: false,
},
gallery: GalleryState {
entries: Vec::new(),
selected: 0,
preview_image: None,
image_state: None,
animation: None,
scanning: false,
view_mode: GalleryViewMode::Grid,
thumbnail_states: Vec::new(),
thumb_dimensions: Vec::new(),
thumb_fixed_cache: Vec::new(),
grid_cols: 3,
grid_scroll: 0,
},
models: ModelsState {
catalog: Vec::new(),
selected: 0,
filter: String::new(),
filtering: false,
},
settings: SettingsState {
selected_model: Some("test-model:q8".to_string()),
row_index: 1,
skip_save: true,
..Default::default()
},
script: crate::ui::script_composer::ScriptComposerState::default(),
config,
server_url: None,
picker,
theme: crate::ui::theme::Theme::default(),
popup: None,
should_quit: false,
bg_tx,
bg_rx,
tokio_handle: tokio::runtime::Handle::current(),
resource_info: crate::ui::info::ResourceInfo::default(),
history: crate::history::PromptHistory::empty(),
layout: LayoutAreas::default(),
server_process: None,
upscale_in_progress: false,
upscale_task: None,
upscale_tile_progress: None,
upscale_progress: ProgressState::default(),
connecting: false,
}
}
fn find_settings_row(app: &App, key: SettingsKey) -> usize {
let rows = app.build_settings_rows();
rows.iter()
.position(|r| matches!(r, SettingsRow::Field { key: k, .. } if *k == key))
.unwrap_or_else(|| panic!("SettingsKey {key:?} not found in rows"))
}
#[test]
fn settings_state_default_values() {
let state = SettingsState::default();
assert_eq!(state.row_index, 0);
assert_eq!(state.scroll_offset, 0);
assert!(state.selected_model.is_none());
assert!(state.save_error.is_none());
}
#[test]
fn settings_row_is_field_and_read_only() {
let header = SettingsRow::SectionHeader {
name: "General".into(),
};
assert!(!header.is_field());
let field = SettingsRow::Field {
key: SettingsKey::DefaultModel,
label: "Model",
field_type: SettingsFieldType::Text,
};
assert!(field.is_field());
assert!(!field.is_read_only());
let ro = SettingsRow::Field {
key: SettingsKey::ModelTransformer,
label: "Transformer",
field_type: SettingsFieldType::ReadOnly,
};
assert!(ro.is_read_only());
}
#[test]
fn settings_env_override_returns_none_for_unset() {
assert!(App::settings_env_override(&SettingsKey::ServerPort).is_none());
assert!(App::settings_env_override(&SettingsKey::DefaultWidth).is_none());
assert!(App::settings_env_override(&SettingsKey::LogLevel).is_none());
}
#[test]
fn settings_input_popup_variant_exists() {
let popup = Popup::SettingsInput {
key: SettingsKey::DefaultModel,
input: "test".to_string(),
label: "Model".to_string(),
};
match popup {
Popup::SettingsInput { key, input, label } => {
assert_eq!(key, SettingsKey::DefaultModel);
assert_eq!(input, "test");
assert_eq!(label, "Model");
}
_ => panic!("expected SettingsInput"),
}
}
#[test]
fn view_labels_and_indices() {
assert_eq!(View::Generate.label(), "Generate");
assert_eq!(View::Gallery.label(), "Gallery");
assert_eq!(View::Models.label(), "Models");
assert_eq!(View::Queue.label(), "Queue");
assert_eq!(View::Settings.label(), "Settings");
assert_eq!(View::Queue.index(), 3);
assert_eq!(View::Settings.index(), 4);
assert_eq!(View::ALL.len(), 6);
assert_eq!(View::ALL[3], View::Queue);
assert_eq!(View::ALL[4], View::Settings);
assert_eq!(View::ALL[5], View::Script);
}
#[test]
fn view_all_includes_script() {
assert_eq!(View::ALL.len(), 6);
assert_eq!(View::ALL[5], View::Script);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_display_all_global_defaults() {
let app = make_settings_test_app();
assert_eq!(
app.settings_display_value(&SettingsKey::DefaultModel),
"flux2-klein:q8"
);
assert_eq!(app.settings_display_value(&SettingsKey::ServerPort), "7680");
assert_eq!(
app.settings_display_value(&SettingsKey::DefaultWidth),
"768"
);
assert_eq!(
app.settings_display_value(&SettingsKey::DefaultHeight),
"768"
);
assert_eq!(app.settings_display_value(&SettingsKey::DefaultSteps), "4");
assert_eq!(
app.settings_display_value(&SettingsKey::EmbedMetadata),
"on"
);
assert_eq!(app.settings_display_value(&SettingsKey::T5Variant), "auto");
assert_eq!(
app.settings_display_value(&SettingsKey::Qwen3Variant),
"auto"
);
assert_eq!(
app.settings_display_value(&SettingsKey::DefaultNegativePrompt),
"(none)"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_display_all_expand_defaults() {
let app = make_settings_test_app();
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandEnabled),
"off"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandBackend),
"local"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandModel),
"qwen3-expand:q8"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandApiModel),
"qwen2.5:3b"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandTemperature),
"0.7"
);
assert_eq!(app.settings_display_value(&SettingsKey::ExpandTopP), "0.90");
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandMaxTokens),
"300"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ExpandThinking),
"off"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_display_all_logging_defaults() {
let app = make_settings_test_app();
assert_eq!(app.settings_display_value(&SettingsKey::LogLevel), "info");
assert_eq!(app.settings_display_value(&SettingsKey::LogFile), "off");
assert_eq!(app.settings_display_value(&SettingsKey::LogMaxDays), "7");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_display_all_model_defaults() {
let app = make_settings_test_app();
assert_eq!(
app.settings_display_value(&SettingsKey::ModelSelector),
"test-model:q8"
);
assert_eq!(app.settings_display_value(&SettingsKey::ModelSteps), "20");
assert_eq!(
app.settings_display_value(&SettingsKey::ModelGuidance),
"3.5"
);
assert_eq!(app.settings_display_value(&SettingsKey::ModelWidth), "1024");
assert_eq!(
app.settings_display_value(&SettingsKey::ModelHeight),
"1024"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelScheduler),
"euler-ancestral"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelNegativePrompt),
"blurry, low quality"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelLora),
"/path/to/lora.safetensors"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelLoraScale),
"0.8"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelTransformer),
"/path/to/transformer.gguf"
);
assert_eq!(
app.settings_display_value(&SettingsKey::ModelVae),
"/path/to/vae.safetensors"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_server_port() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ServerPort, 1.0, 1024.0, 65535.0);
assert_eq!(app.config.server_port, 7681);
app.settings_adjust_number(SettingsKey::ServerPort, -2.0, 1024.0, 65535.0);
assert_eq!(app.config.server_port, 7679);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_default_width() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::DefaultWidth, 64.0, 64.0, 4096.0);
assert_eq!(app.config.default_width, 832);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_default_height() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::DefaultHeight, -64.0, 64.0, 4096.0);
assert_eq!(app.config.default_height, 704);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_default_steps() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::DefaultSteps, 1.0, 1.0, 200.0);
assert_eq!(app.config.default_steps, 5);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn alt_5_while_generating_from_prompt_focus_switches_to_settings() {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut app = make_settings_test_app();
app.active_view = View::Generate;
app.generate.focus = GenerateFocus::Prompt;
app.generate.generating = true;
app.generate.progress.mark_generation_start();
app.handle_crossterm_event(Event::Key(KeyEvent::new(
KeyCode::Char('5'),
KeyModifiers::ALT,
)));
assert_eq!(app.active_view, View::Settings);
assert!(app.generate.generating, "generation must not be aborted");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn theme_cycle_applies_immediately_while_generating() {
use crate::ui::theme::ThemePreset;
let mut app = make_settings_test_app();
app.active_view = View::Settings;
app.settings.focus = SettingsFocus::Appearance;
app.generate.generating = true;
app.generate.progress.mark_generation_start();
let before = app.settings.theme_preset;
assert_eq!(before, ThemePreset::Mocha);
app.dispatch_action(Action::Increment);
assert_ne!(app.settings.theme_preset, before);
assert_eq!(app.theme.bg, app.settings.theme_preset.build().bg);
assert!(app.generate.generating, "generation must keep running");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn esc_then_5_reaches_settings_while_generating() {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut app = make_settings_test_app();
app.active_view = View::Generate;
app.generate.focus = GenerateFocus::Prompt;
app.generate.generating = true;
app.generate.progress.mark_generation_start();
app.handle_crossterm_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert_eq!(app.generate.focus, GenerateFocus::Navigation);
app.handle_crossterm_event(Event::Key(KeyEvent::new(
KeyCode::Char('5'),
KeyModifiers::NONE,
)));
assert_eq!(app.active_view, View::Settings);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn enter_on_appearance_pane_does_not_open_model_dialog() {
let mut app = make_settings_test_app();
app.active_view = View::Settings;
app.settings.focus = SettingsFocus::Appearance;
app.dispatch_action(Action::Confirm);
assert!(
app.popup.is_none(),
"Enter on the Appearance pane must stay on the swatch grid \
and must not open the Model popup"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn enter_on_configuration_still_opens_popup() {
let mut app = make_settings_test_app();
app.active_view = View::Settings;
app.settings.focus = SettingsFocus::Configuration;
app.settings.row_index = 1; app.dispatch_action(Action::Confirm);
assert!(
app.popup.is_some(),
"Enter on a Configuration Text row must still open the popup"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_theme_wraps_both_directions() {
use crate::ui::theme::ThemePreset;
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
assert_eq!(app.settings.theme_preset, ThemePreset::Mocha);
app.settings_cycle_theme(1);
assert_eq!(app.settings.theme_preset, ThemePreset::Latte);
assert_eq!(app.theme.bg, ThemePreset::Latte.build().bg);
app.apply_theme_preset(ThemePreset::Mocha);
app.settings_cycle_theme(-1);
assert_eq!(app.settings.theme_preset, ThemePreset::Dracula);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_navigate_up_from_top_focuses_appearance() {
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Configuration;
app.settings.row_index = 1;
app.settings_navigate(-1);
app.settings_navigate(-1);
assert_eq!(app.settings.focus, SettingsFocus::Appearance);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_navigate_down_from_appearance_enters_configuration() {
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Appearance;
app.settings_navigate(1);
assert_eq!(app.settings.focus, SettingsFocus::Configuration);
}
fn alt_key_event(code: crossterm::event::KeyCode) -> crossterm::event::Event {
use crossterm::event::{Event, KeyEvent, KeyModifiers};
Event::Key(KeyEvent::new(code, KeyModifiers::ALT))
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn alt_5_while_typing_prompt_switches_to_settings() {
use crossterm::event::KeyCode;
let mut app = make_settings_test_app();
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
app.handle_crossterm_event(alt_key_event(KeyCode::Char('5')));
assert_eq!(app.active_view, View::Settings);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn alt_4_while_typing_prompt_switches_to_queue() {
use crossterm::event::KeyCode;
let mut app = make_settings_test_app();
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
app.handle_crossterm_event(alt_key_event(KeyCode::Char('4')));
assert_eq!(app.active_view, View::Queue);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn alt_n_while_typing_prompt_toggles_negative_collapse() {
use crossterm::event::KeyCode;
let mut app = make_settings_test_app();
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
assert!(!app.generate.negative_collapsed);
app.handle_crossterm_event(alt_key_event(KeyCode::Char('n')));
assert!(app.generate.negative_collapsed);
app.handle_crossterm_event(alt_key_event(KeyCode::Char('n')));
assert!(!app.generate.negative_collapsed);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn toggle_negative_prompt_flips_collapsed_flag() {
let mut app = make_settings_test_app();
assert!(!app.generate.negative_collapsed);
app.dispatch_action(Action::ToggleNegativePrompt);
assert!(app.generate.negative_collapsed);
app.dispatch_action(Action::ToggleNegativePrompt);
assert!(!app.generate.negative_collapsed);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn tab_from_prompt_skips_negative_when_collapsed() {
let mut app = make_settings_test_app();
app.generate.capabilities.supports_negative_prompt = true;
app.generate.negative_collapsed = true;
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
app.dispatch_action(Action::FocusNext);
assert_eq!(app.generate.focus, GenerateFocus::Parameters);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn shift_tab_from_parameters_skips_negative_when_collapsed() {
let mut app = make_settings_test_app();
app.generate.capabilities.supports_negative_prompt = true;
app.generate.negative_collapsed = true;
app.generate.focus = GenerateFocus::Parameters;
app.active_view = View::Generate;
app.dispatch_action(Action::FocusPrev);
assert_eq!(app.generate.focus, GenerateFocus::Prompt);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn tab_still_visits_negative_when_expanded() {
let mut app = make_settings_test_app();
app.generate.capabilities.supports_negative_prompt = true;
app.generate.negative_collapsed = false;
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
app.dispatch_action(Action::FocusNext);
assert_eq!(app.generate.focus, GenerateFocus::NegativePrompt);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn mouse_click_on_gallery_tile_row_2_selects_correct_tile() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = make_settings_test_app();
app.active_view = View::Gallery;
app.gallery.view_mode = GalleryViewMode::Grid;
for i in 0..9 {
app.gallery.entries.push(GalleryEntry {
path: std::path::PathBuf::from(format!("tile-{i}.png")),
metadata: make_test_metadata(),
generation_time_ms: None,
timestamp: 0,
server_url: None,
});
app.gallery.thumbnail_states.push(None);
app.gallery.thumb_dimensions.push(None);
app.gallery.thumb_fixed_cache.push(None);
}
app.gallery.grid_cols = 3;
app.gallery.grid_scroll = 0;
app.layout.gallery_grid = ratatui::layout::Rect::new(0, 3, 72, 40);
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 36,
row: 30,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let expected_index = 2 * 3 + 1; assert_eq!(
app.gallery.selected, expected_index,
"click on tile (col=1, row=2) at (col=36,row=30) should select index {expected_index} — \
mouse hit-test must track the real CELL_H, not the stale 14"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn mouse_click_on_each_tab_switches_to_that_view() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let tab_bar = ratatui::layout::Rect::new(0, 0, 120, 3);
let cases: &[(u16, View, &str)] = &[
(0, View::Generate, "block padding"),
(3, View::Generate, "Generate '1'"),
(9, View::Generate, "Generate 'a'"),
(15, View::Generate, "Generate trailing divider"),
(16, View::Gallery, "Gallery pad_left"),
(18, View::Gallery, "Gallery '2'"),
(24, View::Gallery, "Gallery 'r'"),
(29, View::Gallery, "Gallery trailing divider"),
(30, View::Models, "Models pad_left"),
(32, View::Models, "Models '3'"),
(38, View::Models, "Models 'e'"),
(42, View::Models, "Models trailing divider"),
(43, View::Queue, "Queue pad_left (was 'finicky')"),
(45, View::Queue, "Queue '4'"),
(48, View::Queue, "Queue 'e' (body of label)"),
(52, View::Queue, "Queue end of title"),
(54, View::Queue, "Queue trailing divider"),
(55, View::Settings, "Settings pad_left"),
(57, View::Settings, "Settings '5'"),
(62, View::Settings, "Settings 'n'"),
(68, View::Settings, "Settings pad_right"),
(69, View::Settings, "Settings trailing divider"),
(70, View::Script, "Script pad_left"),
(72, View::Script, "Script '6'"),
(76, View::Script, "Script 'p'"),
(81, View::Script, "Script pad_right"),
];
for (col, expected, name) in cases {
let mut app = make_settings_test_app();
app.layout.tab_bar = tab_bar;
app.active_view = View::Generate; app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: *col,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_eq!(
app.active_view, *expected,
"clicking col {col} ({name}) should land on {expected:?}, got {:?}",
app.active_view
);
}
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn mouse_click_past_last_tab_does_not_select_settings() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = make_settings_test_app();
app.layout.tab_bar = ratatui::layout::Rect::new(0, 0, 120, 3);
app.active_view = View::Generate;
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 90,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_eq!(
app.active_view,
View::Generate,
"clicks past the last rendered tab must be a no-op, not a stealth jump to Settings"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn mouse_hit_test_matches_real_tab_bar_rendering() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.active_view = View::Generate;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let tab_bar = app.layout.tab_bar;
assert!(tab_bar.height >= 2, "tab bar should have room to render");
let tab_row = tab_bar.y + 1;
let buf = terminal.backend().buffer();
let digit_to_view: &[(&str, View)] = &[
("1", View::Generate),
("2", View::Gallery),
("3", View::Models),
("4", View::Queue),
("5", View::Settings),
("6", View::Script),
];
for (digit, expected) in digit_to_view {
let col = (0..tab_bar.width)
.find(|&x| buf[(tab_bar.x + x, tab_row)].symbol() == *digit)
.unwrap_or_else(|| panic!("digit {digit} not found in rendered tab bar"));
let click_col = tab_bar.x + col;
let mut click_app = make_settings_test_app();
click_app.layout.tab_bar = tab_bar;
click_app.active_view = View::Generate;
click_app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: click_col,
row: tab_row,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_eq!(
click_app.active_view, *expected,
"clicking on digit '{digit}' at col {click_col} should land on {expected:?}, got {:?}",
click_app.active_view
);
}
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn mouse_click_on_collapsed_negative_row_does_not_focus_it() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = make_settings_test_app();
app.generate.capabilities.supports_negative_prompt = true;
app.generate.negative_collapsed = true;
app.generate.focus = GenerateFocus::Prompt;
app.active_view = View::Generate;
app.layout.negative_prompt = ratatui::layout::Rect::new(0, 5, 80, 1);
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_ne!(app.generate.focus, GenerateFocus::NegativePrompt);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn toggle_negative_prompt_while_focused_moves_focus_to_prompt() {
let mut app = make_settings_test_app();
app.generate.focus = GenerateFocus::NegativePrompt;
app.dispatch_action(Action::ToggleNegativePrompt);
assert!(app.generate.negative_collapsed);
assert_eq!(app.generate.focus, GenerateFocus::Prompt);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_increment_on_appearance_cycles_theme() {
use crate::ui::theme::ThemePreset;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Appearance;
let before = app.settings.theme_preset;
app.settings_increment(1);
assert_ne!(app.settings.theme_preset, before);
assert_eq!(app.settings.theme_preset, ThemePreset::Latte);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_expand_temperature() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ExpandTemperature, 0.1, 0.0, 2.0);
assert!((app.config.expand.temperature - 0.8).abs() < 0.001);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_expand_top_p() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ExpandTopP, -0.05, 0.0, 1.0);
assert!((app.config.expand.top_p - 0.85).abs() < 0.001);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_expand_max_tokens() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ExpandMaxTokens, 64.0, 64.0, 4096.0);
assert_eq!(app.config.expand.max_tokens, 364);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_log_max_days() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::LogMaxDays, 1.0, 1.0, 365.0);
assert_eq!(app.config.logging.max_days, 8);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_model_steps() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ModelSteps, 1.0, 1.0, 200.0);
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.default_steps, Some(21));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_model_guidance() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ModelGuidance, 0.5, 0.0, 30.0);
let mc = app.config.models.get("test-model:q8").unwrap();
assert!((mc.default_guidance.unwrap() - 4.0).abs() < 0.001);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_model_width() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ModelWidth, 64.0, 64.0, 4096.0);
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.default_width, Some(1088));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_model_height() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ModelHeight, -64.0, 64.0, 4096.0);
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.default_height, Some(960));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_adjust_model_lora_scale() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::ModelLoraScale, 0.1, 0.0, 2.0);
let mc = app.config.models.get("test-model:q8").unwrap();
assert!((mc.lora_scale.unwrap() - 0.9).abs() < 0.001);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_numeric_clamps_at_min() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::DefaultSteps, -100.0, 1.0, 200.0);
assert_eq!(app.config.default_steps, 1);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_numeric_clamps_at_max() {
let mut app = make_settings_test_app();
app.settings_adjust_number(SettingsKey::DefaultSteps, 500.0, 1.0, 200.0);
assert_eq!(app.config.default_steps, 200);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_toggle_embed_metadata() {
let mut app = make_settings_test_app();
assert!(app.config.embed_metadata);
app.settings_toggle_bool(SettingsKey::EmbedMetadata);
assert!(!app.config.embed_metadata);
app.settings_toggle_bool(SettingsKey::EmbedMetadata);
assert!(app.config.embed_metadata);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_toggle_expand_enabled() {
let mut app = make_settings_test_app();
assert!(!app.config.expand.enabled);
app.settings_toggle_bool(SettingsKey::ExpandEnabled);
assert!(app.config.expand.enabled);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_toggle_expand_thinking() {
let mut app = make_settings_test_app();
assert!(!app.config.expand.thinking);
app.settings_toggle_bool(SettingsKey::ExpandThinking);
assert!(app.config.expand.thinking);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_toggle_log_file() {
let mut app = make_settings_test_app();
assert!(!app.config.logging.file);
app.settings_toggle_bool(SettingsKey::LogFile);
assert!(app.config.logging.file);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_t5_variant() {
let mut app = make_settings_test_app();
let opts = &["auto", "fp16", "q8", "q6", "q5", "q4", "q3"];
assert_eq!(app.settings_display_value(&SettingsKey::T5Variant), "auto");
app.settings_cycle_toggle(SettingsKey::T5Variant, opts, 1);
assert_eq!(app.config.t5_variant, Some("fp16".into()));
app.settings_cycle_toggle(SettingsKey::T5Variant, opts, 1);
assert_eq!(app.config.t5_variant, Some("q8".into()));
app.settings_cycle_toggle(SettingsKey::T5Variant, opts, -2);
assert!(app.config.t5_variant.is_none()); }
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_qwen3_variant() {
let mut app = make_settings_test_app();
let opts = &["auto", "bf16", "q8", "q6", "iq4", "q3"];
app.settings_cycle_toggle(SettingsKey::Qwen3Variant, opts, 1);
assert_eq!(app.config.qwen3_variant, Some("bf16".into()));
app.settings_cycle_toggle(SettingsKey::Qwen3Variant, opts, -1);
assert!(app.config.qwen3_variant.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_log_level() {
let mut app = make_settings_test_app();
let opts = &["trace", "debug", "info", "warn", "error"];
assert_eq!(app.config.logging.level, "info");
app.settings_cycle_toggle(SettingsKey::LogLevel, opts, 1);
assert_eq!(app.config.logging.level, "warn");
app.settings_cycle_toggle(SettingsKey::LogLevel, opts, 1);
assert_eq!(app.config.logging.level, "error");
app.settings_cycle_toggle(SettingsKey::LogLevel, opts, 1); assert_eq!(app.config.logging.level, "trace");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_model_scheduler() {
let mut app = make_settings_test_app();
let opts = &["(none)", "ddim", "euler-ancestral", "uni-pc"];
assert_eq!(
app.settings_display_value(&SettingsKey::ModelScheduler),
"euler-ancestral"
);
app.settings_cycle_toggle(SettingsKey::ModelScheduler, opts, 1);
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.scheduler, Some(Scheduler::UniPc));
app.settings_cycle_toggle(SettingsKey::ModelScheduler, opts, 1); let mc = app.config.models.get("test-model:q8").unwrap();
assert!(mc.scheduler.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_default_model() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::DefaultModel, "sd15:fp16".into());
assert_eq!(app.config.default_model, "sd15:fp16");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_models_dir() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::ModelsDir, "/tmp/models".into());
assert_eq!(app.config.models_dir, "/tmp/models");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_output_dir() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::OutputDir, "/tmp/output".into());
assert_eq!(app.config.output_dir, Some("/tmp/output".into()));
app.settings_apply_input(SettingsKey::OutputDir, String::new());
assert!(app.config.output_dir.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_default_negative_prompt() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::DefaultNegativePrompt, "ugly, deformed".into());
assert_eq!(
app.config.default_negative_prompt,
Some("ugly, deformed".into())
);
app.settings_apply_input(SettingsKey::DefaultNegativePrompt, String::new());
assert!(app.config.default_negative_prompt.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_expand_backend() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::ExpandBackend, "http://localhost:11434".into());
assert_eq!(app.config.expand.backend, "http://localhost:11434");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_expand_model() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::ExpandModel, "qwen3-expand:q4".into());
assert_eq!(app.config.expand.model, "qwen3-expand:q4");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_expand_api_model() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::ExpandApiModel, "gpt-4o".into());
assert_eq!(app.config.expand.api_model, "gpt-4o");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_log_dir() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::LogDir, "/tmp/logs".into());
assert_eq!(app.config.logging.dir, Some("/tmp/logs".into()));
app.settings_apply_input(SettingsKey::LogDir, String::new());
assert!(app.config.logging.dir.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_model_negative_prompt() {
let mut app = make_settings_test_app();
app.settings_apply_input(SettingsKey::ModelNegativePrompt, "watermark".into());
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.negative_prompt, Some("watermark".into()));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_apply_model_lora() {
let mut app = make_settings_test_app();
app.settings_apply_input(
SettingsKey::ModelLora,
"/new/path/to/lora.safetensors".into(),
);
let mc = app.config.models.get("test-model:q8").unwrap();
assert_eq!(mc.lora, Some("/new/path/to/lora.safetensors".into()));
app.settings_apply_input(SettingsKey::ModelLora, String::new());
let mc = app.config.models.get("test-model:q8").unwrap();
assert!(mc.lora.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_cycle_model_selector() {
let mut app = make_settings_test_app();
app.config.models.insert(
"second-model:fp16".to_string(),
mold_core::config::ModelConfig::default(),
);
assert_eq!(app.settings.selected_model, Some("test-model:q8".into()));
app.settings_cycle_model(1);
assert!(app.settings.selected_model.is_some());
let selected = app.settings.selected_model.clone().unwrap();
app.settings_cycle_model(1); assert_ne!(
app.settings.selected_model.as_deref(),
Some(selected.as_str())
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_navigate_skips_headers() {
let mut app = make_settings_test_app();
app.settings.row_index = 0;
app.settings_navigate(1); let rows = app.build_settings_rows();
assert!(rows[app.settings.row_index].is_field());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_navigate_clamps_at_boundaries() {
let mut app = make_settings_test_app();
app.settings.row_index = 0;
app.settings_navigate(-1); assert_eq!(app.settings.row_index, 0);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_rows_have_all_sections() {
let app = make_settings_test_app();
let rows = app.build_settings_rows();
let headers: Vec<String> = rows
.iter()
.filter_map(|r| match r {
SettingsRow::SectionHeader { name } => Some(name.clone()),
_ => None,
})
.collect();
assert!(headers.iter().any(|h| h == "General"));
assert!(headers.iter().any(|h| h == "Expand"));
assert!(headers.iter().any(|h| h == "Logging"));
assert!(headers.iter().any(|h| h.starts_with("Model Defaults")));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_rows_contain_read_only_paths() {
let app = make_settings_test_app();
let rows = app.build_settings_rows();
let has_ro = rows.iter().any(|r| {
matches!(
r,
SettingsRow::Field {
key: SettingsKey::ModelTransformer,
field_type: SettingsFieldType::ReadOnly,
..
}
)
});
assert!(has_ro, "ModelTransformer should be ReadOnly");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_increment_via_row_index_adjusts_width() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::DefaultWidth);
app.settings.row_index = idx;
app.settings_increment(1);
assert_eq!(app.config.default_width, 832); }
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_increment_via_row_index_toggles_bool() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::EmbedMetadata);
app.settings.row_index = idx;
assert!(app.config.embed_metadata);
app.settings_increment(1);
assert!(!app.config.embed_metadata);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_increment_via_row_index_cycles_toggle() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::LogLevel);
app.settings.row_index = idx;
assert_eq!(app.config.logging.level, "info");
app.settings_increment(1);
assert_eq!(app.config.logging.level, "warn");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_confirm_opens_popup_for_text_field() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::DefaultModel);
app.settings.row_index = idx;
app.settings_confirm();
assert!(matches!(app.popup, Some(Popup::SettingsInput { .. })));
if let Some(Popup::SettingsInput { key, input, .. }) = &app.popup {
assert_eq!(*key, SettingsKey::DefaultModel);
assert_eq!(input, "flux2-klein:q8");
}
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_confirm_toggles_bool_field() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::ExpandEnabled);
app.settings.row_index = idx;
assert!(!app.config.expand.enabled);
app.settings_confirm();
assert!(app.config.expand.enabled);
assert!(app.popup.is_none()); }
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn settings_confirm_cycles_toggle_field() {
let mut app = make_settings_test_app();
let idx = find_settings_row(&app, SettingsKey::T5Variant);
app.settings.row_index = idx;
app.settings_confirm();
assert_eq!(app.config.t5_variant, Some("fp16".into()));
assert!(app.popup.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn generation_complete_metadata_uses_response_model() {
let mut app = make_settings_test_app();
app.active_view = View::Generate;
app.generate.generating = true;
app.generate.batch_remaining = 1;
app.generate.params.model = "flux-dev:q4".to_string();
app.generate.prompt = TextArea::from(["a test prompt"]);
let response = GenerateResponse {
images: vec![mold_core::ImageData {
data: vec![0u8; 4],
format: OutputFormat::Png,
width: 64,
height: 64,
index: 0,
}],
generation_time_ms: 100,
model: "flux-schnell:q8".to_string(),
seed_used: 42,
video: None,
gpu: None,
};
app.bg_tx
.send(BackgroundEvent::GenerationComplete {
response: Box::new(response),
from_local: false,
})
.unwrap();
app.process_background_events();
assert!(!app.history.is_empty());
let results = app.history.search("a test prompt");
assert!(!results.is_empty(), "history should contain our prompt");
assert_eq!(
results[0].model, "flux-schnell:q8",
"history should record response model, not UI model"
);
if let Some(entry) = app.gallery.entries.first() {
assert_eq!(
entry.metadata.model, "flux-schnell:q8",
"gallery metadata should record response model, not UI model"
);
}
}
#[test]
fn batch_remaining_decrements_on_generation_complete() {
let mut gen = GenerateState {
prompt: TextArea::default(),
negative_prompt: TextArea::default(),
params: GenerateParams::from_config(&Config::load_or_default()),
focus: GenerateFocus::Prompt,
param_index: 0,
visible_fields: vec![],
capabilities: capabilities_for_family("flux"),
progress: ProgressState::default(),
preview_image: None,
image_state: None,
animation: None,
generating: true,
batch_remaining: 3,
last_seed: None,
last_generation_time_ms: None,
error_message: None,
model_description: String::new(),
negative_collapsed: false,
};
gen.batch_remaining = gen.batch_remaining.saturating_sub(1);
assert_eq!(gen.batch_remaining, 2);
if gen.batch_remaining == 0 {
gen.generating = false;
}
assert!(
gen.generating,
"should still be generating with 2 images left"
);
gen.batch_remaining = gen.batch_remaining.saturating_sub(1);
assert_eq!(gen.batch_remaining, 1);
if gen.batch_remaining == 0 {
gen.generating = false;
}
assert!(
gen.generating,
"should still be generating with 1 image left"
);
gen.batch_remaining = gen.batch_remaining.saturating_sub(1);
assert_eq!(gen.batch_remaining, 0);
if gen.batch_remaining == 0 {
gen.generating = false;
}
assert!(
!gen.generating,
"should stop generating when batch is complete"
);
}
#[test]
fn batch_remaining_resets_on_error() {
let mut gen = GenerateState {
prompt: TextArea::default(),
negative_prompt: TextArea::default(),
params: GenerateParams::from_config(&Config::load_or_default()),
focus: GenerateFocus::Prompt,
param_index: 0,
visible_fields: vec![],
capabilities: capabilities_for_family("flux"),
progress: ProgressState::default(),
preview_image: None,
image_state: None,
animation: None,
generating: true,
batch_remaining: 4,
last_seed: None,
last_generation_time_ms: None,
error_message: None,
model_description: String::new(),
negative_collapsed: false,
};
gen.generating = false;
gen.batch_remaining = 0;
gen.error_message = Some("connection lost".to_string());
assert!(!gen.generating);
assert_eq!(gen.batch_remaining, 0);
assert!(gen.error_message.is_some());
}
#[test]
fn start_generation_sets_batch_remaining() {
let config = Config::load_or_default();
let params = GenerateParams::from_config(&config);
assert_eq!(params.batch, 1);
let mut gen = GenerateState {
prompt: TextArea::default(),
negative_prompt: TextArea::default(),
params,
focus: GenerateFocus::Prompt,
param_index: 0,
visible_fields: vec![],
capabilities: capabilities_for_family("flux"),
progress: ProgressState::default(),
preview_image: None,
image_state: None,
animation: None,
generating: false,
batch_remaining: 0,
last_seed: None,
last_generation_time_ms: None,
error_message: None,
model_description: String::new(),
negative_collapsed: false,
};
gen.params.batch = 4;
gen.generating = true;
gen.batch_remaining = gen.params.batch;
assert_eq!(gen.batch_remaining, 4);
assert!(gen.generating);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn batch_increment_no_upper_cap() {
let mut app = make_settings_test_app();
app.active_view = View::Generate;
app.generate.focus = GenerateFocus::Parameters;
let batch_idx = app
.generate
.visible_fields
.iter()
.position(|f| *f == ParamField::Batch)
.expect("Batch field should be in visible_fields");
app.generate.param_index = batch_idx;
app.generate.params.batch = 16;
app.increment_param(1);
assert_eq!(
app.generate.params.batch, 17,
"batch should exceed old cap of 16"
);
app.generate.params.batch = 100;
app.increment_param(1);
assert_eq!(
app.generate.params.batch, 101,
"batch should have no upper bound"
);
app.generate.params.batch = 1;
app.increment_param(-1);
assert_eq!(app.generate.params.batch, 1, "batch should not go below 1");
}
#[test]
fn available_upscaler_models_returns_all_known() {
let models: Vec<String> = mold_core::manifest::known_manifests()
.iter()
.filter(|m| m.is_upscaler())
.map(|m| m.name.clone())
.collect();
assert_eq!(models.len(), 7);
assert!(models.iter().all(|n| !n.is_empty()));
}
#[test]
fn upscale_model_selector_popup_variant() {
let popup = Popup::UpscaleModelSelector {
filter: String::new(),
selected: 0,
filtered: vec![
"real-esrgan-x4plus:fp16".into(),
"real-esrgan-x2:fp16".into(),
],
};
if let Popup::UpscaleModelSelector {
filter,
selected,
filtered,
} = &popup
{
assert!(filter.is_empty());
assert_eq!(*selected, 0);
assert_eq!(filtered.len(), 2);
} else {
panic!("expected UpscaleModelSelector");
}
}
#[test]
fn upscale_background_event_variants() {
let progress = BackgroundEvent::UpscaleProgress { tile: 3, total: 9 };
if let BackgroundEvent::UpscaleProgress { tile, total } = progress {
assert_eq!(tile, 3);
assert_eq!(total, 9);
} else {
panic!("expected UpscaleProgress");
}
let complete = BackgroundEvent::UpscaleComplete {
image_data: vec![0u8; 100],
source_path: std::path::PathBuf::from("/tmp/test.png"),
model: "real-esrgan-x4plus:fp16".into(),
scale_factor: 4,
original_width: 512,
original_height: 512,
upscale_time_ms: 1500,
};
if let BackgroundEvent::UpscaleComplete {
scale_factor,
original_width,
original_height,
..
} = complete
{
assert_eq!(scale_factor, 4);
assert_eq!(original_width, 512);
assert_eq!(original_height, 512);
} else {
panic!("expected UpscaleComplete");
}
let failed = BackgroundEvent::UpscaleFailed("OOM".into());
if let BackgroundEvent::UpscaleFailed(msg) = failed {
assert_eq!(msg, "OOM");
} else {
panic!("expected UpscaleFailed");
}
}
#[test]
fn upscale_model_filter_narrows_list() {
let all = vec![
"real-esrgan-x4plus:fp16".to_string(),
"real-esrgan-x2:fp16".to_string(),
"realesrgan-anime:fp16".to_string(),
];
let query = "x4".to_lowercase();
let filtered: Vec<String> = all
.into_iter()
.filter(|name| name.to_lowercase().contains(&query))
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0], "real-esrgan-x4plus:fp16");
}
#[test]
fn upscale_model_filter_empty_returns_all() {
let all = vec![
"real-esrgan-x4plus:fp16".to_string(),
"real-esrgan-x2:fp16".to_string(),
];
let query = "".to_lowercase();
let filtered: Vec<String> = all
.into_iter()
.filter(|name| name.to_lowercase().contains(&query))
.collect();
assert_eq!(filtered.len(), 2);
}
#[test]
fn model_list_sorts_downloaded_first() {
let config = Config::default();
let mut models = [
"not-downloaded-model:q8".to_string(),
"also-not-downloaded:fp16".to_string(),
];
models.sort_by_key(|name| {
let resolved = mold_core::manifest::resolve_model_name(name);
let downloaded =
config.models.contains_key(&resolved) || config.manifest_model_is_downloaded(name);
if downloaded {
0
} else {
1
}
});
assert_eq!(models[0], "not-downloaded-model:q8");
assert_eq!(models[1], "also-not-downloaded:fp16");
}
#[test]
fn model_list_downloaded_sorts_before_undownloaded() {
let mut config = Config::default();
config.models.insert(
"second-model:fp16".to_string(),
mold_core::config::ModelConfig {
transformer: Some("/fake/path.safetensors".into()),
..Default::default()
},
);
let mut models = [
"first-model:q8".to_string(),
"second-model:fp16".to_string(),
"third-model:q4".to_string(),
];
models.sort_by_key(|name| {
let resolved = mold_core::manifest::resolve_model_name(name);
let downloaded =
config.models.contains_key(&resolved) || config.manifest_model_is_downloaded(name);
if downloaded {
0
} else {
1
}
});
assert_eq!(models[0], "second-model:fp16");
assert_eq!(models[1], "first-model:q8");
assert_eq!(models[2], "third-model:q4");
}
#[test]
fn default_model_resolution_for_selector() {
let config = Config::default();
let default = mold_core::manifest::resolve_model_name(&config.resolved_default_model());
assert!(!default.is_empty());
}
#[test]
fn upscale_download_progress_event_variant() {
let event = BackgroundEvent::UpscaleDownloadProgress(SseProgressEvent::DownloadProgress {
filename: "weights.safetensors".into(),
file_index: 0,
total_files: 1,
bytes_downloaded: 50_000_000,
bytes_total: 100_000_000,
batch_bytes_downloaded: 50_000_000,
batch_bytes_total: 100_000_000,
batch_elapsed_ms: 5_000,
});
if let BackgroundEvent::UpscaleDownloadProgress(SseProgressEvent::DownloadProgress {
filename,
bytes_downloaded,
bytes_total,
..
}) = event
{
assert_eq!(filename, "weights.safetensors");
assert_eq!(bytes_downloaded, 50_000_000);
assert_eq!(bytes_total, 100_000_000);
} else {
panic!("expected UpscaleDownloadProgress(DownloadProgress)");
}
}
#[test]
fn upscale_progress_state_tracks_download() {
let mut progress = ProgressState::default();
assert!(!progress.is_downloading());
reduce_progress_state(
&mut progress,
SseProgressEvent::Info {
message: "Model 'real-esrgan-x4plus:fp16' not found locally, pulling...".into(),
},
);
assert!(progress.is_downloading());
reduce_progress_state(
&mut progress,
SseProgressEvent::DownloadProgress {
filename: "RealESRGAN_x4plus.pth".into(),
file_index: 0,
total_files: 1,
bytes_downloaded: 30_000_000,
bytes_total: 67_000_000,
batch_bytes_downloaded: 30_000_000,
batch_bytes_total: 67_000_000,
batch_elapsed_ms: 3_000,
},
);
assert!(progress.is_downloading());
assert_eq!(progress.download_batch_bytes, 30_000_000);
assert_eq!(progress.download_batch_total, 67_000_000);
assert_eq!(progress.download_filename, "RealESRGAN_x4plus.pth");
assert_eq!(progress.download_total_files, 1);
}
#[test]
fn upscale_progress_transitions_download_to_tiles() {
let mut progress = ProgressState::default();
reduce_progress_state(
&mut progress,
SseProgressEvent::DownloadProgress {
filename: "RealESRGAN_x4plus.pth".into(),
file_index: 0,
total_files: 1,
bytes_downloaded: 67_000_000,
bytes_total: 67_000_000,
batch_bytes_downloaded: 67_000_000,
batch_bytes_total: 67_000_000,
batch_elapsed_ms: 6_000,
},
);
assert!(progress.is_downloading());
reduce_progress_state(
&mut progress,
SseProgressEvent::DownloadDone {
filename: "RealESRGAN_x4plus.pth".into(),
file_index: 0,
total_files: 1,
batch_bytes_downloaded: 67_000_000,
batch_bytes_total: 67_000_000,
batch_elapsed_ms: 6_000,
},
);
reduce_progress_state(
&mut progress,
SseProgressEvent::PullComplete {
model: "real-esrgan-x4plus:fp16".into(),
},
);
assert!(!progress.is_downloading());
assert_eq!(progress.download_batch_bytes, 0);
assert_eq!(progress.denoise_step, 0);
}
#[test]
fn upscale_progress_cleared_on_completion() {
let mut progress = ProgressState::default();
reduce_progress_state(
&mut progress,
SseProgressEvent::DownloadProgress {
filename: "model.pth".into(),
file_index: 0,
total_files: 1,
bytes_downloaded: 10_000,
bytes_total: 20_000,
batch_bytes_downloaded: 10_000,
batch_bytes_total: 20_000,
batch_elapsed_ms: 1_000,
},
);
assert!(progress.is_downloading());
progress.clear();
assert!(!progress.is_downloading());
assert_eq!(progress.download_batch_bytes, 0);
assert_eq!(progress.download_batch_total, 0);
assert!(progress.download_filename.is_empty());
}
#[test]
fn model_selector_excludes_upscalers() {
let catalog = mold_core::build_model_catalog(&Config::default(), None, false);
let generation_models: Vec<String> = catalog
.iter()
.filter(|m| m.is_generation_model())
.map(|m| m.name.clone())
.collect();
for name in &generation_models {
assert!(
!name.starts_with("real-esrgan"),
"model selector should not include upscaler model '{name}'"
);
}
assert!(
!generation_models.is_empty(),
"should have generation models after filtering"
);
}
#[test]
fn model_selector_excludes_utility_models() {
let catalog = mold_core::build_model_catalog(&Config::default(), None, false);
let generation_models: Vec<String> = catalog
.iter()
.filter(|m| m.is_generation_model())
.map(|m| m.name.clone())
.collect();
for name in &generation_models {
assert!(
!name.starts_with("qwen3-expand"),
"model selector should not include utility model '{name}'"
);
}
}
#[test]
fn full_catalog_still_includes_upscalers_and_utility() {
let catalog = mold_core::build_model_catalog(&Config::default(), None, false);
assert!(
catalog.iter().any(|m| m.is_upscaler()),
"full catalog should include upscaler models"
);
}
fn make_test_catalog_entry(
name: &str,
steps: u32,
guidance: f64,
width: u32,
height: u32,
desc: &str,
) -> ModelInfoExtended {
ModelInfoExtended {
info: mold_core::ModelInfo {
name: name.to_string(),
family: "flux".to_string(),
size_gb: 4.5,
is_loaded: false,
last_used: None,
hf_repo: "test/repo".to_string(),
},
defaults: mold_core::ModelDefaults {
default_steps: steps,
default_guidance: guidance,
default_width: width,
default_height: height,
description: desc.to_string(),
},
downloaded: true,
disk_usage_bytes: None,
remaining_download_bytes: None,
}
}
#[test]
fn remote_catalog_defaults_applied_to_matching_model() {
let mut params = GenerateParams::from_config(&Config::load_or_default());
params.model = "flux-dev:q4".to_string();
params.steps = 1;
let catalog = [make_test_catalog_entry(
"flux-dev:q4",
20,
3.5,
1024,
1024,
"FLUX Dev Q4 GGUF",
)];
if let Some(entry) = catalog.iter().find(|m| m.name == params.model) {
params.steps = entry.defaults.default_steps;
params.guidance = entry.defaults.default_guidance;
params.width = entry.defaults.default_width;
params.height = entry.defaults.default_height;
}
assert_eq!(params.steps, 20);
assert!((params.guidance - 3.5).abs() < f64::EPSILON);
assert_eq!(params.width, 1024);
assert_eq!(params.height, 1024);
}
#[test]
fn remote_catalog_defaults_no_match_is_noop() {
let mut params = GenerateParams::from_config(&Config::load_or_default());
let original_steps = params.steps;
params.model = "nonexistent-model".to_string();
let catalog = [make_test_catalog_entry(
"flux-dev:q4",
99,
9.9,
512,
512,
"test",
)];
if let Some(entry) = catalog.iter().find(|m| m.name == params.model) {
params.steps = entry.defaults.default_steps;
}
assert_eq!(
params.steps, original_steps,
"should not change for non-matching model"
);
}
#[test]
fn server_status_update_populates_resource_info() {
let mut ri = crate::ui::info::ResourceInfo::default();
let status = mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec!["flux-dev:q4".to_string()],
busy: true,
current_generation: None,
gpu_info: Some(mold_core::GpuInfo {
name: "RTX 4090".to_string(),
vram_total_mb: 24564,
vram_used_mb: 8192,
}),
uptime_secs: 3600,
hostname: Some("hal9000".to_string()),
memory_status: Some("VRAM: 16.0 GB free".to_string()),
gpus: None,
queue_depth: None,
queue_capacity: None,
};
ri.update_from_server_status(status);
assert_eq!(ri.memory_line.as_deref(), Some("VRAM: 16.0 GB free"));
assert_eq!(ri.process_memory_mb, 0);
let ss = ri.server_status.as_ref().unwrap();
assert_eq!(ss.hostname.as_deref(), Some("hal9000"));
assert!(ss.busy);
assert_eq!(ss.gpu_info.as_ref().unwrap().name, "RTX 4090");
}
#[test]
fn clear_server_status_reverts_to_local() {
let mut ri = crate::ui::info::ResourceInfo {
server_status: Some(mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec![],
busy: false,
current_generation: None,
gpu_info: None,
uptime_secs: 0,
hostname: Some("remote".to_string()),
memory_status: Some("VRAM: 16.0 GB free".to_string()),
gpus: None,
queue_depth: None,
queue_capacity: None,
}),
..Default::default()
};
ri.clear_server_status();
assert!(ri.server_status.is_none());
ri.refresh_local();
}
#[test]
fn background_event_server_status_variant_exists() {
let status = mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec![],
busy: false,
current_generation: None,
gpu_info: None,
uptime_secs: 0,
hostname: None,
memory_status: None,
gpus: None,
queue_depth: None,
queue_capacity: None,
};
let _event = BackgroundEvent::ServerStatusUpdate(Some(Box::new(status)));
let _event_none = BackgroundEvent::ServerStatusUpdate(None);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_poll_remote_true_when_server_and_auto() {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Auto;
assert!(app.should_poll_remote());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_poll_remote_true_when_server_and_remote() {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Remote;
assert!(app.should_poll_remote());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_poll_remote_false_when_server_but_local_mode() {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Local;
assert!(
!app.should_poll_remote(),
"local mode must not poll remote even with server_url set"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_poll_remote_false_when_no_server() {
let mut app = make_settings_test_app();
app.server_url = None;
app.generate.params.inference_mode = InferenceMode::Auto;
assert!(!app.should_poll_remote());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn update_model_uses_server_catalog_when_connected() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.models.catalog = vec![make_test_catalog_entry(
"flux-dev:q4",
28,
4.0,
768,
768,
"Server FLUX Dev Q4",
)];
app.update_model("flux-dev:q4");
assert_eq!(app.generate.params.steps, 28);
assert!((app.generate.params.guidance - 4.0).abs() < f64::EPSILON);
assert_eq!(app.generate.params.width, 768);
assert_eq!(app.generate.params.height, 768);
assert_eq!(app.generate.model_description, "Server FLUX Dev Q4");
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn update_model_falls_back_to_local_when_model_not_in_catalog() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.models.catalog = vec![make_test_catalog_entry(
"flux-schnell:q8",
199,
99.9,
256,
256,
"Schnell",
)];
let model = app.config.resolved_default_model();
app.update_model(&model);
assert_ne!(app.generate.params.steps, 199);
assert_ne!(app.generate.params.width, 256);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_save_output_locally_false_when_connected_to_remote_server() {
let mut app = make_settings_test_app();
app.server_url = Some("http://remote.example:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Remote;
assert!(!app.should_save_output_locally());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_save_output_locally_true_when_no_server() {
let mut app = make_settings_test_app();
app.server_url = None;
app.generate.params.inference_mode = InferenceMode::Local;
assert!(app.should_save_output_locally());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_save_output_locally_true_when_forced_local_even_with_server_url() {
let mut app = make_settings_test_app();
app.server_url = Some("http://remote.example:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Local;
assert!(app.should_save_output_locally());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_persist_response_locally_true_for_auto_mode_local_fallback() {
let mut app = make_settings_test_app();
app.server_url = Some("http://remote.example:7680".to_string());
app.generate.params.inference_mode = InferenceMode::Auto;
assert!(
!app.should_save_output_locally(),
"precondition: in Auto+connected mode the generic predicate treats this as remote"
);
assert!(
app.should_persist_response_locally(true),
"Auto-mode fallback response must still be saved locally"
);
assert!(
!app.should_persist_response_locally(false),
"genuine remote success must still skip the local write to avoid duplicates"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn should_persist_response_locally_respects_output_disabled() {
let mut app = make_settings_test_app();
app.server_url = None;
app.generate.params.inference_mode = InferenceMode::Local;
app.config.output_dir = Some(String::new()); assert!(app.config.is_output_disabled());
assert!(
!app.should_persist_response_locally(true),
"output disabled wins over from_local — user explicitly opted out of saving"
);
}
fn add_temp_gallery_entry(app: &mut App, name_prefix: &str) -> std::path::PathBuf {
let tmp = std::env::temp_dir().join(format!("mold-delete-test-{name_prefix}"));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let path = tmp.join(format!("{name_prefix}.png"));
std::fs::write(&path, b"fake-png-bytes-for-test").unwrap();
app.gallery.entries.push(GalleryEntry {
path: path.clone(),
metadata: make_test_metadata(),
generation_time_ms: None,
timestamp: 0,
server_url: None,
});
app.gallery.thumbnail_states.push(None);
app.gallery.thumb_dimensions.push(None);
app.gallery.thumb_fixed_cache.push(None);
app.gallery.selected = app.gallery.entries.len() - 1;
path
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_selected_gallery_image_empty_gallery_is_noop() {
let mut app = make_settings_test_app();
app.gallery.entries.clear();
app.delete_selected_gallery_image();
assert!(app.gallery.entries.is_empty());
assert_eq!(app.gallery.selected, 0);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_selected_gallery_image_out_of_bounds_index_is_noop() {
let mut app = make_settings_test_app();
add_temp_gallery_entry(&mut app, "oob");
app.gallery.selected = 999;
app.delete_selected_gallery_image();
assert_eq!(app.gallery.entries.len(), 1);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_selected_gallery_image_removes_local_file_from_disk() {
let mut app = make_settings_test_app();
let path = add_temp_gallery_entry(&mut app, "local-file");
assert!(path.exists(), "precondition: file exists before delete");
app.delete_selected_gallery_image();
assert!(!path.exists(), "file should be deleted from disk");
assert!(app.gallery.entries.is_empty());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_selected_gallery_image_removes_thumbnail_from_disk() {
let mut app = make_settings_test_app();
let path = add_temp_gallery_entry(&mut app, "thumb");
let thumb_path = crate::thumbnails::thumbnail_path(&path);
if let Some(parent) = thumb_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&thumb_path, b"thumb-bytes").unwrap();
assert!(thumb_path.exists());
app.delete_selected_gallery_image();
assert!(
!thumb_path.exists(),
"thumbnail should be deleted from disk"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_in_detail_view_advances_to_next_image_with_preview_loaded() {
use image::ImageEncoder;
let tmp = std::env::temp_dir().join(format!(
"mold-detail-delete-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
fn write_real_png(path: &std::path::Path, color: [u8; 3]) {
let pixels: Vec<u8> = (0..16 * 16).flat_map(|_| color.iter().copied()).collect();
let f = std::fs::File::create(path).unwrap();
let encoder = image::codecs::png::PngEncoder::new(f);
encoder
.write_image(&pixels, 16, 16, image::ExtendedColorType::Rgb8)
.unwrap();
}
let a_path = tmp.join("a.png");
let b_path = tmp.join("b.png");
write_real_png(&a_path, [255, 0, 0]);
write_real_png(&b_path, [0, 255, 0]);
let mut app = make_settings_test_app();
for path in [&a_path, &b_path] {
app.gallery.entries.push(GalleryEntry {
path: path.clone(),
metadata: make_test_metadata(),
generation_time_ms: None,
timestamp: 0,
server_url: None,
});
app.gallery.thumbnail_states.push(None);
app.gallery.thumb_dimensions.push(None);
app.gallery.thumb_fixed_cache.push(None);
}
app.gallery.selected = 0;
app.gallery.view_mode = GalleryViewMode::Detail;
app.delete_selected_gallery_image();
assert_eq!(
app.gallery.entries.len(),
1,
"one entry should remain after delete"
);
assert_eq!(
app.gallery.view_mode,
GalleryViewMode::Detail,
"Detail view should persist when there is still an image to show"
);
assert!(
app.gallery.preview_image.is_some(),
"preview_image must be loaded for the new selection — \
previously the code cleared it right after load_gallery_preview"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_last_entry_in_detail_view_returns_to_grid() {
let mut app = make_settings_test_app();
let _path = add_temp_gallery_entry(&mut app, "lone-entry");
app.gallery.view_mode = GalleryViewMode::Detail;
app.delete_selected_gallery_image();
assert!(app.gallery.entries.is_empty());
assert_eq!(app.gallery.view_mode, GalleryViewMode::Grid);
assert!(app.gallery.preview_image.is_none());
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn delete_selected_gallery_image_shrinks_parallel_arrays_in_lockstep() {
let mut app = make_settings_test_app();
add_temp_gallery_entry(&mut app, "lockstep-a");
add_temp_gallery_entry(&mut app, "lockstep-b");
app.gallery.selected = 0;
app.delete_selected_gallery_image();
assert_eq!(app.gallery.entries.len(), 1);
assert_eq!(app.gallery.thumbnail_states.len(), 1);
assert_eq!(app.gallery.thumb_dimensions.len(), 1);
assert_eq!(app.gallery.thumb_fixed_cache.len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_selected_gallery_image_server_entry_emits_failure_on_api_error() {
let mut app = make_settings_test_app();
let server = "http://127.0.0.1:1".to_string();
app.server_url = Some(server.clone());
app.gallery.entries.push(GalleryEntry {
path: std::path::PathBuf::from("mold-server-entry.png"),
metadata: make_test_metadata(),
generation_time_ms: None,
timestamp: 0,
server_url: Some(server),
});
app.gallery.thumbnail_states.push(None);
app.gallery.thumb_dimensions.push(None);
app.gallery.thumb_fixed_cache.push(None);
app.gallery.selected = 0;
app.delete_selected_gallery_image();
let ev = tokio::time::timeout(std::time::Duration::from_secs(5), app.bg_rx.recv())
.await
.expect("delete should emit a background event within 5s")
.expect("channel was closed");
assert!(
matches!(ev, BackgroundEvent::GalleryDeleteFailed(_)),
"expected GalleryDeleteFailed; the API delete must not be fire-and-forget"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_delete_failure_surfaces_error_and_rescans() {
let mut app = make_settings_test_app();
app.server_url = Some("http://server.example:7680".to_string());
app.generate.error_message = None;
app.gallery.scanning = false;
app.apply_delete_failure("forbidden");
let msg = app.generate.error_message.clone().unwrap_or_default();
assert!(
msg.to_lowercase().contains("delete") && msg.to_lowercase().contains("forbidden"),
"error_message should mention delete + the server's reason, got: {msg:?}"
);
assert!(
app.gallery.scanning,
"delete failure should trigger a gallery rescan"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_gallery_scan_preserves_selection_by_filename() {
let mut app = make_settings_test_app();
app.gallery.entries = vec![
make_test_entry_with_name("a.png"),
make_test_entry_with_name("b.png"),
make_test_entry_with_name("c.png"),
];
app.gallery.thumbnail_states = vec![None; 3];
app.gallery.thumb_dimensions = vec![None; 3];
app.gallery.thumb_fixed_cache = vec![None; 3];
app.gallery.selected = 1;
let new_entries = vec![
make_test_entry_with_name("a.png"),
make_test_entry_with_name("c.png"),
make_test_entry_with_name("b.png"), ];
app.apply_gallery_scan(new_entries);
assert_eq!(
app.gallery.selected, 2,
"selected should follow b.png to its new index, not reset to 0"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_gallery_scan_clamps_when_previous_filename_is_gone() {
let mut app = make_settings_test_app();
app.gallery.entries = vec![
make_test_entry_with_name("a.png"),
make_test_entry_with_name("b.png"),
make_test_entry_with_name("c.png"),
];
app.gallery.thumbnail_states = vec![None; 3];
app.gallery.thumb_dimensions = vec![None; 3];
app.gallery.thumb_fixed_cache = vec![None; 3];
app.gallery.selected = 1;
let new_entries = vec![
make_test_entry_with_name("a.png"),
make_test_entry_with_name("c.png"),
];
app.apply_gallery_scan(new_entries);
assert_eq!(app.gallery.selected, 1);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_gallery_scan_empty_list_resets_selected() {
let mut app = make_settings_test_app();
app.gallery.entries = vec![make_test_entry_with_name("a.png")];
app.gallery.thumbnail_states = vec![None];
app.gallery.thumb_dimensions = vec![None];
app.gallery.thumb_fixed_cache = vec![None];
app.gallery.selected = 0;
app.apply_gallery_scan(Vec::new());
assert_eq!(app.gallery.selected, 0);
assert!(app.gallery.entries.is_empty());
}
fn make_test_entry_with_name(filename: &str) -> GalleryEntry {
GalleryEntry {
path: std::path::PathBuf::from(filename),
metadata: make_test_metadata(),
generation_time_ms: None,
timestamp: 0,
server_url: None,
}
}
#[test]
fn background_server_command_passes_serve_args() {
let mut cmd = std::process::Command::new("mold");
super::configure_background_server_command(&mut cmd, 7680);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.contains(&"serve".to_string()) && args.contains(&"7680".to_string()),
"serve subcommand and port must still be passed: {args:?}"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_theme_preset_persists_immediately() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.apply_theme_preset(crate::ui::theme::ThemePreset::Dracula);
let loaded = crate::session::TuiSession::load();
assert_eq!(
loaded.theme.as_deref(),
Some("dracula"),
"apply_theme_preset should have persisted the theme immediately"
);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn theme_save_then_load_round_trip_preserves_preset() {
crate::test_env::with_isolated_env(|_home| {
for preset in [
crate::ui::theme::ThemePreset::Mocha,
crate::ui::theme::ThemePreset::Latte,
crate::ui::theme::ThemePreset::Ristretto,
crate::ui::theme::ThemePreset::Gruvbox,
crate::ui::theme::ThemePreset::Tokyo,
crate::ui::theme::ThemePreset::Nord,
crate::ui::theme::ThemePreset::Dracula,
] {
let mut app = make_settings_test_app();
app.apply_theme_preset(preset);
let loaded = crate::session::TuiSession::load();
let parsed = loaded
.theme
.as_deref()
.map(crate::ui::theme::ThemePreset::from_slug)
.unwrap_or_default();
assert_eq!(
parsed, preset,
"preset {preset:?} did not round-trip via TuiSession::load (got {parsed:?})"
);
}
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn update_model_restores_per_model_saved_params() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.config.models.insert(
"flux-dev:q4".to_string(),
mold_core::config::ModelConfig {
default_steps: Some(20),
default_guidance: Some(3.5),
default_width: Some(1024),
default_height: Some(1024),
..Default::default()
},
);
app.config.models.insert(
"sdxl:fp16".to_string(),
mold_core::config::ModelConfig {
default_steps: Some(30),
default_guidance: Some(7.5),
default_width: Some(768),
default_height: Some(768),
..Default::default()
},
);
app.update_model("flux-dev:q4");
app.generate.params.width = 1024;
app.generate.params.height = 1024;
app.generate.params.steps = 20;
app.generate.params.guidance = 3.5;
app.update_model("sdxl:fp16");
assert_eq!(app.generate.params.width, 768);
assert_eq!(app.generate.params.steps, 30);
assert_eq!(app.generate.params.guidance, 7.5);
app.generate.params.width = 512;
app.generate.params.steps = 15;
app.update_model("flux-dev:q4");
assert_eq!(app.generate.params.width, 1024);
assert_eq!(app.generate.params.steps, 20);
assert!((app.generate.params.guidance - 3.5).abs() < 1e-9);
app.update_model("sdxl:fp16");
assert_eq!(app.generate.params.width, 512);
assert_eq!(app.generate.params.steps, 15);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn update_model_to_same_model_is_noop() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.config.models.insert(
"flux-dev:q4".to_string(),
mold_core::config::ModelConfig::default(),
);
app.update_model("flux-dev:q4");
app.generate.params.width = 777;
app.update_model("flux-dev:q4");
assert_eq!(app.generate.params.width, 777);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn theme_default_is_mocha_when_session_is_missing() {
crate::test_env::with_isolated_env(|_home| {
let loaded = crate::session::TuiSession::load();
let resolved = loaded
.theme
.as_deref()
.map(crate::ui::theme::ThemePreset::from_slug)
.unwrap_or_default();
assert_eq!(
resolved,
crate::ui::theme::ThemePreset::Mocha,
"missing session must resolve to Mocha, never Latte"
);
assert!(
loaded.theme.is_none(),
"no theme key should be present in a fresh session"
);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn theme_default_is_mocha_when_slug_is_unknown_or_empty() {
assert_eq!(
crate::ui::theme::ThemePreset::from_slug(""),
crate::ui::theme::ThemePreset::Mocha
);
assert_eq!(
crate::ui::theme::ThemePreset::from_slug("not-a-real-theme"),
crate::ui::theme::ThemePreset::Mocha
);
assert_eq!(
crate::ui::theme::ThemePreset::default(),
crate::ui::theme::ThemePreset::Mocha
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_theme_preset_persists_across_multiple_changes() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.apply_theme_preset(crate::ui::theme::ThemePreset::Dracula);
app.apply_theme_preset(crate::ui::theme::ThemePreset::Nord);
app.apply_theme_preset(crate::ui::theme::ThemePreset::Gruvbox);
let loaded = crate::session::TuiSession::load();
assert_eq!(
loaded.theme.as_deref(),
Some("gruvbox"),
"latest theme (gruvbox) should be the persisted slug"
);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_delete_failure_no_rescan_when_not_connected_to_server() {
let mut app = make_settings_test_app();
app.server_url = None;
app.apply_delete_failure("permission denied");
assert!(app.generate.error_message.is_some());
assert!(
!app.gallery.scanning,
"no rescan should be kicked off when there is no server to rescan from"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn update_model_uses_local_config_when_no_server() {
crate::test_env::with_isolated_env(|_home| {
let mut app = make_settings_test_app();
app.server_url = None;
let model = app.config.resolved_default_model();
app.update_model(&model);
assert!(app.generate.params.steps > 0);
assert!(app.generate.params.width > 0);
});
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_remote_model_defaults_updates_all_fields() {
let mut app = make_settings_test_app();
app.generate.params.model = "flux-dev:q4".to_string();
app.generate.params.steps = 1;
app.generate.params.guidance = 0.0;
app.generate.params.width = 64;
app.generate.params.height = 64;
let catalog = vec![make_test_catalog_entry(
"flux-dev:q4",
20,
3.5,
1024,
1024,
"FLUX Dev Q4",
)];
app.apply_remote_model_defaults(&catalog);
assert_eq!(app.generate.params.steps, 20);
assert!((app.generate.params.guidance - 3.5).abs() < f64::EPSILON);
assert_eq!(app.generate.params.width, 1024);
assert_eq!(app.generate.params.height, 1024);
assert_eq!(app.generate.model_description, "FLUX Dev Q4");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_remote_model_defaults_skips_empty_description() {
let mut app = make_settings_test_app();
app.generate.params.model = "flux-dev:q4".to_string();
app.generate.model_description = "Original description".to_string();
let catalog = vec![make_test_catalog_entry(
"flux-dev:q4",
20,
3.5,
1024,
1024,
"", )];
app.apply_remote_model_defaults(&catalog);
assert_eq!(app.generate.model_description, "Original description");
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn apply_remote_model_defaults_no_match_leaves_params_unchanged() {
let mut app = make_settings_test_app();
app.generate.params.model = "nonexistent:q4".to_string();
app.generate.params.steps = 42;
let catalog = vec![make_test_catalog_entry(
"flux-dev:q4",
20,
3.5,
1024,
1024,
"FLUX",
)];
app.apply_remote_model_defaults(&catalog);
assert_eq!(
app.generate.params.steps, 42,
"should not change for non-matching model"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn reset_defaults_uses_server_catalog_when_connected() {
let mut app = make_settings_test_app();
app.server_url = Some("http://hal9000:7680".to_string());
app.generate.params.model = "flux-dev:q4".to_string();
app.models.catalog = vec![make_test_catalog_entry(
"flux-dev:q4",
30,
7.0,
512,
512,
"Server Flux",
)];
app.generate.params.steps = 1;
app.generate.params.width = 9999;
app.generate.params.batch = 5;
app.generate.params.format = OutputFormat::Jpeg;
app.active_view = View::Generate;
app.generate.focus = GenerateFocus::Parameters;
let reset_idx = app
.generate
.visible_fields
.iter()
.position(|f| *f == ParamField::ResetDefaults)
.unwrap();
app.generate.param_index = reset_idx;
app.activate_current_param();
assert_eq!(app.generate.params.steps, 30);
assert!((app.generate.params.guidance - 7.0).abs() < f64::EPSILON);
assert_eq!(app.generate.params.width, 512);
assert_eq!(app.generate.params.height, 512);
assert_eq!(app.generate.params.batch, 1);
assert_eq!(app.generate.params.format, OutputFormat::Png);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn reset_defaults_uses_local_config_when_no_server() {
let mut app = make_settings_test_app();
app.server_url = None;
app.generate.params.steps = 999;
app.generate.params.batch = 10;
app.active_view = View::Generate;
app.generate.focus = GenerateFocus::Parameters;
let reset_idx = app
.generate
.visible_fields
.iter()
.position(|f| *f == ParamField::ResetDefaults)
.unwrap();
app.generate.param_index = reset_idx;
app.activate_current_param();
assert_ne!(app.generate.params.steps, 999);
assert_eq!(app.generate.params.batch, 1);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn sync_resource_info_mode_local_clears_server_status() {
let mut app = make_settings_test_app();
app.generate.params.inference_mode = InferenceMode::Local;
app.resource_info.server_status = Some(mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec![],
busy: false,
current_generation: None,
gpu_info: None,
uptime_secs: 0,
hostname: Some("stale-host".to_string()),
memory_status: None,
gpus: None,
queue_depth: None,
queue_capacity: None,
});
app.sync_resource_info_mode();
assert!(
app.resource_info.server_status.is_none(),
"local mode should clear server_status"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn server_connected_applies_model_defaults_and_clears_connecting() {
let mut app = make_settings_test_app();
app.connecting = true;
app.generate.params.model = "flux-dev:q4".to_string();
app.generate.params.steps = 1;
let models = vec![make_test_catalog_entry(
"flux-dev:q4",
20,
3.5,
1024,
1024,
"Server FLUX",
)];
let _ = app.bg_tx.send(BackgroundEvent::ServerConnected {
url: "http://hal9000:7680".to_string(),
models,
});
app.process_background_events();
assert!(!app.connecting);
assert_eq!(app.server_url.as_deref(), Some("http://hal9000:7680"));
assert_eq!(app.generate.params.steps, 20);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn server_status_update_some_populates_resource_info() {
let mut app = make_settings_test_app();
let status = mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec!["flux-dev:q4".to_string()],
busy: true,
current_generation: None,
gpu_info: Some(mold_core::GpuInfo {
name: "RTX 4090".to_string(),
vram_total_mb: 24564,
vram_used_mb: 8192,
}),
uptime_secs: 3600,
hostname: Some("hal9000".to_string()),
memory_status: Some("VRAM: 16.0 GB free".to_string()),
gpus: None,
queue_depth: None,
queue_capacity: None,
};
let _ = app
.bg_tx
.send(BackgroundEvent::ServerStatusUpdate(Some(Box::new(status))));
app.process_background_events();
let ri = &app.resource_info;
assert!(ri.server_status.is_some());
assert_eq!(
ri.server_status.as_ref().unwrap().hostname.as_deref(),
Some("hal9000")
);
assert_eq!(ri.memory_line.as_deref(), Some("VRAM: 16.0 GB free"));
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn server_status_update_none_clears_stale_status() {
let mut app = make_settings_test_app();
app.resource_info
.update_from_server_status(mold_core::ServerStatus {
version: "0.6.3".to_string(),
git_sha: None,
build_date: None,
models_loaded: vec![],
busy: false,
current_generation: None,
gpu_info: None,
uptime_secs: 0,
hostname: Some("stale-host".to_string()),
memory_status: Some("VRAM: 16.0 GB free".to_string()),
gpus: None,
queue_depth: None,
queue_capacity: None,
});
assert!(app.resource_info.server_status.is_some());
let _ = app.bg_tx.send(BackgroundEvent::ServerStatusUpdate(None));
app.process_background_events();
assert!(
app.resource_info.server_status.is_none(),
"stale server status should be cleared on fetch failure"
);
}
#[tokio::test]
#[serial_test::serial(mold_env)]
async fn server_unreachable_clears_connecting_and_reverts_host() {
let mut app = make_settings_test_app();
app.connecting = true;
app.server_url = Some("http://original:7680".to_string());
app.generate.params.host = Some("http://new-host:7680".to_string());
let _ = app
.bg_tx
.send(BackgroundEvent::ServerUnreachable("timeout".to_string()));
app.process_background_events();
assert!(!app.connecting);
assert_eq!(
app.generate.params.host.as_deref(),
Some("http://original:7680")
);
assert!(app.resource_info.server_status.is_none());
}
#[tokio::test]
async fn settings_render_appearance_focus() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Appearance;
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
}
#[tokio::test]
async fn settings_render_configuration_focus_with_selection() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Configuration;
app.settings.row_index = find_settings_row(&app, SettingsKey::DefaultWidth);
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
assert!(app.settings.scroll_offset <= app.settings.row_index);
}
#[tokio::test]
async fn settings_render_text_field_selected_emits_dropdown_glyph() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Configuration;
app.settings.row_index = find_settings_row(&app, SettingsKey::DefaultModel);
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let mut found_glyph = false;
for y in 0..buf.area.height {
for x in 0..buf.area.width {
if buf[(x, y)].symbol() == "\u{25bc}" {
found_glyph = true;
break;
}
}
}
assert!(found_glyph, "expected ▼ on the selected Text field's row",);
}
#[tokio::test]
async fn settings_render_with_save_error_drives_error_branch() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Configuration;
app.settings.save_error = Some("disk full".to_string());
let backend = TestBackend::new(100, 80);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let mut full_text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
full_text.push_str(buf[(x, y)].symbol());
}
full_text.push('\n');
}
assert!(
full_text.contains("disk full"),
"save_error message must appear in rendered output",
);
}
#[tokio::test]
async fn settings_render_zero_height_inner_returns_early() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
let backend = TestBackend::new(40, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
}
#[tokio::test]
async fn settings_render_tall_list_overflows_scrollbar() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.settings.focus = SettingsFocus::Configuration;
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
let last_row_index = app.build_settings_rows().len().saturating_sub(1);
app.settings.row_index = last_row_index;
terminal
.draw(|f| crate::ui::settings::render(f, &mut app, f.area()))
.unwrap();
assert!(
app.settings.scroll_offset > 0,
"scrolling far down must shift the visible window",
);
}
fn synth_model(
name: &str,
family: &str,
downloaded: bool,
is_loaded: bool,
) -> mold_core::ModelInfoExtended {
use mold_core::types::{ModelDefaults, ModelInfo, ModelInfoExtended};
ModelInfoExtended {
info: ModelInfo {
name: name.to_string(),
family: family.to_string(),
size_gb: 4.2,
is_loaded,
last_used: None,
hf_repo: format!("test-org/{name}"),
},
defaults: ModelDefaults {
default_steps: 4,
default_guidance: 3.5,
default_width: 1024,
default_height: 1024,
description: format!("synthetic {name} fixture"),
},
downloaded,
disk_usage_bytes: None,
remaining_download_bytes: None,
}
}
#[tokio::test]
async fn models_render_empty_catalog_shows_no_matches_in_details() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.active_view = View::Models;
app.models.catalog.clear();
let backend = TestBackend::new(120, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::models::render(f, &mut app, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push(' ');
}
assert!(
text.contains("no matches"),
"empty catalog must surface the 'no matches' empty-state",
);
}
#[tokio::test]
async fn models_render_installed_section_active_with_loaded_model() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.active_view = View::Models;
app.models.catalog = vec![
synth_model("flux-dev:q8", "flux", true, true),
synth_model("sdxl-base:fp16", "sdxl", true, false),
];
app.models.selected = 0;
let backend = TestBackend::new(140, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::models::render(f, &mut app, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push(' ');
}
assert!(text.contains("loaded"), "loaded status must render");
assert!(text.contains("FLUX"), "family is uppercased in the row");
assert!(
text.contains("flux-dev:q8"),
"installed model name must appear",
);
assert!(
text.contains("test-org/flux-dev:q8"),
"details panel must surface the selected model's HF repo",
);
}
#[tokio::test]
async fn models_render_available_section_active_with_undownloaded_model() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.active_view = View::Models;
app.models.catalog = vec![
synth_model("flux-dev:q8", "flux", true, false),
synth_model("z-image:fp16", "z-image", false, false),
];
app.models.selected = 1;
let backend = TestBackend::new(140, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::models::render(f, &mut app, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push(' ');
}
assert!(
text.contains("ready"),
"downloaded-but-not-loaded → 'ready'"
);
assert!(
text.contains("z-image:fp16"),
"the non-installed model must list under Available",
);
}
#[tokio::test]
async fn models_render_zero_width_collapses_panels_without_panic() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = make_settings_test_app();
app.active_view = View::Models;
app.models.catalog = vec![synth_model("flux-dev:q8", "flux", true, false)];
let backend = TestBackend::new(1, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::ui::models::render(f, &mut app, f.area()))
.unwrap();
}
}