use iced::widget::{Space, button, column, container, row, text};
use iced::{Alignment, Color, Element, Length, widget::Id};
use iced_fonts::lucide;
use crate::gui::dialog;
use crate::gui::theme;
use crate::gui::widgets;
use super::{
DeleteConfirmTarget, EditorMessage, GLOBAL_SEARCH_INPUT_ID, GlobalSearchState,
GlobalSearchStatus, NEW_ITEM_INPUT_ID, NewItemTarget, OwnedGrepMatch, QUICK_OPEN_INPUT_ID,
QuickOpenState,
};
fn result_entry_style(bg: Color) -> impl Fn(&iced::Theme) -> container::Style {
move |_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(bg)),
..Default::default()
}
}
pub(super) fn build_quick_open_overlay(qo: &QuickOpenState) -> Element<'_, EditorMessage> {
let search_input = widgets::single_line_editor(
&qo.filter.buffer,
"Search files…",
true,
Length::Fill,
Some(Id::new(QUICK_OPEN_INPUT_ID)),
|action| match action {
super::super::editor_widget::EditorAction::Submit => {
qo.results.first().map_or(EditorMessage::Escape, |_| {
EditorMessage::QuickOpenSelect(qo.selected_index)
})
}
other => EditorMessage::QuickOpenInput(other),
},
);
let search_elem: Element<'_, EditorMessage> = search_input;
let results_owned: Vec<String> = qo.results.clone();
let selected_index = qo.selected_index;
let results: Vec<Element<'static, EditorMessage>> = results_owned
.iter()
.enumerate()
.map(|(i, path)| {
let is_selected = i == selected_index;
let path_owned = path.clone();
let bg = if is_selected {
theme::HOVER_STRONG
} else {
iced::Color::TRANSPARENT
};
let label = text(path_owned).size(theme::TEXT_12).color(if is_selected {
theme::ACCENT
} else {
theme::TEXT_SECONDARY
});
let entry = container(label)
.padding(theme::PAD_4)
.width(Length::Fill)
.style(result_entry_style(bg));
button(entry)
.on_press(EditorMessage::QuickOpenSelect(i))
.style(theme::button_transparent)
.width(Length::Fill)
.padding(0)
.into()
})
.collect();
let results_column = column(results).spacing(0).width(Length::Fill);
let empty_hint: Option<Element<'_, EditorMessage>> =
if qo.filter.text().is_empty() && qo.results.is_empty() {
Some(
text("Type to filter files…")
.size(theme::TEXT_12)
.color(theme::TEXT_FAINT)
.into(),
)
} else if qo.results.is_empty() {
Some(
text("No matches found")
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY)
.into(),
)
} else {
None
};
let content: Element<'_, EditorMessage> = if let Some(hint) = empty_hint {
column![search_elem, hint].spacing(theme::SPACE_4).into()
} else {
column![
search_elem,
widgets::vscroll_sized(results_column, Length::Fill, Length::Fixed(300.0)),
]
.spacing(theme::SPACE_4)
.into()
};
let dialog = dialog::dialog_shell(content, 400.0, 12.0);
widgets::modal_backdrop(dialog, EditorMessage::Escape, 0.4)
}
fn dialog_button(
label: &str,
color: Color,
style: fn(&iced::Theme, iced::widget::button::Status) -> iced::widget::button::Style,
on_press: EditorMessage,
) -> Element<'_, EditorMessage> {
button(
text(label)
.size(theme::TEXT_13)
.color(color)
.align_x(Alignment::Center),
)
.style(style)
.on_press(on_press)
.into()
}
pub(super) fn build_close_dialog(
on_save: EditorMessage,
on_discard: EditorMessage,
on_cancel: EditorMessage,
description: String,
) -> Element<'static, EditorMessage> {
widgets::modal_backdrop(
dialog::confirm_dialog(
row![
lucide::triangle_alert::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_16)
.color(theme::STATUS_WARNING),
Space::new().width(theme::SPACE_8),
dialog::dialog_title("Unsaved changes"),
]
.align_y(Alignment::Center),
dialog::dialog_body([description]),
[
dialog::DialogAction::secondary("Cancel", on_cancel.clone()),
dialog::DialogAction::danger("Discard", on_discard),
dialog::DialogAction::secondary("Save", on_save),
],
),
on_cancel,
0.5,
)
}
pub(super) fn build_delete_confirm_dialog(
target: &DeleteConfirmTarget,
) -> Element<'static, EditorMessage> {
let description = if target.is_dir {
let dirty_msg = if target.dirty_tab_count > 0 {
format!(
" ({} tab{} with unsaved changes will be closed)",
target.dirty_tab_count,
if target.dirty_tab_count == 1 { "" } else { "s" }
)
} else {
String::new()
};
format!(
"Delete directory \"{}\" and all its contents?{}",
target.path, dirty_msg
)
} else {
format!("Delete file \"{}\"?", target.path)
};
let title = if target.is_dir {
"Delete directory"
} else {
"Delete file"
};
widgets::modal_backdrop(
dialog::confirm_dialog(
row![
lucide::triangle_alert::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_16)
.color(theme::STATUS_WARNING),
Space::new().width(theme::SPACE_8),
dialog::dialog_title(title),
]
.align_y(Alignment::Center),
dialog::dialog_body([description]),
[
dialog::DialogAction::secondary("Cancel", EditorMessage::CancelDelete),
dialog::DialogAction::danger("Delete", EditorMessage::ConfirmDelete),
],
),
EditorMessage::CancelDelete,
0.5,
)
}
pub(super) fn build_new_item_input(target: &NewItemTarget) -> Element<'_, EditorMessage> {
let label = if target.is_dir {
format!("New directory in \"{}\"", target.parent_dir)
} else {
format!("New file in \"{}\"", target.parent_dir)
};
let input = widgets::single_line_editor(
&target.input_text.buffer,
"Name…",
true,
Length::Fill,
Some(Id::new(NEW_ITEM_INPUT_ID)),
|action| match action {
super::super::editor_widget::EditorAction::Submit => {
EditorMessage::NewItemSubmit(target.input_text.text())
}
other => EditorMessage::NewItemInput(other),
},
);
widgets::modal_backdrop(
dialog::dialog_shell(
column![
text(label).size(theme::TEXT_14).color(theme::TEXT_PRIMARY),
input,
dialog::dialog_footer_row([
dialog_button(
"Cancel",
theme::TEXT_SECONDARY,
theme::button_secondary,
EditorMessage::Escape,
),
dialog_button(
"Create",
theme::TEXT_PRIMARY,
theme::button_secondary,
EditorMessage::NewItemSubmit(target.input_text.text()),
),
]),
]
.spacing(theme::SPACE_12)
.width(Length::Fill),
400.0,
24.0,
),
EditorMessage::Escape,
0.5,
)
}
#[expect(clippy::too_many_lines)]
pub(super) fn build_global_search_overlay(gs: &GlobalSearchState) -> Element<'_, EditorMessage> {
let search_input = widgets::single_line_editor(
&gs.query.buffer,
"Search across workspace…",
true,
Length::Fill,
Some(Id::new(GLOBAL_SEARCH_INPUT_ID)),
|action| match action {
super::super::editor_widget::EditorAction::Submit => {
if gs.results.is_empty() {
EditorMessage::Escape
} else {
EditorMessage::GlobalSearchSelect(gs.selected_index)
}
}
other => EditorMessage::GlobalSearchInput(other),
},
);
let search_elem: Element<'_, EditorMessage> = search_input;
let status_elem: Element<'static, EditorMessage> = match &gs.status {
GlobalSearchStatus::Idle => text("Type to search across workspace files")
.size(theme::TEXT_12)
.color(theme::TEXT_FAINT)
.into(),
GlobalSearchStatus::Searching => row![
text("Searching…")
.size(theme::TEXT_12)
.color(theme::TEXT_MUTED),
Space::new().width(Length::Fill),
]
.into(),
GlobalSearchStatus::NoResults => text("No matches found")
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY)
.into(),
GlobalSearchStatus::Error(e) => text(format!("Search error: {e}"))
.size(theme::TEXT_12)
.color(theme::STATUS_ERROR)
.into(),
GlobalSearchStatus::Done => {
let count = gs.results.len();
text(format!(
"{count} result{}",
if count == 1 { "" } else { "s" }
))
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY)
.into()
}
};
let results_owned: Vec<OwnedGrepMatch> = gs.results.clone();
let selected_index = gs.selected_index;
let results: Vec<Element<'static, EditorMessage>> = results_owned
.iter()
.enumerate()
.map(|(i, m)| {
let is_selected = i == selected_index;
let bg = if is_selected {
theme::HOVER_STRONG
} else {
iced::Color::TRANSPARENT
};
let path_text = format!("{}:{}", m.rel_path, m.line_number);
let line_content = m.line_content.clone();
let offsets = m.match_byte_offsets.clone();
let accent = if is_selected {
theme::ACCENT
} else {
theme::TEXT_SECONDARY
};
let path_label = text(path_text)
.size(theme::TEXT_12)
.color(accent)
.font(theme::FONT_BOLD);
let trimmed = line_content.trim().to_string();
let snippet_label: Element<'static, EditorMessage> =
if let Some(&(start, end)) = offsets.first() {
let start_us = start as usize;
let end_us = end as usize;
let content = &line_content;
let pre = if start_us <= content.len() {
content[..start_us.min(content.len())]
.trim_start()
.to_string()
} else {
String::new()
};
let matched = if start_us < content.len() && end_us <= content.len() {
content[start_us..end_us].to_string()
} else {
String::new()
};
let post = if end_us < content.len() {
content[end_us..].trim_end().to_string()
} else {
String::new()
};
let text_color = if is_selected {
theme::TEXT_PRIMARY
} else {
theme::TEXT_SECONDARY
};
row![
text(pre).size(theme::TEXT_12).color(text_color),
text(matched)
.size(theme::TEXT_12)
.color(theme::ACCENT_LIGHT)
.font(theme::FONT_BOLD),
text(post).size(theme::TEXT_12).color(text_color),
]
.spacing(0)
.into()
} else {
text(trimmed)
.size(theme::TEXT_12)
.color(if is_selected {
theme::TEXT_PRIMARY
} else {
theme::TEXT_MUTED
})
.into()
};
let entry_content = column![path_label, snippet_label].spacing(theme::SPACE_1);
let entry = container(entry_content)
.padding(theme::PAD_4)
.width(Length::Fill)
.style(result_entry_style(bg));
button(entry)
.on_press(EditorMessage::GlobalSearchSelect(i))
.style(theme::button_transparent)
.width(Length::Fill)
.padding(0)
.into()
})
.collect();
let results_column = column(results).spacing(0).width(Length::Fill);
let has_results = !gs.results.is_empty();
let content: Element<'_, EditorMessage> = if !has_results {
column![search_elem, status_elem]
.spacing(theme::SPACE_4)
.into()
} else {
column![
search_elem,
status_elem,
widgets::vscroll_sized(results_column, Length::Fill, Length::Fixed(400.0)),
]
.spacing(theme::SPACE_4)
.into()
};
let dialog = dialog::dialog_shell(content, 600.0, 12.0);
widgets::modal_backdrop(dialog, EditorMessage::Escape, 0.4)
}