Files
feresaria/shell/ferese-settings/src/pages.rs
T

806 lines
34 KiB
Rust

use super::{
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row,
schema, scrollable, text_input, visuals, widget,
};
pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| {
self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height, size.width)
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn page_layout(&self, compact: bool, height: f32, width: f32) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let sidebar = column([])
.spacing(4)
.push(
widget::mouse_area(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::brand_icon())
.push(
self.label(ferese_config::tr!("Ferese"), 14.)
.class(cosmic::theme::Text::Color(palette.text)),
)
.width(Length::Fill),
)
.on_drag(Message::DragWindow)
.on_double_click(Message::ToggleMaximize)
.interaction(cosmic::iced::mouse::Interaction::Grab),
)
.push(widget::Space::new().height(14))
.push(
text_input(ferese_config::tr!("Search settings"), self.search.clone())
.id(widget::Id::new("settings-search"))
.on_input(Message::Search)
.font(self.font)
.style(visuals::input_style(palette))
.size(12),
)
.push(widget::Space::new().height(8));
let mut navigation = column([]).spacing(20);
for (title, pages) in Page::NAVIGATION {
let mut section = column([]).spacing(3).push(
container(
self.label(
ferese_config::locale::text(if title == "Appearance" {
"Settings"
} else {
title
}),
visuals::SECTION_TITLE_SIZE,
)
.font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Bold,
..self.font
})
.class(cosmic::theme::Text::Color(palette.muted)),
)
.padding([0, 8]),
);
for &page in pages {
let selected = self.page.navigation_page() == page;
let indicator = container(widget::Space::new().width(2).height(14))
.id(widget::Id::new(format!(
"settings-nav-marker-{}",
page as usize
)))
.class(visuals::surface(
if selected {
palette.accent
} else {
cosmic::iced::Color::TRANSPARENT
},
1.,
));
let icon = container(visuals::icon(
page,
if selected {
palette.accent
} else {
palette.muted
},
))
.id(widget::Id::new(format!(
"settings-nav-icon-{}",
page as usize
)));
let label = container(self.label(page.title(), 13.)).id(widget::Id::new(format!(
"settings-nav-label-{}",
page as usize
)));
let content = container(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(indicator)
.push(icon)
.push(label),
)
.id(widget::Id::new(format!(
"settings-nav-content-{}",
page as usize
)));
// The button lays its child out at its top padding. Center the
// entire row inside its 36 px hit area, as well as row children.
section =
section.push(
button::custom(
container(content)
.center_y(Length::Fill)
.width(Length::Fill),
)
.id(widget::Id::new(format!(
"settings-nav-button-{}",
page as usize
)))
.name(page.title())
.width(Length::Fill)
.height(36)
.padding([0, 8])
.class(visuals::navigation_style(palette, selected))
.on_press(Message::Page(if selected { self.page } else { page })),
);
}
navigation = navigation.push(section);
}
let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill)))
.id("settings-sidebar")
.width(if compact { 244 } else { 208 })
.height(Length::Fill)
.padding([24, 12])
.class(visuals::sidebar(palette));
let sidebar = self.window_chrome(sidebar);
let mut heading = row([]).align_y(Alignment::Center).spacing(16).push(
self.label(
if self.search.is_empty() {
self.page.navigation_page().title()
} else {
ferese_config::tr!("Search")
},
visuals::PAGE_TITLE_SIZE,
)
.font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Medium,
..self.font
})
.width(Length::Fill),
);
if self.page == Page::Theme && self.search.is_empty() {
let automatic = self.draft.string("theme.mode", "dark") == "auto";
heading = heading.push(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(ferese_config::tr!("Auto"), 12.))
.push(
ferese_theme::controls::switch(automatic, palette)
.name(if automatic {
"Auto appearance: on"
} else {
"Auto appearance: off"
})
.on_press(Message::AutoAppearance(!automatic)),
),
);
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
let source = if scheduled { "Schedule" } else { "System" };
let label = if automatic {
format!(
"{source} · {}",
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
"Light"
} else {
"Dark"
},
)
} else {
source.to_owned()
};
let explanation = if scheduled {
format!(
"Auto uses Light from {} and Dark from {} ({})",
self.draft.string("theme.schedule.light_at", "07:00"),
self.draft.string("theme.schedule.dark_at", "19:00"),
self.draft.string("theme.schedule.timezone", "system"),
)
} else {
"Auto follows the GTK/GNOME appearance preference".to_owned()
};
heading = heading.push(widget::tooltip(
self.settings_button(
&label,
"M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 7v5l3 2",
Some(Message::AutoDetails(!self.auto_details)),
self.auto_details,
),
self.label(explanation, 12.),
widget::tooltip::Position::Bottom,
));
}
let mut body = column([]).spacing(20);
if !self.search.is_empty() {
let pages = || {
Page::NAVIGATION
.into_iter()
.flat_map(|(_, pages)| pages.iter().copied())
};
if !pages().any(|p| {
p.matches(&self.search)
|| (p == Page::Configuration && self.options.matches(&self.search))
}) {
body = body.push(self.note(ferese_config::tr!(
"No matching settings. Try wallpaper, keyboard, or motion."
)));
}
for page in pages().filter(|p| {
p.matches(&self.search)
|| (*p == Page::Configuration && self.options.matches(&self.search))
}) {
let destination = page.search_destination(&self.search);
body = body.push(
button::custom(
column([])
.spacing(3)
.push(self.label(page.title(), 16.))
.push(self.label(destination.subtitle(), 12.)),
)
.width(Length::Fill)
.padding(10)
.class(visuals::button_style(palette, false))
.on_press(Message::Page(destination)),
);
}
} else {
if self.page == Page::Appearance {
body = body.push(
self.settings_hub((width - if compact { 0. } else { 208. }).min(1280.) - 54.),
);
}
if self.page == Page::Configuration {
body = body.push(self.all_options_view());
}
if self.page == Page::Theme {
let split = visuals::split(&self.draft);
if split {
body = body
.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
body =
body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
} else {
body = body.push(self.theme_gallery(None));
}
body = body.push(
widget::checkbox(split)
.label("Use different themes for light and dark")
.text_size(12)
.font(self.font)
.on_toggle(Message::SplitThemes),
);
if let Some(note) = &self.resolved.fallback_note {
body = body.push(self.note(note));
}
}
if self.page == Page::Wallpaper {
body = body.push(self.wallpaper_controls());
}
if self.page == Page::Bar
&& self.panel_page != crate::panel_controls::PanelPage::Arrange
{
body = body.push(self.panel_controls());
}
let fields = if self.page == Page::Bar {
Vec::new()
} else if self.page == Page::Wallpaper {
self.wallpaper_fields()
} else {
schema::fields(self.page)
};
let fields = fields
.into_iter()
.filter(|field| {
self.page != Page::Theme
|| !(field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file"))
})
.collect();
for group in schema::grouped_fields(self.page, fields) {
let title = group.title;
let mut rows = column([]).spacing(4);
if self.page == Page::Windows && title == "Focus and window effects" {
rows = rows.push(visuals::preview_resolved(
&self.draft,
&self.resolved.presented,
));
}
for field in group.fields {
rows = rows.push(self.field(field));
}
body = body.push(
column([])
.spacing(10)
.push(self.panel_heading(
ferese_config::locale::text(title),
visuals::SECTION_TITLE_SIZE,
))
.push(container(rows).padding(4).class(visuals::section(palette))),
);
}
if self.page == Page::Theme {
if self.auto_details {
body = body.push(
column([])
.spacing(10)
.push(
self.panel_heading(ferese_config::tr!("Automatic appearance"), 14.),
)
.push(self.auto_controls()),
);
}
body = body.push(
button::custom(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(self.label(ferese_config::tr!("Advanced customization"), 13.))
.push(visuals::action_icon(
if self.advanced_theme {
"M6 15l6-6 6 6"
} else {
"M6 9l6 6 6-6"
},
palette.muted,
)),
)
.name(if self.advanced_theme {
"Collapse advanced customization"
} else {
"Expand advanced customization"
})
.padding([8, 12])
.width(Length::Fill)
.class(visuals::button_style(palette, false))
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
);
if self.advanced_theme {
body = body.push(self.theme_file_picker());
}
}
match self.page {
Page::Tray => {
body = body
.push(self.tray_application_controls())
.push(self.tray_status_controls())
}
Page::Connections => body = body.push(self.connections_view()),
Page::LockScreen => {
body = body.push(self.note(ferese_config::tr!("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")));
body = body.push(self.settings_button(
ferese_config::tr!("Preview lock screen"),
"M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
Some(Message::PreviewLock),
false,
));
body = body.push(self.note(ferese_config::tr!("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")));
}
Page::Startup => {
body = body.push(self.note(ferese_config::tr!("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")));
for index in 0..self.draft.records("autostart") {
let prefix = format!("autostart.{index}");
let group = column([])
.spacing(1)
.push(self.field(Field::new(
format!("{prefix}.command"),
"Program",
"A program and its arguments, not a shell script.",
Kind::Text {
default: "",
argv: true,
},
)))
.push(self.field(Field::new(
format!("{prefix}.enabled"),
"Open at login",
"",
Kind::Toggle(true),
)))
.push(self.field(Field::new(
format!("{prefix}.restart"),
"Restart if it exits",
"Managed by the Ferese session.",
Kind::Toggle(true),
)))
.push(
container(
self.settings_icon_button(
ferese_config::tr!("Remove login item"),
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
(!self.saving)
.then_some(Message::Remove("autostart".into(), index)),
),
)
.padding([7, 10]),
);
body =
body.push(container(group).padding(4).class(visuals::section(palette)));
}
body = body.push(
row([])
.spacing(8)
.push(
text_input(
ferese_config::tr!("Program and arguments"),
self.new_command.clone(),
)
.font(self.font)
.size(12)
.padding([4, 8])
.style(visuals::input_style(palette))
.on_input(Message::NewCommand),
)
.push(
self.settings_button(
ferese_config::tr!("Add login item"),
"M12 5v14 M5 12h14",
(!self.saving && !self.new_command.trim().is_empty())
.then_some(Message::AddCommand),
false,
),
),
);
}
Page::Shortcuts => {
body =
body.push(self.panel_heading(ferese_config::tr!("Custom shortcuts"), 14.));
body = body.push(self.note(ferese_config::tr!("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument.")));
let mut gestures = row([]).spacing(6);
for (keys, label) in [
("Swipe3Up", "Customize swipe up"),
("Swipe3Down", "Customize swipe down"),
("Swipe3Left", "Customize swipe left"),
("Swipe3Right", "Customize swipe right"),
] {
let exists = (0..self.draft.records("bindings")).any(|index| {
self.draft
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures = gestures.push(self.settings_icon_button(
label,
match keys {
"Swipe3Up" => "M12 20V4 M5 11l7-7 7 7",
"Swipe3Down" => "M12 4v16 M5 13l7 7 7-7",
"Swipe3Left" => "M20 12H4 M11 5l-7 7 7 7",
_ => "M4 12h16 M13 5l7 7-7 7",
},
(!self.saving).then_some(Message::AddSwipe(keys)),
));
}
}
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let content: Element<'static, Message> =
if self.visible_rows.contains(&("bindings", index)) {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"Keys or swipe, such as Super+Return",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action name, such as spawn or focus",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command, direction, or action argument",
"",
)));
container(group)
.padding(4)
.height(164)
.class(visuals::section(palette))
.into()
} else {
widget::Space::new().height(164).width(Length::Fill).into()
};
body = body.push(
cosmic::iced::widget::sensor(content)
.key(("bindings", index))
.anticipate(200)
.on_show(move |_| Message::RowVisibility("bindings", index, true))
.on_hide(Message::RowVisibility("bindings", index, false)),
);
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note(ferese_config::tr!("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")));
}
}
Page::Displays => body = body.push(self.displays_view()),
_ => {}
}
}
let mut page_heading = row([]).spacing(12).align_y(Alignment::Center);
if compact {
page_heading = page_heading.push(
container(self.settings_icon_button(
ferese_config::tr!("Show settings navigation"),
"M3 5h18v14H3z M9 5v14",
Some(Message::Sidebar(true)),
))
.id("settings-navigation-toggle"),
);
}
page_heading = page_heading.push(
widget::mouse_area(heading.width(Length::Fill))
.on_drag(Message::DragWindow)
.on_double_click(Message::ToggleMaximize)
.interaction(cosmic::iced::mouse::Interaction::Grab),
);
let mut content = column([]).spacing(16).push(page_heading);
if self.search.is_empty() && !self.page.subpages().is_empty() {
content = content.push(
container(
row(self.page.subpages().iter().map(|&(page, title)| {
button::custom(self.label(ferese_config::locale::text(title), 13.))
.name(format!("{}: {title}", self.page.navigation_page().title()))
.padding([7, 12])
.class(visuals::panel_button(palette, self.page == page))
.on_press(Message::Page(page))
.into()
}))
.spacing(2),
)
.padding(3)
.class(visuals::surface(palette.sidebar, 9.)),
);
}
if self.page == Page::Bar && self.search.is_empty() {
if self.panel_page == crate::panel_controls::PanelPage::Arrange {
content = content.push(self.panel_navigation());
}
content = content.push(self.panel_preview());
}
if let Some(error) = &self.error {
content = content.push(
container(self.label(error.clone(), 12.))
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(palette.error, 10.)),
);
}
if self.page == Page::Bar
&& self.search.is_empty()
&& self.panel_page == crate::panel_controls::PanelPage::Arrange
{
content = content.push(self.panel_workspace());
} else {
content = content.push(
scrollable(
container(cosmic::iced::widget::keyed_column([(
self.page as usize,
body.into(),
)]))
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
bottom: 0.,
left: 2.,
})
.width(Length::Fill),
)
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10)
.scroller_width(3),
))
.height(Length::Fill),
);
}
let footer = row([])
.spacing(4)
.align_y(Alignment::Center)
.push(widget::tooltip(
visuals::action_icon(
if self.saving {
"M12 3v9l5 3 M21 12a9 9 0 1 1-9-9"
} else {
"M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0"
},
palette.muted,
),
self.label(
if self.saving {
"Saving…"
} else {
&self.status
},
11.,
),
widget::tooltip::Position::Top,
))
.push(
self.label(
if self.saving {
ferese_config::tr!("Saving…").to_owned()
} else {
ferese_config::locale::translate(&self.status)
},
11.,
)
.class(cosmic::theme::Text::Color(palette.muted))
.width(Length::Fill),
)
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
if self.undo.is_some() && !self.saving {
palette.text
} else {
palette.muted
},
))
.padding(4)
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
self.label(ferese_config::tr!("Undo last change"), 11.),
widget::tooltip::Position::Top,
))
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
palette.text,
))
.padding(4)
.on_press_maybe((!self.saving).then_some(Message::Reload)),
self.label(ferese_config::tr!("Reload configuration"), 11.),
widget::tooltip::Position::Top,
));
let footer: Element<'_, Message> = if self.page == Page::Connections {
self.note(ferese_config::tr!("Connections are managed by the system. Passwords are not saved in your Ferese config."))
} else if self.page == Page::Bar {
container(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.note(if self.saving {
"Saving…"
} else {
&self.status
}))
.push(widget::Space::new().width(Length::Fill))
.push(self.settings_button(
ferese_config::tr!("Undo"),
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
(self.undo.is_some() && !self.saving).then_some(Message::Undo),
false,
))
.push(self.settings_button(
ferese_config::tr!("Reload configuration"),
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
(!self.saving).then_some(Message::Reload),
false,
)),
)
.padding([10, 0])
.into()
} else {
footer.into()
};
let mut drawer = None;
let sidebar: Element<'_, Message> = if compact {
if self.sidebar_open {
drawer = Some(sidebar);
}
widget::Space::new().width(0).into()
} else {
sidebar.into()
};
let body_width = (width - if compact { 0. } else { 208. }).max(0.);
let outer_gutter = ((body_width - 1280.) / 2.).max(0.);
let blank = |id: &'static str, w: Length, h: Length| {
self.window_chrome(container(widget::Space::new().width(w).height(h)).id(id))
};
let frame = column([])
.push(blank(
"settings-top-blank",
Length::Fill,
Length::Fixed(16.),
))
.push(
row([])
.push(blank(
"settings-left-blank",
Length::Fixed(20.),
Length::Fill,
))
.push(
column([])
.push(content.height(Length::Fill))
.push(footer)
.spacing(8)
.width(Length::Fill)
.height(Length::Fill),
)
.push(blank(
"settings-right-blank",
Length::Fixed(20.),
Length::Fill,
))
.height(Length::Fill),
)
.push(blank(
"settings-bottom-blank",
Length::Fill,
Length::Fixed(12.),
))
.width(Length::Fill)
.height(Length::Fill);
let view = row([]).push(sidebar).push(
container(
row([])
.push(blank(
"settings-outer-left-blank",
Length::Fixed(outer_gutter),
Length::Fill,
))
.push(frame)
.push(blank(
"settings-outer-right-blank",
Length::Fixed(outer_gutter),
Length::Fill,
)),
)
.id("settings-page")
.width(Length::Fill)
.height(Length::Fill)
.class(visuals::surface(palette.background, 0.)),
);
// Keep the page mounted and full-width while compact navigation is open.
let mut view = widget::popover(view).modal(true);
if let Some(sidebar) = drawer {
let dismiss = widget::mouse_area(
container(
widget::Space::new()
.width(Length::Fill)
.height(Length::Fill),
)
.class(visuals::surface(
cosmic::iced::Color::BLACK.scale_alpha(0.20),
0.,
)),
)
.on_press(Message::Sidebar(false));
view = view
.position(widget::popover::Position::Point(
cosmic::iced::Point::ORIGIN,
))
.popup(
row([])
.push(sidebar)
.push(dismiss)
.width(Length::Fill)
.height(height),
);
}
let view: Element<'_, Message> = view.into();
if self.profile_pages {
let page = self.page;
cosmic::iced::widget::sensor(view)
.key((page as usize, self.search.is_empty()))
.on_show(move |_| Message::PagePresented(page))
.into()
} else {
view
}
}
}