Files
OneCAD-Draft/src/ui/window/alias_editor.rs
T

320 lines
12 KiB
Rust

//! Command-alias editor — an in-canvas modal (Plan B) for adding, remapping and
//! removing command-line aliases (the `ocad.pgp` table). Opened by ALIASEDIT.
//! Rows are `(alias, command)`; edits are buffered in `alias_editor_rows` and
//! committed to the alias table on Apply. Mirrors the shortcut editor:
//! transient draft row with explicit accept, duplicate/unknown validation,
//! reset-to-defaults with confirmation, apply-and-exit, alias count, and an
//! unsaved-changes guard on close.
use crate::app::Message;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::editor_table::{danger_input_style, danger_text_style, EDITOR_TABLE_GUTTER};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Length, Theme};
/// Which column of an alias row a text edit targets.
#[derive(Clone, Copy, Debug)]
pub enum AliasField {
Alias,
Command,
}
/// Build the alias editor content. `rows` is the live working buffer.
pub fn view_window<'a>(
rows: &'a [(String, String)],
import_report: &'a str,
pending_add: bool,
reset_confirm: bool,
duplicate_aliases: &rustc_hash::FxHashSet<String>,
duplicate_conflicts: &[(String, String)],
unknown_commands: &rustc_hash::FxHashSet<String>,
close_confirm: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let title = text(t!("Command Aliases")).size(15);
let hint = text(t!(
"Click + Add alias, type the alias and command, accept the row, then Apply. Esc cancels the pending row."
))
.size(11)
.style(muted_style);
// Right gutter reserved so the scrollbar has its own lane and never sits on
// top of the row delete (✕) buttons. Applied to both the header and the
// scrollable rows so the columns stay aligned.
let gutter = iced::Padding {
top: 0.0,
right: EDITOR_TABLE_GUTTER,
bottom: 0.0,
left: 0.0,
};
let head = container(
row![
container(text(t!("Alias")).size(11).style(muted_style)).width(Length::Fixed(120.0)),
container(text(t!("Command")).size(11).style(muted_style)).width(sizing.width),
Space::new().width(Length::Fixed(62.0)),
]
.spacing(8),
)
.padding(gutter);
let mut list = column![].spacing(3);
for (idx, (alias, cmd)) in rows.iter().enumerate() {
let alias_key = alias.trim().to_uppercase();
let duplicate = !alias_key.is_empty() && duplicate_aliases.contains(alias_key.as_str());
let alias_box = text_input(t!("alias").as_ref(), alias)
.on_input(move |v| Message::AliasEditorInput {
idx,
field: AliasField::Alias,
value: v,
})
.size(13)
.padding([3, 6])
.width(Length::Fixed(120.0));
let alias_box = if duplicate {
alias_box.style(danger_input_style)
} else {
alias_box
};
let unknown_command = !cmd.trim().is_empty() && unknown_commands.contains(cmd.trim());
let cmd_box = text_input(t!("command").as_ref(), cmd)
.on_input(move |v| Message::AliasEditorInput {
idx,
field: AliasField::Command,
value: v,
})
.size(13)
.padding([3, 6])
.width(sizing.width);
let cmd_box = if unknown_command {
cmd_box.style(danger_input_style)
} else {
cmd_box
};
// Draft rows get a check (finish the addition, without applying) and
// a cancel ✕; committed rows get the trash bin.
let remove = if pending_add && idx == 0 {
let done_ok = !alias.trim().is_empty()
&& !cmd.trim().is_empty()
&& !duplicate
&& !unknown_commands.contains(cmd.trim());
row![
button(crate::ui::icons::themed_success_text(
crate::ui::icons::CHECK,
12.0,
))
.on_press_maybe(done_ok.then_some(Message::AliasEditorDraftAccept))
.padding([2, 6])
.style(button::success),
button(crate::ui::icons::themed_danger_text(
crate::ui::icons::CLOSE,
12.0,
))
.on_press(Message::AliasEditorDraftCancel)
.padding([2, 6])
.style(button::danger),
]
.spacing(4)
.align_y(iced::Center)
} else {
row![button(crate::ui::icons::themed_danger_text(
crate::ui::icons::TRASH,
12.0,
))
.on_press(Message::AliasEditorRemove(idx))
.padding([2, 6])
.style(button::danger)]
.align_y(iced::Center)
};
list = list.push(
row![alias_box, cmd_box, remove]
.spacing(8)
.align_y(iced::Center),
);
}
// While a draft row is pending, the add button doubles as the visible
// cancel affordance (Esc works too).
let add = if pending_add {
button(text(t!("Cancel add (Esc)")).size(12))
.on_press(Message::AliasEditorDraftCancel)
.padding([4, 10])
.style(button::danger)
} else {
button(text(t!("+ Add alias")).size(12))
.on_press(Message::AliasEditorAdd)
.padding([4, 10])
.style(button::secondary)
};
// The reset confirmation replaces the alias count so the warning
// gets the whole left side of the bar.
let stats = if reset_confirm {
row![Space::new().width(Length::Fixed(0.0))]
} else {
row![text(format!("{}: {}", t!("Number of aliases"), rows.len()))
.size(12)
.style(muted_style)]
};
// Reset asks for confirmation in place: the add/reset buttons are
// replaced by the question with Yes / No.
let (reset_area, add_area) = if reset_confirm {
(
row![
text(t!(
"Are you sure you want to reset? You will lose all your current aliases!"
))
.size(12)
.style(danger_text_style),
button(text(t!("Yes, reset")).size(12))
.on_press(Message::AliasEditorResetConfirm)
.padding([4, 10])
.style(button::danger),
button(text(t!("No")).size(12))
.on_press(Message::AliasEditorResetDeny)
.padding([4, 10])
.style(button::secondary),
]
.spacing(8)
.align_y(iced::Center),
row![Space::new().width(Length::Fixed(0.0))],
)
} else {
(
row![button(text(t!("Reset to default")).size(12))
.on_press(Message::AliasEditorResetAsk)
.padding([4, 10])
.style(button::secondary)]
.spacing(8)
.align_y(iced::Center),
row![
add,
button(text(t!("Import PGP…")).size(12))
.on_press_maybe((!pending_add).then_some(Message::AliasEditorImport))
.padding([4, 10])
.style(button::secondary)
]
.spacing(8),
)
};
// Apply — primary action; commits the rows to ocad.pgp and stays open.
let apply = button(text(t!("Apply")).size(12))
.on_press(Message::AliasEditorApply)
.padding([4, 16])
.style(button::primary);
let apply_exit = button(text(t!("Apply & Exit")).size(12))
.on_press(Message::AliasEditorApplyExit)
.padding([4, 16])
.style(button::primary);
// The reset confirmation takes over the whole action bar.
let apply_area = if reset_confirm {
row![Space::new().width(Length::Fixed(0.0))]
} else {
row![apply, apply_exit].spacing(8).align_y(iced::Center)
};
// Persistent validation warnings: shown until every conflicting alias is
// resolved (edited or a row removed) and every command is runnable.
let mut conflict_banner = column![].spacing(2);
for (alias, command) in duplicate_conflicts {
conflict_banner = conflict_banner.push(
text(crate::tf!(
"Alias already used for command: {} → {}",
alias,
command
))
.size(12)
.style(danger_text_style),
);
}
for command in unknown_commands {
conflict_banner = conflict_banner.push(
text(crate::tf!("Unknown command: {}", command))
.size(12)
.style(danger_text_style),
);
}
let content = container(
column![
title,
hint,
text(import_report).size(11).style(muted_style),
Space::new().height(6),
head,
scrollable(container(list).padding(gutter)).height(sizing.height),
Space::new().height(6),
scrollable(conflict_banner).height(Length::Fit.max(72)),
Space::new().height(4),
row![add_area, reset_area,].spacing(8).align_y(iced::Center),
row![stats, Space::new().width(Length::Fill), apply_area]
.spacing(8)
.align_y(iced::Center),
]
.spacing(6)
.width(sizing.width)
.height(sizing.height),
)
.padding(12)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
});
// Unsaved-changes guard: closing with un-applied rows stacks a dimmed
// shield and a confirmation panel on top of the editor.
if !close_confirm {
return content.into();
}
let shield = iced::widget::mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.strongest.color.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(Message::AliasEditorCloseKeep)
.interaction(iced::mouse::Interaction::Idle);
let panel = container(
column![
text(t!("Unsaved changes will be discarded.")).size(14),
Space::new().height(10),
row![
button(text(t!("Discard & close")).size(12))
.on_press(Message::AliasEditorCloseDiscard)
.padding([4, 12])
.style(button::danger),
button(text(t!("Keep editing")).size(12))
.on_press(Message::AliasEditorCloseKeep)
.padding([4, 12])
.style(button::secondary),
]
.spacing(8),
]
.spacing(4),
)
.padding(16)
.width(Length::Fixed(320.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: iced::Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
let centered = container(panel)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center);
iced::widget::stack![content, iced::widget::opaque(shield), centered].into()
}