use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use crate::app::{App, Popup};
pub fn render(frame: &mut Frame, app: &mut App) {
match &app.popup {
Some(Popup::Help) => render_help(frame, app),
Some(Popup::ModelSelector { .. }) => render_model_selector(frame, app),
Some(Popup::HostInput { .. }) => render_host_input(frame, app),
Some(Popup::SeedInput { .. }) => render_seed_input(frame, app),
Some(Popup::HistorySearch { .. }) => render_history_search(frame, app),
Some(Popup::Confirm { message, .. }) => render_confirm(frame, app, message.clone()),
Some(Popup::SettingsInput { .. }) => render_settings_input(frame, app),
Some(Popup::Info { message }) => render_info(frame, app, message.clone()),
Some(Popup::UpscaleModelSelector { .. }) => render_upscale_model_selector(frame, app),
None => {}
}
}
fn centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_pct) / 2),
Constraint::Percentage(height_pct),
Constraint::Percentage((100 - height_pct) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_pct) / 2),
Constraint::Percentage(width_pct),
Constraint::Percentage((100 - width_pct) / 2),
])
.split(vertical[1])[1]
}
fn render_help(frame: &mut Frame, app: &App) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 60, 70);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Keybindings ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let help_text = vec![
Line::from(Span::styled(
"Navigation",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(" Tab / Shift+Tab Cycle focus between panels"),
Line::from(" Alt+1/2/3 Switch to Generate/Gallery/Models"),
Line::from(" Esc Close popup / cancel"),
Line::from(" q / Ctrl+C Quit"),
Line::from(""),
Line::from(Span::styled(
"Generate View",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(" Enter Start generation"),
Line::from(" Ctrl+E Expand prompt via LLM"),
Line::from(" Ctrl+S Save current image"),
Line::from(" Ctrl+R Randomize seed"),
Line::from(" Ctrl+M Open model selector"),
Line::from(" Ctrl+K Compare models"),
Line::from(" j/k Navigate parameters"),
Line::from(" +/- or Left/Right Adjust parameter value"),
Line::from(""),
Line::from(Span::styled(
"Gallery View",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(" j/k Navigate history"),
Line::from(" Enter Re-generate with same params"),
Line::from(" e Edit parameters & generate"),
Line::from(" d Delete image"),
Line::from(" u Upscale with AI model"),
Line::from(" o Open in system viewer"),
Line::from(" hjkl Pan image viewport"),
Line::from(" +/- Zoom in/out"),
Line::from(""),
Line::from(Span::styled(
"Models View",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(" Enter Select as default model"),
Line::from(" p Pull (download) model"),
Line::from(" r Remove model"),
Line::from(" u Unload from GPU"),
Line::from(" / Filter by name"),
Line::from(""),
Line::from(Span::styled(
"Settings View",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(" j/k Navigate settings"),
Line::from(" +/- or Left/Right Adjust value"),
Line::from(" Enter Edit text field / toggle"),
Line::from(" Esc Return to Generate"),
];
let paragraph = Paragraph::new(help_text)
.block(block)
.style(Style::default().fg(theme.text))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn build_model_item<'a>(
name: &str,
is_selected: bool,
show_download_status: bool,
default_model: Option<&str>,
theme: &crate::ui::theme::Theme,
config: &mold_core::Config,
width: u16,
) -> ListItem<'a> {
let manifest = mold_core::manifest::find_manifest(name);
let resolved = mold_core::manifest::resolve_model_name(name);
let downloaded =
config.models.contains_key(&resolved) || config.manifest_model_is_downloaded(name);
let is_default = default_model.is_some_and(|d| d == name);
let marker = if is_selected { "> " } else { " " };
let size_str = manifest
.map(|m| {
let bytes = m.model_size_bytes();
if bytes >= 1_073_741_824 {
format!("{:.1}GB", bytes as f64 / 1_073_741_824.0)
} else {
format!("{}MB", bytes / 1_048_576)
}
})
.unwrap_or_default();
let status_width: usize = if show_download_status { 12 } else { 0 };
let default_display_width: usize = if is_default { 2 } else { 0 };
let left_display_width = 2 + name.len() + default_display_width; let right_width = 7 + if status_width > 0 {
2 + status_width
} else {
0
};
let padding = (width as usize).saturating_sub(left_display_width + right_width);
let pad = " ".repeat(padding);
let name_style = Style::default().fg(theme.text);
let size_style = Style::default().fg(theme.text_dim);
let default_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let mut spans = vec![
Span::styled(format!("{marker}{name}"), name_style),
if is_default {
Span::styled(" \u{2605}", default_style)
} else {
Span::raw("")
},
Span::styled(pad, name_style),
Span::styled(format!("{size_str:>7}"), size_style),
];
if show_download_status {
spans.push(Span::raw(" "));
if downloaded {
let tag = format!("{:>width$}", "\u{2713} ready", width = status_width);
spans.push(Span::styled(tag, Style::default().fg(Color::Green)));
} else {
let tag = format!("{:>width$}", "(download)", width = status_width);
spans.push(Span::styled(tag, Style::default().fg(theme.text_dim)));
}
}
let line1 = Line::from(spans);
let desc = manifest.map(|m| m.description.clone()).unwrap_or_default();
let desc_indent = " ";
let max_desc = (width as usize).saturating_sub(desc_indent.len());
let desc_text = if desc.len() > max_desc {
format!("{}{}...", desc_indent, &desc[..max_desc.saturating_sub(3)])
} else {
format!("{desc_indent}{desc}")
};
let line2 = Line::from(Span::styled(desc_text, Style::default().fg(theme.text_dim)));
let bg = if is_selected {
theme.list_selected()
} else {
Style::default()
};
ListItem::new(vec![line1, line2]).style(bg)
}
#[allow(clippy::too_many_arguments)]
fn render_model_selector_popup(
frame: &mut Frame,
app: &mut App,
title: &str,
filter: &str,
selected: usize,
filtered: &[String],
show_download_status: bool,
default_model: Option<&str>,
) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 65, 60);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(format!(" {title} "))
.title_style(theme.title_focused())
.style(theme.popup_bg());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let filter_display = if filter.is_empty() {
"Type to filter...".to_string()
} else {
filter.to_string()
};
let filter_style = if filter.is_empty() {
theme.dim()
} else {
Style::default().fg(theme.text)
};
let filter_line = Paragraph::new(format!("Filter: {filter_display}")).style(filter_style);
let filter_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
frame.render_widget(filter_line, filter_area);
let list_area = Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: inner.height.saturating_sub(2),
};
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(i, name)| {
build_model_item(
name,
i == selected,
show_download_status,
default_model,
theme,
&app.config,
inner.width,
)
})
.collect();
let list = List::new(items);
let mut state = ListState::default().with_selected(Some(selected));
frame.render_stateful_widget(list, list_area, &mut state);
}
fn render_model_selector(frame: &mut Frame, app: &mut App) {
if let Some(Popup::ModelSelector {
filter,
selected,
filtered,
}) = &app.popup
{
let filter = filter.clone();
let selected = *selected;
let filtered = filtered.clone();
let default = mold_core::manifest::resolve_model_name(&app.config.resolved_default_model());
render_model_selector_popup(
frame,
app,
"Select Model",
&filter,
selected,
&filtered,
true,
Some(&default),
);
}
}
fn render_upscale_model_selector(frame: &mut Frame, app: &mut App) {
if let Some(Popup::UpscaleModelSelector {
filter,
selected,
filtered,
}) = &app.popup
{
let filter = filter.clone();
let selected = *selected;
let filtered = filtered.clone();
let default = filtered
.iter()
.find(|n| app.config.manifest_model_is_downloaded(n))
.cloned()
.unwrap_or_else(|| "real-esrgan-x4plus:fp16".to_string());
render_model_selector_popup(
frame,
app,
"Select Upscaler Model",
&filter,
selected,
&filtered,
true,
Some(&default),
);
}
}
fn render_host_input(frame: &mut Frame, app: &mut App) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 50, 15);
frame.render_widget(Clear, area);
if let Some(Popup::HostInput { input }) = &app.popup {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Server Host ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let hint =
Paragraph::new("Enter server address (or clear for local mode)").style(theme.dim());
let hint_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
frame.render_widget(hint, hint_area);
let display = format!("{input}\u{2588}"); let input_line = Paragraph::new(display).style(Style::default().fg(theme.text));
let input_area = Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: 1,
};
frame.render_widget(input_line, input_area);
let actions = Line::from(vec![
Span::styled("Enter", theme.status_key()),
Span::styled(" Confirm ", Style::default().fg(theme.text)),
Span::styled("Esc", theme.status_key()),
Span::styled(" Cancel", Style::default().fg(theme.text)),
]);
let actions_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(actions), actions_area);
}
}
fn render_seed_input(frame: &mut Frame, app: &mut App) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 45, 15);
frame.render_widget(Clear, area);
if let Some(Popup::SeedInput { input }) = &app.popup {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Seed Value ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let hint = Paragraph::new("Enter seed (digits only, empty for auto)").style(theme.dim());
frame.render_widget(hint, Rect { height: 1, ..inner });
let display = format!("{input}\u{2588}");
let input_line = Paragraph::new(display).style(Style::default().fg(theme.text));
frame.render_widget(
input_line,
Rect {
y: inner.y + 2,
height: 1,
..inner
},
);
let actions = Line::from(vec![
Span::styled("Enter", theme.status_key()),
Span::styled(" Confirm ", Style::default().fg(theme.text)),
Span::styled("Esc", theme.status_key()),
Span::styled(" Cancel", Style::default().fg(theme.text)),
]);
frame.render_widget(
Paragraph::new(actions),
Rect {
y: inner.y + inner.height.saturating_sub(1),
height: 1,
..inner
},
);
}
}
fn render_history_search(frame: &mut Frame, app: &mut App) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 60, 55);
frame.render_widget(Clear, area);
if let Some(Popup::HistorySearch {
filter,
selected,
results,
}) = &app.popup
{
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Prompt History ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let filter_display = if filter.is_empty() {
"Type to search...".to_string()
} else {
filter.clone()
};
let filter_style = if filter.is_empty() {
theme.dim()
} else {
Style::default().fg(theme.text)
};
let filter_line = Paragraph::new(format!("/ {filter_display}")).style(filter_style);
frame.render_widget(filter_line, Rect { height: 1, ..inner });
let list_area = Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: inner.height.saturating_sub(2),
};
let items: Vec<ListItem> = results
.iter()
.enumerate()
.map(|(i, prompt)| {
let style = if i == *selected {
theme.list_selected()
} else {
Style::default().fg(theme.text)
};
let marker = if i == *selected { "\u{25b8} " } else { " " };
let display = if prompt.len() > list_area.width as usize - 4 {
format!("{marker}{}...", &prompt[..list_area.width as usize - 7])
} else {
format!("{marker}{prompt}")
};
ListItem::new(display).style(style)
})
.collect();
if items.is_empty() {
let empty = Paragraph::new("No matching prompts")
.style(theme.dim())
.alignment(Alignment::Center);
frame.render_widget(empty, list_area);
} else {
let list = List::new(items);
let mut state = ListState::default().with_selected(Some(*selected));
frame.render_stateful_widget(list, list_area, &mut state);
}
}
}
fn render_confirm(frame: &mut Frame, app: &App, message: String) {
let theme = &app.theme;
let line_count = message.lines().count();
let (w, h) = if line_count > 2 { (55, 35) } else { (45, 30) };
let area = centered_rect(frame.area(), w, h);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Confirm ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let mut text: Vec<Line> = message.lines().map(|l| Line::from(l.to_string())).collect();
text.push(Line::from(""));
text.push(Line::from(vec![
Span::styled("Enter/y", theme.status_key()),
Span::styled(" Confirm ", Style::default().fg(theme.text)),
Span::styled("Esc/n", theme.status_key()),
Span::styled(" Cancel", Style::default().fg(theme.text)),
]));
let paragraph = Paragraph::new(text)
.block(block)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
fn render_info(frame: &mut Frame, app: &App, message: String) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 55, 20);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(" Info ")
.title_style(theme.title_focused())
.style(theme.popup_bg());
let mut text: Vec<Line> = message.lines().map(|l| Line::from(l.to_string())).collect();
text.push(Line::from(""));
text.push(Line::from(Span::styled(
"Press any key to dismiss",
Style::default().fg(theme.text_dim),
)));
let paragraph = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
fn render_settings_input(frame: &mut Frame, app: &mut App) {
let theme = &app.theme;
let area = centered_rect(frame.area(), 55, 15);
frame.render_widget(Clear, area);
if let Some(Popup::SettingsInput { input, label, .. }) = &app.popup {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border())
.title(format!(" {label} "))
.title_style(theme.title_focused())
.style(theme.popup_bg());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let hint = Paragraph::new("Enter value (empty to clear)").style(theme.dim());
frame.render_widget(hint, Rect { height: 1, ..inner });
let display = format!("{input}\u{2588}");
let input_line = Paragraph::new(display).style(Style::default().fg(theme.text));
frame.render_widget(
input_line,
Rect {
y: inner.y + 2,
height: 1,
..inner
},
);
let actions = Line::from(vec![
Span::styled("Enter", theme.status_key()),
Span::styled(" Confirm ", Style::default().fg(theme.text)),
Span::styled("Esc", theme.status_key()),
Span::styled(" Cancel", Style::default().fg(theme.text)),
]);
frame.render_widget(
Paragraph::new(actions),
Rect {
y: inner.y + inner.height.saturating_sub(1),
height: 1,
..inner
},
);
}
}
#[cfg(test)]
mod tests {
#[test]
fn confirm_popup_hint_lists_enter_as_default_confirm() {
let rendered = render_confirm_to_string("Delete file.png?");
assert!(
rendered.contains("Enter"),
"confirm popup should advertise Enter as the default: {rendered}"
);
assert!(
rendered.contains("Confirm"),
"confirm popup should still say Confirm: {rendered}"
);
assert!(
rendered.contains("Esc") || rendered.contains("n"),
"confirm popup should still expose a cancel affordance: {rendered}"
);
}
fn render_confirm_to_string(message: &str) -> String {
use crate::app::{App, ConfirmAction, Popup};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
let picker = ratatui_image::picker::Picker::from_fontsize((8, 16));
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App {
active_view: crate::action::View::Gallery,
generate: crate::app::GenerateState {
prompt: tui_textarea::TextArea::default(),
negative_prompt: tui_textarea::TextArea::default(),
params: crate::app::GenerateParams::from_config(&mold_core::Config::default()),
focus: crate::app::GenerateFocus::Navigation,
param_index: 0,
visible_fields: Vec::new(),
capabilities: crate::model_info::capabilities_for_family(
&crate::model_info::family_for_model("", &mold_core::Config::default()),
),
progress: crate::app::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: crate::app::GalleryState {
entries: Vec::new(),
selected: 0,
preview_image: None,
image_state: None,
animation: None,
scanning: false,
view_mode: crate::app::GalleryViewMode::Grid,
thumbnail_states: Vec::new(),
thumb_dimensions: Vec::new(),
thumb_fixed_cache: Vec::new(),
grid_cols: 3,
grid_scroll: 0,
},
models: crate::app::ModelsState {
catalog: Vec::new(),
selected: 0,
filter: String::new(),
filtering: false,
},
settings: crate::app::SettingsState::default(),
script: crate::ui::script_composer::ScriptComposerState::default(),
config: mold_core::Config::default(),
server_url: None,
picker,
theme: crate::ui::theme::Theme::default(),
popup: Some(Popup::Confirm {
message: message.to_string(),
on_confirm: ConfirmAction::DeleteGalleryImage,
}),
should_quit: false,
bg_tx: tx,
bg_rx: rx,
tokio_handle: tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
let rt = tokio::runtime::Runtime::new().unwrap();
let h = rt.handle().clone();
std::mem::forget(rt);
h
}),
resource_info: crate::ui::info::ResourceInfo::default(),
history: crate::history::PromptHistory::load(),
layout: crate::app::LayoutAreas::default(),
server_process: None,
upscale_in_progress: false,
upscale_task: None,
upscale_tile_progress: None,
upscale_progress: crate::app::ProgressState::default(),
connecting: false,
};
terminal.draw(|f| super::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn upscaler_manifest_has_description_and_size() {
for manifest in mold_core::manifest::known_manifests() {
if !manifest.is_upscaler() {
continue;
}
assert!(
!manifest.description.is_empty(),
"{} has empty description",
manifest.name
);
assert!(
manifest.model_size_bytes() > 0,
"{} has zero size",
manifest.name
);
}
}
#[test]
fn default_upscaler_exists_in_manifest() {
let manifest = mold_core::manifest::find_manifest("real-esrgan-x4plus:fp16");
assert!(manifest.is_some(), "default upscaler not found in manifest");
assert!(manifest.unwrap().is_upscaler());
}
#[test]
fn upscaler_size_formats_as_mb() {
let manifest = mold_core::manifest::find_manifest("real-esrgan-x4plus:fp16").unwrap();
let bytes = manifest.model_size_bytes();
assert!(bytes < 1_073_741_824, "expected < 1GB, got {bytes}");
assert!(bytes > 1_048_576, "expected > 1MB, got {bytes}");
}
#[test]
fn status_tag_logic() {
let is_default = true;
let downloaded = true;
let status = if is_default && downloaded {
"default"
} else if is_default && !downloaded {
"default · pull"
} else if !downloaded {
"pull"
} else {
""
};
assert_eq!(status, "default");
let status2 = if false {
"default · pull"
} else if true {
"default"
} else {
""
};
assert_eq!(status2, "default");
}
}