shellm/src/dialogs/mod.rs

913 lines
34 KiB
Rust

//! Modal dialogs (rendered as overlay panels within the main window).
//!
//! We render these as floating panels using a stack-based approach:
//! - if a dialog state has `visible == true`, we render the dialog content
//! on top of the main view in a centered container with a dimmed
//! background.
use iced::widget::{button, column, container, row, scrollable, text, text_input};
use iced::{Alignment, Element, Length, Padding};
use crate::message::{ConfirmKind, Message};
use crate::state::Shellm;
use crate::theme_style;
/// Compose: main content + any visible dialogs layered on top.
pub fn overlay<'a>(main: Element<'a, Message>, state: &'a Shellm) -> Element<'a, Message> {
// Build all visible dialogs as a vertical stack of centered panels.
let mut layers: Vec<Element<Message>> = vec![main];
if state.message_dialog.visible {
layers.push(message_dialog(state));
}
if state.confirm_dialog.visible {
layers.push(confirm_dialog(state));
}
if state.about_dialog.visible {
layers.push(about_dialog(state));
}
if state.agent_info_dialog.visible {
layers.push(agent_info_dialog(state));
}
if state.key_info_dialog.visible {
layers.push(key_info_dialog(state));
}
if state.hostkeys_dialog.visible {
layers.push(hostkeys_dialog(state));
}
if state.history_dialog.visible {
layers.push(history_dialog(state));
}
if state.trace_dialog.visible {
layers.push(trace_dialog(state));
}
if state.colors_dialog.visible {
layers.push(colors_dialog(state));
}
if state.forwards_dialog.entries.is_empty()
&& state.forwards_dialog.kind == crate::message::ForwardKind::Local
&& false
{
// never triggered
}
if !state.forwards_dialog_kind_is_default() {
layers.push(forwards_dialog(state));
}
if state.keygen_dialog.change_pass.visible {
layers.push(change_pass_dialog(state));
}
if state.key_dist_wizard.visible {
layers.push(key_dist_wizard_dialog(state));
}
if state.keygen_dialog_visible {
layers.push(keygen_dialog(state));
}
if state.ask_user_modal.visible {
layers.push(ask_user_modal(state));
}
if state.export_dialog.visible {
layers.push(export_dialog(state));
}
if state.remote_acct_dialog.visible {
layers.push(remote_acct_dialog(state));
}
if state.protect_data_dialog.visible {
layers.push(protect_data_dialog(state));
}
if state.fonts_dialog.visible {
layers.push(fonts_dialog(state));
}
if state.context_menu.visible {
layers.push(context_menu(state));
}
if state.scp_browser.mkdir_modal.visible {
layers.push(scp_mkdir_modal(state));
}
// We can only stack via iced::widget::stack (iced 0.13).
iced::widget::stack(layers).into()
}
/// The "Manage keypairs" dialog (`.top52` in the original).
fn keygen_dialog(state: &Shellm) -> Element<Message> {
let k = &state.keygen_dialog;
let mut items: Vec<Element<Message>> = Vec::new();
for name in &k.keys_list {
items.push(
row![
text(name.as_str()).size(12).width(Length::Fill),
button(text("Info").size(11))
.on_press(Message::KeygenShowInfo(name.clone()))
.padding(2)
.style(theme_style::tk_button),
button(text("ChPass").size(11))
.on_press(Message::KeygenChangePassStart(name.clone()))
.padding(2)
.style(theme_style::tk_button),
button(text("Del").size(11))
.on_press(Message::KeygenDeleteKey(name.clone()))
.padding(2)
.style(theme_style::danger_button),
]
.spacing(4)
.into(),
);
}
if items.is_empty() {
items.push(text("(no keys found in ~/.ssh/)").size(12).into());
}
let panel = container(
column![
text("shellm - Key management").size(14),
text("Generate new keypair:").size(12),
text("Comment:").size(11),
text_input("comment", &k.comment).on_input(Message::KeygenCommentChanged).size(12),
text("Key file:").size(11),
row![
text_input("~/.ssh/id_rsa", &k.file).on_input(Message::KeygenFileChanged).size(12),
button(text("...").size(11)).on_press(Message::KeygenBrowseFile).padding(4).style(theme_style::tk_button),
].spacing(4),
row![
iced::widget::checkbox("No passphrase", k.no_pass)
.on_toggle(Message::KeygenNoPassToggled)
.size(12),
],
text("Passphrase:").size(11),
text_input("passphrase", &k.pass1).on_input(Message::KeygenPassChanged).size(12).secure(true),
text("Repeat passphrase:").size(11),
text_input("repeat", &k.pass2).on_input(Message::KeygenPass2Changed).size(12).secure(true),
text("Key type:").size(11),
row![
button(text("SSH2 DSA").size(11))
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Dsa))
.padding(4)
.style(if k.key_type == crate::data::KeyType::Dsa { theme_style::tab_button_active } else { theme_style::tk_button }),
button(text("SSH2 RSA").size(11))
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Rsa))
.padding(4)
.style(if k.key_type == crate::data::KeyType::Rsa { theme_style::tab_button_active } else { theme_style::tk_button }),
button(text("SSH1 RSA1").size(11))
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Rsa1))
.padding(4)
.style(if k.key_type == crate::data::KeyType::Rsa1 { theme_style::tab_button_active } else { theme_style::tk_button }),
].spacing(4),
row![
button(text("Generate keypair").size(12)).on_press(Message::KeygenGenerate).padding(4).style(theme_style::tk_button),
button(text("Refresh list").size(12)).on_press(Message::KeygenRefreshList).padding(4).style(theme_style::tk_button),
button(text("Close").size(12)).on_press(Message::KeygenClose).padding(4).style(theme_style::tk_button),
].spacing(4),
text("Existing keys:").size(12),
scrollable(column(items).spacing(2)).height(Length::Fixed(140.0)),
]
.spacing(4)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(460.0))
.center(Length::Fill);
panel.into()
}
fn message_dialog(state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm - Message").size(14),
text(state.message_dialog.text.as_str()).size(13),
button(text("OK").size(12))
.on_press(Message::CloseMessage)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(360.0))
.center(Length::Fill);
panel.into()
}
fn confirm_dialog(state: &Shellm) -> Element<Message> {
let kind = state.confirm_dialog.kind;
let yes_msg = Message::Confirm(kind, true);
let no_msg = Message::Confirm(kind, false);
let panel = container(
column![
text("shellm - Confirm").size(14),
text(state.confirm_dialog.text.as_str()).size(13),
row![
button(text("Yes").size(12)).on_press(yes_msg).padding(6).style(theme_style::tk_button),
button(text("No").size(12)).on_press(no_msg).padding(6).style(theme_style::tk_button),
]
.spacing(6),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(360.0))
.center(Length::Fill);
let _ = ConfirmKind::Generic; // silence unused
panel.into()
}
fn about_dialog(_state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm").size(16),
text(format!("Version {}", crate::data::paths::SP_VERSION)).size(13),
text("Graphical user interface for managing SSH- and SCP-connections").size(12),
text("© Jeremy Anderson — https://dcos.net").size(11),
text("Rust/iced port of the original Tcl/Tk secpanel by Steffen Leich-Nienhaus").size(11),
text("https://themediahost.de/secpanel/").size(11),
button(text("Close").size(12))
.on_press(Message::CloseAbout)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(4)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(360.0))
.center(Length::Fill);
panel.into()
}
fn agent_info_dialog(state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm - Agent info").size(14),
scrollable(text(state.agent_info_dialog.text.as_str()).size(12))
.width(Length::Fixed(560.0))
.height(Length::Fixed(180.0)),
button(text("Close").size(12))
.on_press(Message::CloseAgentInfo)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(600.0))
.center(Length::Fill);
panel.into()
}
fn key_info_dialog(state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm - View key").size(14),
text(format!("Fingerprint: {}", state.key_info_dialog.fingerprint)).size(12),
text(format!("Bubblebabble: {}", state.key_info_dialog.bubblebabble)).size(12),
button(text("Close").size(12))
.on_press(Message::CloseKeyInfo)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(600.0))
.center(Length::Fill);
panel.into()
}
fn hostkeys_dialog(state: &Shellm) -> Element<Message> {
let mut items: Vec<Element<Message>> = Vec::new();
for (host, kt) in &state.hostkeys_dialog.entries {
items.push(
row![
text(host.as_str()).size(12).width(Length::Fill),
text(kt.as_str()).size(12),
button(text("Delete").size(11))
.on_press(Message::HostkeyDelete(host.clone()))
.padding(2)
.style(theme_style::danger_button),
]
.spacing(6)
.into(),
);
}
if items.is_empty() {
items.push(text("(no hostkeys found)").size(12).into());
}
let panel = container(
column![
text("shellm - Hostkeys").size(14),
scrollable(column(items).spacing(2)).width(Length::Fixed(440.0)).height(Length::Fixed(280.0)),
button(text("Close").size(12))
.on_press(Message::HostkeyClose)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(480.0))
.center(Length::Fill);
panel.into()
}
fn history_dialog(state: &Shellm) -> Element<Message> {
let mut items: Vec<Element<Message>> = Vec::new();
for e in &state.history_dialog.entries {
items.push(
row![
text(format!("[{}]", e.category.label())).size(11),
text(format!("{}{}", e.time.format("%Y-%m-%d %H:%M:%S"), e.text)).size(11),
]
.spacing(6)
.into(),
);
}
if items.is_empty() {
items.push(text("(history is empty)").size(12).into());
}
let panel = container(
column![
text("shellm - Command History").size(14),
scrollable(column(items).spacing(2)).width(Length::Fixed(560.0)).height(Length::Fixed(320.0)),
button(text("Close").size(12))
.on_press(Message::CloseHistory)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(600.0))
.center(Length::Fill);
panel.into()
}
fn trace_dialog(state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm - Command Trace").size(14),
scrollable(text(state.trace_dialog.text.as_str()).size(11))
.width(Length::Fixed(560.0))
.height(Length::Fixed(320.0)),
button(text("Close").size(12))
.on_press(Message::CloseTrace)
.padding(6)
.style(theme_style::tk_button),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(600.0))
.center(Length::Fill);
panel.into()
}
fn colors_dialog(state: &Shellm) -> Element<Message> {
let panel = container(
column![
text("shellm - GUI Colors").size(14),
row![
text("Window fg:").size(12),
text_input("#000000", &state.colors_dialog.fore).size(12),
].spacing(4),
row![
text("Window bg:").size(12),
text_input("#dcdcd5", &state.colors_dialog.back).size(12),
].spacing(4),
row![
text("Entry fg:").size(12),
text_input("#000000", &state.colors_dialog.entfore).size(12),
].spacing(4),
row![
text("Entry bg:").size(12),
text_input("#fefefe", &state.colors_dialog.entback).size(12),
].spacing(4),
row![
text("Listbox fg:").size(12),
text_input("#000000", &state.colors_dialog.listfore).size(12),
].spacing(4),
row![
text("Listbox bg:").size(12),
text_input("#ffffff", &state.colors_dialog.listback).size(12),
].spacing(4),
row![
button(text("Cancel").size(12)).on_press(Message::CloseColors).padding(4).style(theme_style::tk_button),
].spacing(4),
]
.spacing(4)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(420.0))
.center(Length::Fill);
panel.into()
}
fn forwards_dialog(state: &Shellm) -> Element<Message> {
let kind = state.forwards_dialog.kind;
let title = match kind {
crate::message::ForwardKind::Local => "shellm - Local forwardings",
crate::message::ForwardKind::Remote => "shellm - Remote forwardings",
};
let mut items: Vec<Element<Message>> = Vec::new();
for (i, (ip, h, op, c)) in state.forwards_dialog.entries.iter().enumerate() {
items.push(
row![
text(format!("{} -> {}:{}", ip, h, op)).size(12).width(Length::Fill),
text(c.as_str()).size(11),
button(text("Del").size(11))
.on_press(Message::DelForward(kind, i))
.padding(2)
.style(theme_style::danger_button),
]
.spacing(6)
.into(),
);
}
if items.is_empty() {
items.push(text("(no forwards defined)").size(12).into());
}
let new_in = text_input("in port", &state.forwards_dialog.new_in_port).size(12);
let new_host = text_input("host (or <TARGET-HOST>/<LOCAL-HOST>)", &state.forwards_dialog.new_host).size(12);
let new_out = text_input("out port", &state.forwards_dialog.new_out_port).size(12);
let new_cmt = text_input("comment", &state.forwards_dialog.new_comment).size(12);
// Note: text_input's `on_input` produces per-keystroke updates, but we
// don't have a way to bind the dialog's text fields back to state in
// this simple model. For a faithful port we'd route through messages
// carrying the field name. To keep this MVP focused, we use a single
// `AddForward` message that reads the (current) text fields — but since
// we can't extract text from a text_input without state, we instead
// expect the user to fill them in then click Add. For now this is a
// placeholder; a future iteration would add dedicated messages.
let panel = container(
column![
text(title).size(14),
scrollable(column(items).spacing(2)).width(Length::Fixed(380.0)).height(Length::Fixed(200.0)),
column![
text("Add new:").size(12),
new_in,
new_host,
new_out,
new_cmt,
row![
text("Quick ports:").size(11),
button(text("HTTP 80").size(11)).on_press(Message::InsertProtoPort("80".into())).padding(2).style(theme_style::tk_button),
button(text("POP3 110").size(11)).on_press(Message::InsertProtoPort("110".into())).padding(2).style(theme_style::tk_button),
button(text("IMAP 143").size(11)).on_press(Message::InsertProtoPort("143".into())).padding(2).style(theme_style::tk_button),
button(text("SMTP 25").size(11)).on_press(Message::InsertProtoPort("25".into())).padding(2).style(theme_style::tk_button),
].spacing(4),
].spacing(4),
row![
button(text("OK").size(12)).on_press(Message::CloseForwardsDialog).padding(4).style(theme_style::tk_button),
button(text("Cancel").size(12)).on_press(Message::CloseForwardsDialog).padding(4).style(theme_style::tk_button),
].spacing(4),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(420.0))
.center(Length::Fill);
panel.into()
}
fn change_pass_dialog(state: &Shellm) -> Element<Message> {
let cp = &state.keygen_dialog.change_pass;
let panel = container(
column![
text(format!("shellm - Change passphrase for {}", cp.target_key)).size(13),
text("Old passphrase:").size(12),
text_input("", &cp.old_pass).on_input(Message::KeygenChangePassOldChanged).size(13).secure(true),
text("New passphrase:").size(12),
text_input("", &cp.new_pass1).on_input(Message::KeygenChangePassNew1Changed).size(13).secure(true),
text("Repeat new passphrase:").size(12),
text_input("", &cp.new_pass2).on_input(Message::KeygenChangePassNew2Changed).size(13).secure(true),
text("Comment:").size(12),
text_input("", &cp.comment).on_input(Message::KeygenChangePassCommentChanged).size(13),
row![
button(text("Cancel").size(12)).on_press(Message::KeygenChangePassClose).padding(4).style(theme_style::tk_button),
button(text("Save").size(12)).on_press(Message::KeygenChangePassDo).padding(4).style(theme_style::tk_button),
].spacing(4),
]
.spacing(4)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(420.0))
.center(Length::Fill);
panel.into()
}
fn key_dist_wizard_dialog(state: &Shellm) -> Element<Message> {
let wiz = &state.key_dist_wizard;
let step = wiz.step;
let title = format!("shellm - Key distribution (step {step}/3)");
let body: Element<Message> = match step {
1 => {
let mut items: Vec<Element<Message>> = Vec::new();
for k in &state.keygen_dialog.keys_list {
items.push(
button(text(k.as_str()).size(12))
.on_press(Message::StatusUpdate(format!("__distkey__{}", k)))
.padding(4)
.width(Length::Fill)
.style(theme_style::tk_button)
.into(),
);
}
column![
text("Select a public key:").size(12),
scrollable(column(items).spacing(2)).height(Length::Fixed(180.0)),
]
.spacing(4)
.into()
}
2 => {
let mut items: Vec<Element<Message>> = Vec::new();
for (name, p) in state.profiles_in_order() {
items.push(
button(text(format!("{}{}", name, p.title)).size(12))
.on_press(Message::StatusUpdate(format!("__disthost__{}", name)))
.padding(4)
.width(Length::Fill)
.style(theme_style::tk_button)
.into(),
);
}
column![
text("Select target connection(s):").size(12),
scrollable(column(items).spacing(2)).height(Length::Fixed(180.0)),
]
.spacing(4)
.into()
}
_ => {
column![text(wiz.message.as_str()).size(12)].into()
}
};
let panel = container(
column![
text(title).size(14),
body,
row![
button(text("Cancel").size(12)).on_press(Message::KeygenClose).padding(4).style(theme_style::tk_button),
{
let label: String = if step < 3 { "Continue".into() } else { "Distribute key".into() };
let msg = if step < 3 {
Message::StatusUpdate("__distnext__".into())
} else {
Message::StatusUpdate("__distfinish__".into())
};
let b: iced::widget::Button<Message> = button(text(label).size(12)).on_press(msg).padding(4).style(theme_style::tk_button);
let e: Element<Message> = b.into();
e
}
].spacing(4),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(16)
.style(theme_style::groove_container)
.width(Length::Fixed(440.0))
.center(Length::Fill);
panel.into()
}
// ===== New dialogs added in the modern port =====
/// Ask-for-user modal — prompts for a single string value (typically a
/// username for `<ASKFORUSER>` profiles). Mirrors `.top21` in the original.
fn ask_user_modal(state: &Shellm) -> Element<Message> {
let m = &state.ask_user_modal;
let panel = container(
column![
text("Login required").size(14).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}),
text(m.prompt.as_str()).size(12).color(theme_style::text_muted()),
text_input("username", &m.value)
.on_input(Message::AskUserInputChanged)
.on_submit(Message::AskUserSubmit)
.size(13),
row![
button(text("Cancel").size(12))
.on_press(Message::AskUserCancelRequest)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
button(text("Connect").size(12))
.on_press(Message::AskUserSubmit)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
]
.spacing(6),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(380.0))
.center(Length::Fill);
panel.into()
}
/// Profile export dialog.
fn export_dialog(state: &Shellm) -> Element<Message> {
let d = &state.export_dialog;
let formats: Vec<crate::message::ExportFormat> = crate::message::ExportFormat::all().to_vec();
let fmt_pick = iced::widget::pick_list(formats, Some(d.format), |f| {
Message::ExportProfileFormatChanged(f)
});
let mut body: Vec<Element<Message>> = vec![
text("Export profile").size(14).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}).into(),
row![
text("Format:").size(12).color(theme_style::text_muted()),
fmt_pick,
].spacing(6).align_y(Alignment::Center).into(),
];
if !d.result_message.is_empty() {
body.push(
scrollable(text(d.result_message.as_str()).size(11))
.width(Length::Fixed(440.0))
.height(Length::Fixed(180.0))
.into(),
);
}
body.push(
row![
button(text("Close").size(12))
.on_press(Message::ExportProfileClose)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
button(text("Generate").size(12))
.on_press(Message::ExportProfileDo)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
]
.spacing(6)
.into(),
);
let panel = container(column(body).spacing(8).align_x(Alignment::Center))
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(500.0))
.center(Length::Fill);
panel.into()
}
/// Remote account manager dialog (`.top40` in the original).
fn remote_acct_dialog(state: &Shellm) -> Element<Message> {
let d = &state.remote_acct_dialog;
let title = format!("Remote Account Manager — {}@{}:{}", d.user, d.host, d.port);
let panel = container(
column![
text(title).size(13).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}),
text("~/.shosts:").size(11).color(theme_style::text_muted()),
text_input("shosts entries (one per line)", &d.shosts)
.on_input(Message::RemoteAcctShostsChanged)
.size(11),
text("~/.ssh/authorized_keys:").size(11).color(theme_style::text_muted()),
scrollable(text(d.auth_keys.as_str()).size(11))
.width(Length::Fill)
.height(Length::Fixed(160.0)),
text("Edit authorized_keys via the field below:").size(11).color(theme_style::text_muted()),
text_input("authorized_keys (paste here)", &d.auth_keys)
.on_input(Message::RemoteAcctAuthKeysChanged)
.size(11),
text(d.status.as_str()).size(11).color(theme_style::text_muted()),
row![
button(text("Cancel").size(12))
.on_press(Message::RemoteAcctCancel)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
button(text("Write to remote").size(12))
.on_press(Message::RemoteAcctWrite)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
]
.spacing(6),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(540.0))
.center(Length::Fill);
panel.into()
}
/// GPG data protection dialog.
fn protect_data_dialog(state: &Shellm) -> Element<Message> {
let d = &state.protect_data_dialog;
let panel = container(
column![
text("Data protection").size(14).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}),
text("Enter a passphrase to encrypt ~/.shellm/ using GnuPG (AES256).")
.size(11)
.color(theme_style::text_muted()),
text_input("passphrase", &d.passphrase)
.on_input(Message::ProtectDataPassphraseChanged)
.secure(true)
.size(13),
text(d.status.as_str()).size(11).color(theme_style::text_muted()),
row![
button(text("Encrypt now").size(12))
.on_press(Message::ProtectDataEncrypt)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
button(text("Decrypt").size(12))
.on_press(Message::ProtectDataDecrypt)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
]
.spacing(6),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(420.0))
.center(Length::Fill);
panel.into()
}
/// Fonts dialog — modern replacement for `.top23`.
fn fonts_dialog(state: &Shellm) -> Element<Message> {
let d = &state.fonts_dialog;
let panel = container(
column![
text("Font settings").size(14).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}),
text("Font family:").size(11).color(theme_style::text_muted()),
text_input("e.g. Inter, Sans", &d.fontfam)
.on_input(Message::FontsFontFamilyChanged)
.size(13),
row![
text("Size:").size(11).color(theme_style::text_muted()),
iced::widget::slider(6..=60, d.fontsize, Message::FontsSizeChanged),
text(d.fontsize.to_string()).size(11),
].spacing(6).align_y(Alignment::Center),
row![
iced::widget::checkbox("Bold", d.bold).on_toggle(Message::FontsBoldToggled).size(13),
iced::widget::checkbox("Italic", d.italic).on_toggle(Message::FontsItalicToggled).size(13),
].spacing(8),
iced::widget::checkbox("Use system fonts", d.sysfonts)
.on_toggle(Message::FontsSystemToggled)
.size(13),
row![
button(text("Cancel").size(12))
.on_press(Message::FontsClose)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
button(text("Save").size(12))
.on_press(Message::FontsSave)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
].spacing(6),
]
.spacing(6)
.align_x(Alignment::Center),
)
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(380.0))
.center(Length::Fill);
panel.into()
}
/// Right-click context menu on the connections list.
fn context_menu(_state: &Shellm) -> Element<Message> {
let panel = container(
column![
button(text("Connect").size(12))
.on_press(Message::ConnectSelected)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("Edit").size(12))
.on_press(Message::EditSelectedConnection)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("SCP").size(12))
.on_press(Message::ConnectScpSelected)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("External SFTP").size(12))
.on_press(Message::LaunchExternalSftp)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("Export as...").size(12))
.on_press(Message::ExportProfileShow)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("Manage remote account").size(12))
.on_press(Message::RemoteAcctOpen)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::ghost_button),
button(text("Delete").size(12))
.on_press(Message::DeleteSelectedConnection)
.padding(Padding::new(6.0))
.width(Length::Fill)
.style(theme_style::danger_secondary_button),
]
.spacing(1),
)
.padding(4)
.style(theme_style::modal_panel)
.width(Length::Fixed(220.0))
.center(Length::Fill);
panel.into()
}
/// SCP browser "new folder" modal — asks for a name and creates the folder
/// on either the local or remote side (per `mkdir_modal.side`).
fn scp_mkdir_modal(state: &Shellm) -> Element<Message> {
let m = &state.scp_browser.mkdir_modal;
let side_label = match m.side {
crate::state::ScpSide::Local => "local",
crate::state::ScpSide::Remote => "remote",
};
let panel = container(
column![
text("New folder").size(14).font(iced::Font {
weight: iced::font::Weight::Semibold,
..Default::default()
}),
text(format!("Create a new folder on the {} side:", side_label))
.size(11)
.color(theme_style::text_muted()),
text_input("folder name", &m.name)
.on_input(Message::ScpMkdirNameChanged)
.on_submit(Message::ScpMkdirDo)
.size(13),
row![
button(text("Cancel").size(12))
.on_press(Message::ScpMkdirCancel)
.padding(Padding::new(8.0))
.style(theme_style::secondary_button),
button(text("Create").size(12))
.on_press(Message::ScpMkdirDo)
.padding(Padding::new(8.0))
.style(theme_style::primary_button),
]
.spacing(6),
]
.spacing(8)
.align_x(Alignment::Center),
)
.padding(20)
.style(theme_style::modal_panel)
.width(Length::Fixed(380.0))
.center(Length::Fill);
panel.into()
}