use crate::render_backend::{buffer_draw_text, buffer_fill_rect, OptimizedBuffer, Style};
use crate::{
command::CommandSpec,
model::{Focus, Model, PaletteMode},
theme,
view::components::{dim_rect, draw_text_truncated, Rect},
};
const OUTER_PAD: u32 = 1;
const INNER_PAD: u32 = 1;
const BULLET_W: u32 = 1;
const BULLET_GAP: u32 = 1;
const TRAIL_PAD: u32 = 3;
const TEXT_INDENT: u32 = OUTER_PAD + INNER_PAD + BULLET_W + BULLET_GAP;
pub fn view(model: &Model, buffer: &mut OptimizedBuffer) {
if model.focus != Focus::CommandPalette {
return;
}
let screen = Rect::from_size(model.width, model.height);
dim_rect(buffer, screen, 0.35);
match model.command_palette_mode {
PaletteMode::Commands => render_commands(model, buffer, screen),
PaletteMode::Themes => render_themes(model, buffer, screen),
}
}
fn render_commands(model: &Model, buffer: &mut OptimizedBuffer, screen: Rect) {
let modal_width = 60u32.min(screen.width.saturating_sub(4));
let rows = build_rows(&model.command_palette_commands);
let list_height = rows.len() as u32;
let modal_height = (1 + 1 + 1 + 1 + 2 + list_height + 2).min(screen.height.saturating_sub(2));
let modal_x = (screen.width.saturating_sub(modal_width)) / 2;
let modal_y = screen.height / 4;
buffer_fill_rect(
buffer,
modal_x,
modal_y,
modal_width,
modal_height,
model.theme.panel_bg,
);
let text_x = modal_x + TEXT_INDENT;
let text_width = modal_width.saturating_sub(TEXT_INDENT + OUTER_PAD);
let esc_label = "esc";
let esc_right = modal_x + modal_width - OUTER_PAD - TRAIL_PAD;
let mut y = modal_y;
y += 1;
buffer_draw_text(
buffer,
text_x,
y,
"Commands",
model.theme.style_foreground().with_bold(),
);
let esc_x = esc_right.saturating_sub(esc_label.len() as u32);
buffer_draw_text(buffer, esc_x, y, esc_label, model.theme.style_muted());
y += 1;
y += 1;
render_search_field(model, buffer, text_x, y, text_width);
y += 1;
y += 2;
let list_max = modal_y + modal_height - 2; for row in &rows {
if y >= list_max {
break;
}
match row {
Row::Category(name) => {
buffer_draw_text(
buffer,
text_x,
y,
name,
model.theme.style_primary().with_bold(),
);
}
Row::Separator => {
}
Row::Item(cmd, idx) => {
let selected = *idx == model.command_palette_selection;
render_item_row(buffer, modal_x, y, modal_width, cmd, selected, model);
}
}
y += 1;
}
}
fn render_themes(model: &Model, buffer: &mut OptimizedBuffer, screen: Rect) {
let modal_width = 60u32.min(screen.width.saturating_sub(4));
let theme_names = filtered_theme_names(&model.command_palette_input);
let list_height = theme_names.len() as u32;
let modal_height = (1 + 1 + 1 + 1 + 2 + list_height + 2).min(screen.height.saturating_sub(2));
let modal_x = (screen.width.saturating_sub(modal_width)) / 2;
let modal_y = screen.height / 4;
buffer_fill_rect(
buffer,
modal_x,
modal_y,
modal_width,
modal_height,
model.theme.panel_bg,
);
let text_x = modal_x + TEXT_INDENT;
let text_width = modal_width.saturating_sub(TEXT_INDENT + OUTER_PAD);
let esc_label = "esc";
let esc_right = modal_x + modal_width - OUTER_PAD - TRAIL_PAD;
let mut y = modal_y;
y += 1;
buffer_draw_text(
buffer,
text_x,
y,
"Themes",
model.theme.style_foreground().with_bold(),
);
let esc_x = esc_right.saturating_sub(esc_label.len() as u32);
buffer_draw_text(buffer, esc_x, y, esc_label, model.theme.style_muted());
y += 1;
y += 1;
render_search_field(model, buffer, text_x, y, text_width);
y += 1;
y += 2;
let list_max = modal_y + modal_height - 2;
for (idx, name) in theme_names.iter().enumerate() {
if y >= list_max {
break;
}
let selected = idx == model.command_palette_selection;
let is_current = *name == model.theme.name;
render_theme_row(
buffer,
&ModalLayout {
x: modal_x,
width: modal_width,
},
y,
name,
selected,
is_current,
model,
);
y += 1;
}
}
fn render_search_field(
model: &Model,
buffer: &mut OptimizedBuffer,
text_x: u32,
y: u32,
text_width: u32,
) {
if model.command_palette_input.is_empty() {
buffer_draw_text(buffer, text_x, y, "Search", model.theme.style_muted());
} else {
let input_text = format!("{}\u{2588}", model.command_palette_input);
draw_text_truncated(
buffer,
text_x,
y,
&input_text,
text_width,
model.theme.style_foreground(),
);
}
}
fn render_item_row(
buffer: &mut OptimizedBuffer,
modal_x: u32,
y: u32,
modal_width: u32,
cmd: &CommandSpec,
selected: bool,
model: &Model,
) {
let highlight_x = modal_x + OUTER_PAD;
let highlight_width = modal_width - (OUTER_PAD * 2);
let (bg, fg) = if selected {
(model.theme.selection_bg, model.theme.selection_fg)
} else {
(model.theme.panel_bg, model.theme.foreground)
};
buffer_fill_rect(buffer, highlight_x, y, highlight_width, 1, bg);
let bullet_x = highlight_x + INNER_PAD;
let bullet = if cmd.active { "●" } else { " " };
buffer_draw_text(buffer, bullet_x, y, bullet, Style::fg(fg));
let name_x = bullet_x + BULLET_W + BULLET_GAP;
let content_end = highlight_x + highlight_width - TRAIL_PAD;
let content_width = content_end.saturating_sub(name_x);
if let Some(shortcut) = cmd.shortcut {
let shortcut_len = shortcut.len() as u32;
if shortcut_len < content_width {
let shortcut_x = content_end - shortcut_len;
buffer_draw_text(buffer, shortcut_x, y, shortcut, model.theme.style_muted());
let name_max = content_width.saturating_sub(shortcut_len + 1);
draw_text_truncated(buffer, name_x, y, cmd.name, name_max, Style::fg(fg));
} else {
draw_text_truncated(buffer, name_x, y, cmd.name, content_width, Style::fg(fg));
}
} else {
draw_text_truncated(buffer, name_x, y, cmd.name, content_width, Style::fg(fg));
}
}
struct ModalLayout {
x: u32,
width: u32,
}
fn render_theme_row(
buffer: &mut OptimizedBuffer,
layout: &ModalLayout,
y: u32,
name: &str,
selected: bool,
is_current: bool,
model: &Model,
) {
let highlight_x = layout.x + OUTER_PAD;
let highlight_width = layout.width - (OUTER_PAD * 2);
let (bg, fg) = if selected {
(model.theme.selection_bg, model.theme.selection_fg)
} else {
(model.theme.panel_bg, model.theme.foreground)
};
buffer_fill_rect(buffer, highlight_x, y, highlight_width, 1, bg);
let bullet_x = highlight_x + INNER_PAD;
let bullet = if is_current { "●" } else { " " };
buffer_draw_text(buffer, bullet_x, y, bullet, Style::fg(fg));
let name_x = bullet_x + BULLET_W + BULLET_GAP;
let content_end = highlight_x + highlight_width - TRAIL_PAD;
let content_width = content_end.saturating_sub(name_x);
draw_text_truncated(buffer, name_x, y, name, content_width, Style::fg(fg));
}
enum Row<'a> {
Category(&'static str),
Separator,
Item(&'a CommandSpec, usize),
}
fn build_rows(commands: &[CommandSpec]) -> Vec<Row<'_>> {
let mut rows = Vec::new();
let mut current_category: Option<&str> = None;
for (selectable_index, cmd) in commands.iter().enumerate() {
if current_category != Some(cmd.category) {
if current_category.is_some() {
rows.push(Row::Separator);
}
rows.push(Row::Category(cmd.category));
current_category = Some(cmd.category);
}
rows.push(Row::Item(cmd, selectable_index));
}
rows
}
fn filtered_theme_names(query: &str) -> Vec<&'static str> {
let names = theme::built_in_theme_names();
let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
if terms.is_empty() {
return names;
}
names
.into_iter()
.filter(|name| {
let name_lower = name.to_lowercase();
terms.iter().all(|term| name_lower.contains(term.as_str()))
})
.collect()
}