Consolidate desktop controls with editable holidays and a running-window tray

This commit is contained in:
aria committed 2026-10-28 06:02:46 +09:00
1 parent 9908dde9f8
commit 326465952a
55 files changed
+4207 -450

No files matched your search

+2 -4
View File
@@ -559,7 +559,7 @@ impl App {
..self.font
}))
.push(
self.label(p.subtitle(), 12.)
self.label(p.subtitle(), 11.)
.class(cosmic::theme::Text::Color(palette.muted)),
)
.width(Length::Fill),
@@ -947,11 +947,9 @@ impl App {
for item in group.items {
if !matches!(
item.kind,
ferese_panel::ItemKind::Network
| ferese_panel::ItemKind::Audio
ferese_panel::ItemKind::Audio
| ferese_panel::ItemKind::Battery { .. }
| ferese_panel::ItemKind::Notifications
| ferese_panel::ItemKind::NightLight
| ferese_panel::ItemKind::Recording
| ferese_panel::ItemKind::CaptureStatus
) {
@@ -0,0 +1,282 @@
use super::{App, Element, Length, Message, column, container, row, schema, text_input, visuals};
use super::{
Task,
store::{Snapshot, set},
};
use cosmic::widget::text_editor;
use ferese_config::tr;
#[derive(Default)]
pub(super) struct FunctionEditor {
pub expanded: bool,
initialized: bool,
content: text_editor::Content,
validating: bool,
error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
#[test]
fn function_icon_preserves_unsaved_code_and_only_applies_matching_validation() {
let mut app = App::init(
cosmic::app::Core::default(),
(
"/unused/calendar.kdl".into(),
Snapshot::parse(String::new()),
None,
),
)
.0;
app.saving = true;
let original = app.draft.source.clone();
let _ = app.calendar_function_input(FunctionInput::Toggle);
assert!(app.calendar_editor.expanded);
assert!(!app.calendar_editor.dirty(&app.draft));
let edited = "fn japanese_holidays(year) { #{} }";
app.calendar_editor.content = text_editor::Content::with_text(edited);
let _ = app.calendar_function_input(FunctionInput::Toggle);
assert!(!app.calendar_editor.expanded);
let _ = app.calendar_function_input(FunctionInput::Toggle);
assert_eq!(app.calendar_editor.content.text(), edited);
assert_eq!(app.draft.source, original);
let _ = app.calendar_function_input(FunctionInput::Validated("stale".into(), Ok(())));
assert!(app.pending.is_empty());
let _ = app.calendar_function_input(FunctionInput::Validated(
edited.into(),
Err("invalid syntax".into()),
));
assert!(app.pending.is_empty() && app.calendar_editor.error.is_some());
let _ = app.calendar_function_input(FunctionInput::Validated(edited.into(), Ok(())));
assert_eq!(app.pending.len(), 1);
assert_eq!(
source(&Snapshot::parse(app.draft.source.clone()).unwrap()),
edited
);
let _ = app.calendar_function_input(FunctionInput::Restore);
assert_eq!(
source(&app.draft),
edited,
"restoring the editor still requires Apply"
);
assert!(app.calendar_editor.dirty(&app.draft));
}
}
const FUNCTION_PATH: &str = "status.calendar.japanese_holiday_function";
fn source(snapshot: &Snapshot) -> String {
snapshot.string(
FUNCTION_PATH,
ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION,
)
}
impl FunctionEditor {
pub fn dirty(&self, snapshot: &Snapshot) -> bool {
self.initialized && self.content.text() != source(snapshot)
}
pub fn refresh(&mut self, snapshot: &Snapshot) {
if self.initialized {
self.content = text_editor::Content::with_text(&source(snapshot));
}
self.validating = false;
self.error = None;
}
}
#[derive(Clone, Debug)]
pub(super) enum FunctionInput {
Toggle,
Edit(text_editor::Action),
Apply,
Restore,
Validated(String, Result<(), String>),
}
impl App {
pub(super) fn calendar_function_input(&mut self, input: FunctionInput) -> Task<Message> {
match input {
FunctionInput::Toggle => {
if !self.calendar_editor.initialized || !self.calendar_editor.dirty(&self.draft) {
self.calendar_editor.content =
text_editor::Content::with_text(&source(&self.draft));
self.calendar_editor.initialized = true;
}
self.calendar_editor.expanded = !self.calendar_editor.expanded;
}
FunctionInput::Edit(action) => {
self.calendar_editor.content.perform(action);
self.calendar_editor.error = None;
}
FunctionInput::Restore => {
self.calendar_editor.content = text_editor::Content::with_text(
ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION,
);
self.calendar_editor.error = None;
}
FunctionInput::Apply if !self.saving && !self.calendar_editor.validating => {
let source = self.calendar_editor.content.text();
let edited = source.clone();
self.calendar_editor.validating = true;
return Task::perform(
async move {
tokio::task::spawn_blocking(move || {
ferese_config::calendar::validate_japanese_holiday_function(&source)
})
.await
.unwrap_or_else(|error| Err(error.to_string()))
},
move |result| {
cosmic::Action::App(Message::CalendarFunction(FunctionInput::Validated(
edited.clone(),
result,
)))
},
);
}
FunctionInput::Validated(source, result) => {
self.calendar_editor.validating = false;
if source != self.calendar_editor.content.text() {
return Task::none();
}
match result {
Ok(()) => return self.change(set(FUNCTION_PATH, source)),
Err(error) => {
self.calendar_editor.error = Some(ferese_config::locale::translate(&error))
}
}
}
_ => {}
}
Task::none()
}
fn calendar_function_view(&self) -> Element<'_, Message> {
let state = &self.calendar_editor;
let palette = self.native_palette;
let mut rows = column([]).spacing(10).push(
row([])
.align_y(super::Alignment::Center)
.push(
self.panel_heading(tr!("Japanese holidays"), 14.)
.width(Length::Fill),
)
.push(
container(self.settings_icon_button(
tr!("Edit holiday calculation function"),
"M8 6l-6 6 6 6 M16 6l6 6-6 6 M14 4l-4 16",
Some(Message::CalendarFunction(FunctionInput::Toggle)),
))
.id("calendar-function-toggle"),
),
);
if state.expanded {
rows=rows.push(self.note(tr!("Rhai function japanese_holidays(year) returns a YYYY-MM-DD to name map. Built-in rules cover 1955–2099; unpublished years are estimates.")))
.push(cosmic::widget::TextEditor::new(&state.content)
// The context-menu wrapper repeats its inner editor ID;
// bypass it so named-state recovery retains the editor tree.
.context_menu(false)
.id("calendar-function-editor").font(self.font).size(11).height(280).padding(12)
.style(move|_,status|text_editor::Style{
background:palette.card.into(),
border:cosmic::iced::Border{width:1.,radius:5.into(),color:if matches!(status,text_editor::Status::Focused{..}){palette.accent}else{palette.muted.scale_alpha(0.25)},..Default::default()},
placeholder:palette.muted,value:palette.text,selection:palette.accent.scale_alpha(0.3),
})
.on_action(|action|Message::CalendarFunction(FunctionInput::Edit(action))))
.push(row([]).spacing(10)
.push(self.settings_button(if state.validating {tr!("Checking function…")} else {tr!("Apply function")},"M5 12l4 4L19 6",(!state.validating&&!self.saving&&state.dirty(&self.draft)).then_some(Message::CalendarFunction(FunctionInput::Apply)),false))
.push(self.settings_button(tr!("Restore built-in function"),"M4 11a8 8 0 1 1 2 7 M4 4v7h7",Some(Message::CalendarFunction(FunctionInput::Restore)),false)));
if let Some(error) = &state.error {
rows = rows.push(self.note(&ferese_config::locale::format(
"Holiday function error: {error}",
&[("error", error.clone())],
)));
}
}
rows.into()
}
}
impl App {
pub(super) fn calendar_controls(&self) -> Element<'_, Message> {
let palette = self.native_palette;
let date = self
.inputs
.get("$holiday.date")
.cloned()
.unwrap_or_default();
let name = self
.inputs
.get("$holiday.name")
.cloned()
.unwrap_or_default();
let valid = ferese_config::calendar::Holiday {
date: date.trim().into(),
name: name.trim().into(),
}
.validate()
.is_ok();
let mut rows = column([]).spacing(8);
for index in 0..self.draft.records("status.calendar.custom_holidays") {
let prefix = format!("status.calendar.custom_holidays.{index}");
rows = rows.push(
container(
column([])
.spacing(4)
.push(self.field(schema::text(
&format!("{prefix}.date"),
"Date",
"YYYY-MM-DD for one date; MM-DD repeats every year.",
"",
)))
.push(self.field(schema::text(
&format!("{prefix}.name"),
"Holiday name",
"",
"",
)))
.push(self.settings_button(
tr!("Remove holiday"),
"M5 6h14 M9 6V3h6v3 M6 6l1 15h10l1-15",
(!self.saving).then_some(Message::Remove(
"status.calendar.custom_holidays".into(),
index,
)),
false,
)),
)
.padding(4)
.class(visuals::section(palette)),
);
}
let input = |placeholder, value, path: &'static str| {
text_input(placeholder, value)
.font(self.font)
.size(12)
.padding([7, 10])
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.into(), value))
.on_submit(|_| Message::AddHoliday)
};
rows = rows.push(
row([])
.spacing(10)
.align_y(super::Alignment::Center)
.push(input(tr!("YYYY-MM-DD / MM-DD"), date, "$holiday.date").width(170))
.push(input(tr!("Holiday name"), name, "$holiday.name").width(Length::Fill))
.push(self.settings_button(
tr!("Add holiday"),
"M12 5v14 M5 12h14",
(!self.saving && valid).then_some(Message::AddHoliday),
false,
)),
);
column([]).spacing(10)
.push(self.calendar_function_view())
.push(self.panel_heading(tr!("Custom holidays"),14.))
.push(self.note(tr!("Japanese public holidays are included. Add personal dates below; MM-DD repeats annually.")))
.push(rows).into()
}
}
@@ -18,7 +18,7 @@ impl App {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Bluetooth unavailable", 14.))
.push(self.label(ferese_config::tr!("Bluetooth unavailable"), 14.))
.push(self.connection_note(result.as_ref().expect_err(
"the unavailable-services branch contains a connection error",
))),
@@ -55,11 +55,15 @@ impl App {
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Bluetooth", 15.))
.push(self.label(ferese_config::tr!("Bluetooth"), 15.))
.push(self.connection_note(&format!(
"{} · {}",
adapter.name,
if adapter.powered { "On" } else { "Off" }
if adapter.powered {
ferese_config::tr!("On")
} else {
ferese_config::tr!("Off")
}
))),
)
.push(
@@ -85,7 +89,7 @@ impl App {
row([])
.align_y(Alignment::Center)
.push(
self.label(
self.ui_label(
if scanning {
"Searching for devices…"
} else {
@@ -121,7 +125,7 @@ impl App {
.filter(|d| d.adapter == adapter.path)
.collect();
for paired in [true, false] {
body = body.push(self.label(
body = body.push(self.ui_label(
if paired {
"Paired devices"
} else {
@@ -144,13 +148,21 @@ impl App {
} else {
"Not paired"
};
let state = ferese_config::locale::text(state);
let detail = format!(
"{state} · {}{}{}",
device.address,
if device.trusted { " · Trusted" } else { "" },
if device.trusted {
ferese_config::tr!(" · Trusted")
} else {
""
},
device
.battery
.map(|b| format!(" · {b}% battery"))
.map(|b| ferese_config::locale::format(
" · {percent}% battery",
&[("percent", b.to_string())]
))
.unwrap_or_default()
);
let mut controls = row([]).spacing(6);
@@ -20,31 +20,46 @@ impl App {
let explanation = match &prompt.kind {
agent::Kind::Pin => "Enter the PIN shown on your device.".into(),
agent::Kind::Passkey => "Enter the six-digit passkey shown on your device.".into(),
agent::Kind::Confirm(key) => format!("Does {key:06} match the number on your device?"),
agent::Kind::Confirm(key) => ferese_config::locale::format(
"Does {key} match the number on your device?",
&[("key", format!("{key:06}"))],
),
agent::Kind::Authorize => "Allow this device to pair?".into(),
agent::Kind::Service(uuid) => format!("Allow the device to use service {uuid}?"),
agent::Kind::Service(uuid) => ferese_config::locale::format(
"Allow the device to use service {uuid}?",
&[("uuid", uuid.clone())],
),
agent::Kind::Display(text) => text.clone(),
};
let mut body = column([])
.spacing(8)
.push(self.label(format!("Pair with {device}"), 15.))
.push(self.label(
ferese_config::locale::format(
"Pair with {device}",
&[("device", device.to_owned())],
),
15.,
))
.push(self.connection_note(&explanation));
let id = prompt.id;
let input = matches!(prompt.kind, agent::Kind::Pin | agent::Kind::Passkey);
if input {
body = body.push(
text_input("PIN or passkey", s.pairing_input.as_str())
.id(cosmic::widget::Id::new("bluetooth-passkey"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value))))
.on_submit(move |mut value| {
value.zeroize();
message(Input::Answer(id, true))
}),
text_input(
ferese_config::tr!("PIN or passkey"),
s.pairing_input.as_str(),
)
.id(cosmic::widget::Id::new("bluetooth-passkey"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value))))
.on_submit(move |mut value| {
value.zeroize();
message(Input::Answer(id, true))
}),
);
}
let valid = agent::valid_answer(&prompt.kind, &s.pairing_input);
+6 -6
View File
@@ -92,7 +92,7 @@ impl App {
));
}
if !icon_only {
content = content.push(self.label(label.to_owned(), 12.));
content = content.push(self.label(ferese_config::locale::translate(label), 12.));
}
let control = button::custom(content)
.name(label.to_owned())
@@ -102,7 +102,7 @@ impl App {
if icon_only {
widget::tooltip(
control,
self.label(label.to_owned(), 11.),
self.label(ferese_config::locale::translate(label), 11.),
widget::tooltip::Position::Top,
)
.into()
@@ -113,7 +113,7 @@ impl App {
pub(super) fn connection_note(&self, text: &str) -> Element<'static, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 11.)
self.label(ferese_config::locale::translate(text), 10.5)
.class(cosmic::theme::Text::Color(p.muted))
.into()
}
@@ -154,7 +154,7 @@ impl App {
);
if let Some(error) = &s.error {
body = body.push(
container(self.label(error.clone(), 12.))
container(self.label(ferese_config::locale::translate(error), 12.))
.padding(8)
.width(Length::Fill)
.class(visuals::surface(p.error, 10.)),
@@ -164,7 +164,7 @@ impl App {
let mut status = row([])
.align_y(Alignment::Center)
.spacing(6)
.push(self.label(busy, 12.));
.push(self.label(ferese_config::locale::translate(busy), 12.));
if busy == "Pairing…" {
status = status.push(self.connection_button(
"Cancel",
@@ -184,7 +184,7 @@ impl App {
self.connection_card(
column([])
.spacing(6)
.push(self.label("Forget this connection?", 14.))
.push(self.label(ferese_config::tr!("Forget this connection?"), 14.))
.push(self.connection_note(
"Saved credentials or pairing keys will be removed. You will need to connect again.",
))
@@ -19,7 +19,7 @@ impl App {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Wi-Fi unavailable", 14.))
.push(self.label(ferese_config::tr!("Wi-Fi unavailable"), 14.))
.push(self.connection_note(result.as_ref().expect_err(
"the unavailable-services branch contains a connection error",
))),
@@ -44,7 +44,7 @@ impl App {
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Wi-Fi", 15.))
.push(self.label(ferese_config::tr!("Wi-Fi"), 15.))
.push(self.connection_note(status)),
)
.push(
@@ -65,7 +65,10 @@ impl App {
body = body.push(
row([])
.align_y(Alignment::Center)
.push(self.label("Networks", 14.).width(Length::Fill))
.push(
self.label(ferese_config::tr!("Networks"), 14.)
.width(Length::Fill),
)
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
);
if wifi.networks.is_empty() {
@@ -88,10 +91,14 @@ impl App {
} else {
""
};
let state = ferese_config::locale::text(state);
let detail = format!(
"{state}{} · {}% signal{}",
network.security.label(),
network.strength,
"{state}{} · {}{}",
ferese_config::locale::text(network.security.label()),
ferese_config::locale::format(
"{percent}% signal",
&[("percent", network.strength.to_string())]
),
if wifi.radios.len() > 1 {
format!(" · {radio}")
} else {
@@ -171,7 +178,7 @@ impl App {
body = body.push(self.wifi_credentials());
}
if !wifi.saved.is_empty() {
body = body.push(self.label("Saved networks", 14.));
body = body.push(self.label(ferese_config::tr!("Saved networks"), 14.));
for saved in &wifi.saved {
body = body.push(
self.connection_card(
@@ -206,28 +213,34 @@ impl App {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let target = self.wifi_target();
let mut body = column([]).spacing(6).push(self.label(
let mut body = column([]).spacing(6).push(self.ui_label(
if s.hidden {
"Hidden network".into()
ferese_config::locale::translate("Hidden network")
} else {
format!(
"Join {}",
s.selected
.as_ref()
.map(|n| n.name.as_str())
.unwrap_or("network")
ferese_config::locale::format(
"Join {network}",
&[(
"network",
s.selected
.as_ref()
.map(|n| n.name.clone())
.unwrap_or_else(|| ferese_config::locale::translate("network")),
)],
)
},
16.,
));
if s.hidden {
body = body.push(
text_input("Network name (SSID)", s.hidden_name.clone())
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|name| message(Input::HiddenName(name))),
text_input(
ferese_config::tr!("Network name (SSID)"),
s.hidden_name.clone(),
)
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|name| message(Input::HiddenName(name))),
);
let mut choices = row([]).spacing(6);
for security in [
@@ -251,7 +264,7 @@ impl App {
.unwrap_or(s.hidden_security);
if security.password() {
body = body.push(
text_input("Network password", s.password.as_str())
text_input(ferese_config::tr!("Network password"), s.password.as_str())
.id(cosmic::widget::Id::new("wifi-password"))
.password()
.font(self.font)
+1 -1
View File
@@ -265,7 +265,7 @@ impl std::fmt::Display for Refresh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Manual(mode) => f.write_str(&mode.label()),
Self::Automatic(_) => f.write_str("Auto (battery)"),
Self::Automatic(_) => f.write_str(ferese_config::tr!("Auto (battery)")),
}
}
}
+4 -1
View File
@@ -186,7 +186,10 @@ impl App {
.spacing(10)
.align_y(Alignment::Center)
.padding([7, 10])
.push(self.label(label.to_owned(), 13.).width(Length::Fill))
.push(
self.label(ferese_config::locale::translate(label), 13.)
.width(Length::Fill),
)
.push(control)
.into()
}
+29 -16
View File
@@ -12,6 +12,14 @@ impl App {
ferese_theme::text(text, self.font).size(size)
}
pub(super) fn ui_label<'a>(
&self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
self.label(ferese_config::locale::translate(text.into().as_ref()), size)
}
pub(super) fn window_chrome<'a>(
&self,
content: impl Into<Element<'a, Message>> + 'a,
@@ -27,21 +35,24 @@ impl App {
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
self.label(text, size.min(visuals::SECTION_TITLE_SIZE))
.font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Bold,
..self.font
})
self.label(
ferese_config::locale::translate(text.into().as_ref()),
size.min(visuals::SECTION_TITLE_SIZE),
)
.font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Bold,
..self.font
})
}
pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(
text.to_owned(),
ferese_config::locale::translate(text),
if self.page == crate::schema::Page::Bar {
12.
10.5
} else {
11.
10.5
},
)
.class(cosmic::theme::Text::Color(palette.muted))
@@ -80,7 +91,7 @@ impl App {
palette.muted
},
))
.push(self.label(label.to_owned(), 12.)),
.push(self.label(ferese_config::locale::translate(label), 12.)),
)
.name(label.to_owned())
.padding([4, 8])
@@ -109,7 +120,7 @@ impl App {
.padding(4)
.class(self.control_button_style(palette, false))
.on_press_maybe(message),
self.label(label.to_owned(), 11.),
self.label(ferese_config::locale::translate(label), 11.),
widget::tooltip::Position::Top,
)
.into()
@@ -123,8 +134,10 @@ impl App {
ferese_config::theme::Appearance::Dark => "dark",
};
field.path = format!("theme.{name}.material.opacity");
field.description =
format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
field.description = ferese_config::locale::format(
"Opacity for the current {name} appearance. Text and icons stay opaque.",
&[("name", ferese_config::locale::translate(name))],
);
if let Kind::Range { default, .. } = &mut field.kind {
*default = self.resolved.theme.tokens.material.opacity;
}
@@ -147,7 +160,7 @@ impl App {
ferese_config::locale::translate(&field.description)
))
.font(regular)
.size(12.)
.size(10.5)
.color(palette.muted),
);
}
@@ -226,14 +239,14 @@ impl App {
for (key, label) in choices {
options = options.push(
button::custom(self.label(ferese_config::locale::text(label), 12.))
.padding([4, 8])
.padding([6, 10])
.class(self.control_button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))),
);
}
container(options)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.))
.padding(5)
.class(visuals::segment_group(palette))
.into()
}
Kind::Text { default, argv } => {
+80 -13
View File
@@ -1,4 +1,5 @@
mod all_options;
mod calendar_controls;
mod connections;
mod form_controls;
mod navigation;
@@ -66,6 +67,7 @@ fn main() -> cosmic::iced::Result {
Some("bar") => Some(InitialPage::Bar),
Some("appearance") => Some(InitialPage::Settings(Page::Appearance)),
Some("general") => Some(InitialPage::Settings(Page::General)),
Some("calendar") => Some(InitialPage::Settings(Page::Calendar)),
Some("theme") => Some(InitialPage::Settings(Page::Theme)),
Some("tray") => Some(InitialPage::Settings(Page::Tray)),
Some("all-options") => Some(InitialPage::Settings(Page::Configuration)),
@@ -134,6 +136,7 @@ enum Message {
RowVisibility(&'static str, usize, bool),
Search(String),
Sidebar(bool),
RevealNavigation,
Change(Edit),
PanelClearSelection,
PanelPage(panel_controls::PanelPage),
@@ -173,6 +176,8 @@ enum Message {
LockPreviewStarted(Result<(), String>),
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
NewCommand(String),
AddHoliday,
CalendarFunction(calendar_controls::FunctionInput),
AddCommand,
AddSwipe(&'static str),
Remove(String, usize),
@@ -207,6 +212,7 @@ struct App {
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
new_command: String,
calendar_editor: calendar_controls::FunctionEditor,
font: cosmic::font::Font,
native_palette: visuals::Palette,
family_ids: std::sync::Arc<Vec<String>>,
@@ -324,6 +330,7 @@ impl cosmic::Application for App {
inputs: HashMap::new(),
ranges: HashMap::new(),
new_command: String::new(),
calendar_editor: Default::default(),
font,
native_palette,
gallery_focus: None,
@@ -358,7 +365,10 @@ impl cosmic::Application for App {
} else {
Task::none()
};
let task = Task::batch([task, tray_apps]);
let reveal =
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(app.page))
.map(cosmic::Action::App);
let task = Task::batch([task, tray_apps, reveal]);
let connections = if app.page == Page::Connections {
app.refresh_connections()
} else {
@@ -460,6 +470,7 @@ impl cosmic::Application for App {
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
|| self.options.dirty()
|| self.calendar_editor.dirty(&self.draft)
{
self.status =
"Config changed externally · Reload when your edits are finished"
@@ -469,6 +480,7 @@ impl cosmic::Application for App {
&snapshot.doc,
));
self.options.reset(&snapshot);
self.calendar_editor.refresh(&snapshot);
self.current = snapshot.clone();
self.draft = snapshot;
self.family_sections = theme_controls::FamilySections::new(
@@ -517,11 +529,21 @@ impl cosmic::Application for App {
Message::Sidebar(open) => {
self.sidebar_open = open;
if open {
return cosmic::iced::widget::operation::focus(widget::Id::new(
"settings-search",
));
return Task::batch([
cosmic::iced::widget::operation::focus(widget::Id::new("settings-search")),
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
self.page,
))
.map(cosmic::Action::App),
]);
}
}
Message::RevealNavigation => {
return cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
self.page,
))
.map(cosmic::Action::App);
}
Message::Page(page) => {
self.sidebar_open = false;
if self.page == page && self.search.is_empty() {
@@ -545,6 +567,13 @@ impl cosmic::Application for App {
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
let reset = Task::batch([
reset,
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
page,
))
.map(cosmic::Action::App),
]);
if leaving {
let cleanup = self.leave_connections();
let load = match page {
@@ -839,6 +868,7 @@ impl cosmic::Application for App {
Ok(snapshot) => {
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
self.options.reset(&snapshot);
self.calendar_editor.refresh(&snapshot);
self.current = snapshot.clone();
self.draft = snapshot;
self.family_sections =
@@ -1012,6 +1042,37 @@ impl cosmic::Application for App {
return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults());
}
Message::NewCommand(value) => self.new_command = value,
Message::CalendarFunction(input) => return self.calendar_function_input(input),
Message::AddHoliday if !self.saving => {
let date = self
.inputs
.get("$holiday.date")
.cloned()
.unwrap_or_default()
.trim()
.to_owned();
let name = self
.inputs
.get("$holiday.name")
.cloned()
.unwrap_or_default()
.trim()
.to_owned();
let holiday = ferese_config::calendar::Holiday { date, name };
if let Err(error) = holiday.validate() {
self.error = Some(ferese_config::locale::translate(&error));
} else {
self.inputs.remove("$holiday.date");
self.inputs.remove("$holiday.name");
return self.change(Edit::Add(
"status.calendar.custom_holidays".into(),
vec![
("date".into(), holiday.date.into()),
("name".into(), holiday.name.into()),
],
));
}
}
Message::AddCommand if !self.saving => match shlex::split(&self.new_command)
.filter(|a| !a.is_empty())
{
@@ -1342,7 +1403,7 @@ mod tests {
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true; // An earlier save has not completed yet.
let id = ItemId("network".into());
let id = ItemId("audio".into());
let _ = app.update(Message::PanelSelect(id.clone()));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Move(
id.clone(),
@@ -1366,11 +1427,9 @@ mod tests {
let panels: Vec<ferese_panel::Panel> =
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
ferese_panel::validate(&panels).unwrap();
assert_eq!(panels[0].start.groups[1].items[1].id, id);
assert_eq!(
panels[0].start.groups[1].items[1].overflow,
ferese_panel::OverflowPolicy::Always
);
let moved = panels[0].start.groups[1].items.last().unwrap();
assert_eq!(moved.id, id);
assert_eq!(moved.overflow, ferese_panel::OverflowPolicy::Always);
assert!(
!panels[0].end.groups[0]
.items
@@ -1419,7 +1478,15 @@ mod tests {
let _ = app.update(Message::PanelEdit(panel_edit::Action::Remove(id)));
let _ = app.page_view();
assert!(app.error.is_none());
assert_eq!(app.draft.string("panels.0.end.groups.1.items.0.id", ""), "");
assert!(
!app.preview_panel()
.unwrap()
.end
.groups
.iter()
.flat_map(|group| &group.items)
.any(|item| item.kind == ferese_panel::ItemKind::Clock)
);
}
#[test]
@@ -1509,7 +1576,7 @@ mod tests {
panel_edit::Zone::Center,
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Place(
ItemId("network".into()),
ItemId("audio".into()),
panel_edit::Destination {
zone: "end",
group: GroupId("time".into()),
@@ -1523,7 +1590,7 @@ mod tests {
assert!(app.saving && app.pending.is_empty());
assert_eq!(
app.draft.string("panels.0.center.groups.1.items.0.id", ""),
"network"
"audio"
);
assert_eq!(
app.draft.string("panels.0.center.groups.1.items.1.id", ""),
+14 -1
View File
@@ -22,6 +22,7 @@ pub(super) fn gallery_row_id(list: &str, row: usize) -> Id {
pub(super) struct RevealRow {
target: Id,
scroller: Id,
bounds: Option<Rectangle>,
}
@@ -33,6 +34,18 @@ impl RevealRow {
pub(super) fn for_target(target: Id) -> Self {
Self {
target,
scroller: Id::new("settings-content"),
bounds: None,
}
}
pub(super) fn navigation(page: crate::schema::Page) -> Self {
Self {
target: Id::new(format!(
"settings-nav-row-{}",
page.navigation_page() as usize
)),
scroller: Id::new("settings-navigation"),
bounds: None,
}
}
@@ -57,7 +70,7 @@ impl<M> Operation<M> for RevealRow {
translation: Vector,
state: &mut dyn Scrollable,
) {
if id != Some(&Id::new("settings-content")) {
if id != Some(&self.scroller) {
return;
}
+51 -22
View File
@@ -114,6 +114,10 @@ impl App {
section.push(
button::custom(
container(content)
.id(widget::Id::new(format!(
"settings-nav-row-{}",
page as usize
)))
.center_y(Length::Fill)
.width(Length::Fill),
)
@@ -131,15 +135,31 @@ impl App {
}
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 sidebar = container(
sidebar.push(
scrollable(navigation)
.id("settings-navigation")
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::default()
.width(4)
.scroller_width(4)
.spacing(8),
))
.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(
cosmic::iced::widget::sensor(sidebar)
.key("settings-navigation-mount")
.on_show(|_| Message::RevealNavigation),
);
let mut heading = row([]).align_y(Alignment::Center).spacing(16).push(
self.label(
self.ui_label(
if self.search.is_empty() {
self.page.navigation_page().title()
} else {
@@ -171,28 +191,36 @@ impl App {
),
);
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
let source = if scheduled { "Schedule" } else { "System" };
let source = ferese_config::locale::text(if scheduled { "Schedule" } else { "System" });
let label = if automatic {
format!(
"{source} · {}",
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
"Light"
ferese_config::tr!("Light")
} else {
"Dark"
ferese_config::tr!("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"),
ferese_config::locale::format(
"Light from {light}, dark from {dark} ({zone})",
&[
(
"light",
self.draft.string("theme.schedule.light_at", "07:00"),
),
("dark", self.draft.string("theme.schedule.dark_at", "19:00")),
(
"zone",
self.draft.string("theme.schedule.timezone", "system"),
),
],
)
} else {
"Auto follows the GTK/GNOME appearance preference".to_owned()
ferese_config::locale::translate("Auto follows the GTK/GNOME appearance preference")
};
heading = heading.push(widget::tooltip(
self.settings_button(
@@ -360,6 +388,7 @@ impl App {
}
match self.page {
Page::Calendar => body = body.push(self.calendar_controls()),
Page::Tray => {
body = body
.push(self.tray_application_controls())
@@ -550,15 +579,15 @@ impl App {
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])
.padding([8, 14])
.class(visuals::panel_button(palette, self.page == page))
.on_press(Message::Page(page))
.into()
}))
.spacing(2),
)
.padding(3)
.class(visuals::surface(palette.sidebar, 9.)),
.padding(5)
.class(visuals::segment_group(palette)),
);
}
if self.page == Page::Bar && self.search.is_empty() {
@@ -618,7 +647,7 @@ impl App {
},
palette.muted,
),
self.label(
self.ui_label(
if self.saving {
"Saving…"
} else {
@@ -629,7 +658,7 @@ impl App {
widget::tooltip::Position::Top,
))
.push(
self.label(
self.ui_label(
if self.saving {
ferese_config::tr!("Saving…").to_owned()
} else {
+70 -38
View File
@@ -24,29 +24,27 @@ pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result<store::Edit, Str
struct CatalogItem(ItemKind);
impl std::fmt::Display for CatalogItem {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0.label())
formatter.write_str(ferese_config::locale::text(self.0.label()))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct StyleChoice(ferese_panel::PanelStyle);
impl std::fmt::Display for StyleChoice {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0.label())
formatter.write_str(ferese_config::locale::text(self.0.label()))
}
}
const CATALOG: [CatalogItem; 16] = [
const CATALOG: [CatalogItem; 14] = [
CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::Dots,
}),
CatalogItem(ItemKind::FocusedWindow),
CatalogItem(ItemKind::WindowTray),
CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::Activities),
CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Tray),
CatalogItem(ItemKind::NightLight),
CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio),
CatalogItem(ItemKind::Recording),
CatalogItem(ItemKind::CaptureStatus),
@@ -126,8 +124,11 @@ impl App {
} else {
"Bottom"
};
widget::button::custom(self.label(label, 12.))
.name(format!("Panel position: {label}"))
widget::button::custom(self.label(ferese_config::locale::translate(label), 12.))
.name(ferese_config::locale::format(
"Panel position: {label}",
&[("label", ferese_config::locale::translate(&label))],
))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, edge == position))
@@ -144,8 +145,8 @@ impl App {
"Position",
"Choose the screen edge for the panel.",
widget::container(position)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.padding(5)
.class(crate::visuals::segment_group(palette))
.into(),
),
)
@@ -222,7 +223,10 @@ impl App {
let maximum = self.draft.number("activities.max_visible", 2.) as u64;
let choices = widget::row([1_u64, 2].into_iter().map(|count| {
widget::button::custom(self.label(count.to_string(), 12.))
.name(format!("Show up to {count} activities"))
.name(ferese_config::locale::format(
"Show up to {count} activities",
&[("count", count.to_string())],
))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, count == maximum))
@@ -237,8 +241,8 @@ impl App {
"Visible activities",
"Maximum activities in the collapsed and peek island.",
widget::container(choices)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.padding(5)
.class(crate::visuals::segment_group(palette))
.into(),
),
);
@@ -336,7 +340,10 @@ impl App {
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(
field.path.clone(),
)));
let reset_label = format!("Reset {}", field.label);
let reset_label = ferese_config::locale::format(
"Reset {label}",
&[("label", ferese_config::locale::translate(&field.label))],
);
settings = settings.push(
widget::row![
self.field(field),
@@ -410,7 +417,7 @@ impl App {
.push(
widget::column([])
.spacing(4)
.push(self.label(label.to_owned(), 13.))
.push(self.label(ferese_config::locale::translate(label), 13.))
.push(self.note(description))
.width(cosmic::iced::Length::Fill),
)
@@ -512,28 +519,34 @@ impl App {
let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius));
let modes = widget::row([("Automatic", false), ("Custom", true)].map(
|(label, selected)| {
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
.name(format!("Corner radius: {label}"))
.padding([8, 12])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
custom.is_some() == selected,
))
.on_press_maybe((custom.is_some() != selected).then(|| {
Message::PanelEdit(if selected {
Action::SetRadius(format!("{shell_radius}px"))
} else {
Action::ClearRadius
})
}))
.into()
widget::button::custom(
self.label(ferese_config::locale::translate(label), 12.)
.align_x(Alignment::Center),
)
.name(ferese_config::locale::format(
"Corner radius: {label}",
&[("label", ferese_config::locale::translate(&label))],
))
.padding([8, 12])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
custom.is_some() == selected,
))
.on_press_maybe((custom.is_some() != selected).then(|| {
Message::PanelEdit(if selected {
Action::SetRadius(format!("{shell_radius}px"))
} else {
Action::ClearRadius
})
}))
.into()
},
))
.spacing(2);
let modes = widget::container(modes)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.));
.padding(5)
.class(crate::visuals::segment_group(palette));
let mut corners = Vec::new();
// Match the physical corners: top row first, then bottom-left / bottom-right.
for (index, label, icon) in [
@@ -554,9 +567,15 @@ impl App {
.spacing(8)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon(icon, palette.muted))
.push(self.label(label, 12.).width(Length::Fill))
.push(
self.label(ferese_config::locale::translate(label), 12.)
.width(Length::Fill),
)
.push(self.settings_icon_button(
&format!("Decrease {label} radius"),
&ferese_config::locale::format(
"Decrease {label} radius",
&[("label", ferese_config::locale::translate(&label))],
),
"M5 12h14",
(custom.is_some() && value > 0.).then_some(Message::PanelEdit(
Action::SetCorner(index, (value - 1.).max(0.)),
@@ -570,7 +589,10 @@ impl App {
)
.push(
self.settings_icon_button(
&format!("Increase {label} radius"),
&ferese_config::locale::format(
"Increase {label} radius",
&[("label", ferese_config::locale::translate(&label))],
),
"M12 5v14 M5 12h14",
custom
.is_some()
@@ -808,7 +830,10 @@ impl App {
))
.push(self.label(group_name(&group.id.0), 13.)),
)
.name(format!("Edit group {}", group.id.0))
.name(ferese_config::locale::format(
"Edit group {name}",
&[("name", group.id.0.clone())],
))
.padding([7, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
@@ -860,7 +885,13 @@ impl App {
})),
),
)
.name(format!("Edit {} · {}", item.kind.label(), item.id.0))
.name(ferese_config::locale::format(
"Edit {label} · {id}",
&[
("label", ferese_config::locale::translate(item.kind.label())),
("id", item.id.0.clone()),
],
))
.padding([8, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
@@ -963,6 +994,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::WindowTray => icons::APPLICATION,
ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
+4 -3
View File
@@ -18,7 +18,8 @@ impl std::fmt::Display for Destination {
};
write!(
formatter,
"{zone} · {}",
"{} · {}",
ferese_config::locale::text(zone),
crate::panel_controls::group_name(&self.group.0)
)
}
@@ -84,11 +85,11 @@ impl Zone {
impl std::fmt::Display for Zone {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
formatter.write_str(ferese_config::locale::text(match self {
Self::Start => "Start",
Self::Center => "Center",
Self::End => "End",
})
}))
}
}
+26 -11
View File
@@ -7,7 +7,7 @@ use ferese_panel::{Group, Item, ItemKind, Panel, Representation};
struct Choice(&'static str, &'static str);
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.1)
f.write_str(ferese_config::locale::text(self.1))
}
}
@@ -179,7 +179,10 @@ impl App {
.align_y(Alignment::Center),
)
.id(format!("workspace-style:{}", style.key()).into())
.name(format!("Workspace style: {}", style.label()))
.name(ferese_config::locale::format(
"Workspace style: {label}",
&[("label", ferese_config::locale::translate(style.label()))],
))
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, style == selected))
@@ -266,13 +269,19 @@ impl App {
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(label, 13.).width(Length::Fill))
.push(
self.label(ferese_config::locale::translate(label), 13.)
.width(Length::Fill),
)
.push(
widget::row([])
.spacing(4)
.align_y(Alignment::Center)
.push(self.settings_icon_button(
&format!("Decrease {label}"),
&ferese_config::locale::format(
"Decrease {label}",
&[("label", ferese_config::locale::translate(&label))],
),
"M5 12h14",
(value > 0.).then(|| change((value - 1.).max(0.))),
))
@@ -282,7 +291,10 @@ impl App {
.align_x(Alignment::Center),
)
.push(self.settings_icon_button(
&format!("Increase {label}"),
&ferese_config::locale::format(
"Increase {label}",
&[("label", ferese_config::locale::translate(&label))],
),
"M12 5v14 M5 12h14",
(value < maximum).then(|| change((value + 1.).min(maximum))),
)),
@@ -358,7 +370,7 @@ impl App {
.map(|&(key, label)| Choice(key, label));
return widget::column([])
.spacing(8)
.push(self.label(field.label, 13.))
.push(self.label(ferese_config::locale::translate(&field.label), 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(
choices
@@ -384,7 +396,7 @@ impl App {
.into();
}
let options = widget::row(choices.iter().map(|&(key, label)| {
widget::button::custom(self.label(label, 12.))
widget::button::custom(self.label(ferese_config::locale::translate(label), 12.))
.name(format!("{}: {label}", field.label))
.padding([8, 4])
.width(Length::Fill)
@@ -400,11 +412,11 @@ impl App {
.width(Length::Fill);
let mut rows = widget::column([])
.spacing(8)
.push(self.label(field.label, 13.))
.push(self.label(ferese_config::locale::translate(&field.label), 13.))
.push(
widget::container(options)
.padding(3)
.class(crate::visuals::surface(p.sidebar, 9.)),
.padding(5)
.class(crate::visuals::segment_group(p)),
);
if !field.description.is_empty() {
rows = rows.push(self.note(&field.description));
@@ -419,7 +431,10 @@ impl App {
widget::row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.label(field.label.clone(), 13.).width(Length::Fill))
.push(
self.label(ferese_config::locale::translate(&field.label), 13.)
.width(Length::Fill),
)
.push(
ferese_theme::controls::switch(enabled, p)
.name(format!(
+30 -1
View File
@@ -118,6 +118,10 @@ impl App {
.map_err(|error| error.to_string())?
.into_iter()
.next()
.map(|mut panel| {
panel.consolidate_clock_controls();
panel
})
.ok_or_else(|| "No panel configured.".into())
}
@@ -383,6 +387,25 @@ impl App {
let control_height =
(self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
if item.kind == ItemKind::Notifications {
let mark = container(cosmic::widget::Space::new().width(3).height(13))
.id(format!("preview-icon:{}", item.id.0))
.class(visuals::surface(cosmic::iced::Color::WHITE, 1.5));
let control = button::custom(container(mark).center_y(control_height))
.name(ferese_config::tr!("Edit Notifications"))
.padding([0, 4])
.height(control_height)
.on_press(Message::PanelSelect(item.id.clone()))
.class(preview_button(
palette,
self.panel_selection.as_ref() == Some(&item.id),
));
return container(control)
.id(format!("preview-item:{}", item.id.0))
.width(width.map_or(Length::Shrink, Length::Fixed))
.clip(true)
.into();
}
if let ItemKind::Workspaces { style } = item.kind {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
let control =
@@ -405,6 +428,7 @@ impl App {
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::WindowTray => icons::APPLICATION,
ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
@@ -447,9 +471,14 @@ impl App {
.id(format!("preview-icon:{}", item.id.0)),
);
if item.kind == ItemKind::WindowTray {
for source in [icons::DISPLAY, icons::FERESE] {
content = content.push(icons::tinted(source, 16, palette.text));
}
}
if !label.is_empty() {
content = content.push(
self.label(label, 12.)
self.label(ferese_config::locale::text(label), 12.)
.wrapping(cosmic::iced::widget::text::Wrapping::None),
);
}
+46 -1
View File
@@ -17,6 +17,7 @@ pub enum Page {
Startup,
Displays,
Connections,
Calendar,
}
impl Page {
@@ -36,6 +37,7 @@ impl Page {
Self::Displays,
Self::Connections,
Self::Notifications,
Self::Calendar,
Self::LockScreen,
Self::Startup,
],
@@ -75,7 +77,7 @@ impl Page {
}
#[cfg(test)]
pub const ALL: [Self; 17] = [
pub const ALL: [Self; 18] = [
Self::Appearance,
Self::General,
Self::Theme,
@@ -93,6 +95,7 @@ impl Page {
Self::Startup,
Self::Displays,
Self::Connections,
Self::Calendar,
];
pub fn title(self) -> &'static str {
@@ -117,6 +120,7 @@ impl Page {
Self::Startup => "Login items",
Self::Displays => "Displays",
Self::Connections => "Connections",
Self::Calendar => "Calendar",
}
}
@@ -141,6 +145,7 @@ impl Page {
Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.",
Self::Connections => "Wi-Fi networks and Bluetooth devices.",
Self::Calendar => "Calendar, Japanese holiday rules and custom holidays.",
})
}
@@ -159,6 +164,9 @@ impl Page {
Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01",
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4",
Self::Calendar => {
"M4 5h16v16H4z M8 3v4 M16 3v4 M4 10h16 M8 14h.01 M12 14h.01 M16 14h.01 M8 18h.01 M12 18h.01"
}
Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
@@ -971,6 +979,12 @@ pub fn fields(page: Page) -> Vec<Field> {
("en", "English"),
],
)],
Page::Calendar => vec![toggle(
"status.calendar.show_adjacent_days",
"Show dates from adjacent months",
"Display the previous and next month's dates in the calendar.",
true,
)],
Page::Tray => vec![
toggle(
"status.tray.enabled",
@@ -1027,6 +1041,7 @@ pub fn grouped_fields(page: Page, fields: Vec<Field>) -> Vec<FieldGroup> {
let p = field.path.as_str();
let title = match page {
Page::General => "Interface",
Page::Calendar => "Calendar display",
Page::Windows => {
if p.starts_with("workspaces.") || p == "layout.mode" || p.starts_with("scrolling.")
{
@@ -1115,6 +1130,36 @@ pub fn grouped_fields(page: Page, fields: Vec<Field>) -> Vec<FieldGroup> {
mod group_tests {
use super::*;
#[test]
fn every_standard_field_and_choice_has_chinese_interface_text() {
use ferese_config::locale::{Language, in_language};
for page in Page::ALL {
for field in fields(page) {
for text in [&field.label, &field.description] {
if !text.is_empty() {
assert_ne!(
in_language(Language::Chinese, text),
text,
"{}: {text}",
field.path
);
}
}
if let Kind::Choice { choices, .. } = field.kind {
for (_, text) in choices {
if !matches!(*text, "English" | "简体中文") {
assert_ne!(
in_language(Language::Chinese, text),
*text,
"{}: {text}",
field.path
);
}
}
}
}
}
}
#[test]
fn semantic_groups_keep_every_field_once_and_separate_input_devices() {
for page in Page::ALL {
let mut expected = fields(page).into_iter().map(|f| f.path).collect::<Vec<_>>();
+81 -3
View File
@@ -1137,7 +1137,7 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
.operate(&mut tree, Layout::new(&node), &renderer, &mut cards);
assert_eq!(
cards.bounds.len(),
14,
15,
"{width}: all settings destinations must be reachable"
);
for bounds in cards.bounds.values() {
@@ -1174,6 +1174,7 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
struct Centers {
buttons: std::collections::HashMap<usize, Rectangle>,
parts: Vec<(usize, Rectangle)>,
sidebar: Option<Rectangle>,
}
impl Operation for Centers {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
@@ -1197,6 +1198,9 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
}
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-sidebar")) {
self.sidebar = Some(bounds);
}
for page in Page::ALL {
for part in ["content", "icon", "label", "marker"] {
if id
@@ -1243,8 +1247,16 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
let mut centers = Centers::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut centers);
assert_eq!(centers.buttons.len(), 15);
assert_eq!(centers.parts.len(), 60);
assert_eq!(centers.buttons.len(), 16);
assert_eq!(centers.parts.len(), 64);
let sidebar = centers.sidebar.unwrap();
assert!(
centers
.buttons
.values()
.all(|button| button.x + button.width <= sidebar.x + sidebar.width - 24. + 0.5),
"navigation hitboxes must leave a separate scrollbar lane"
);
for (page, part) in &centers.parts {
let button = centers.buttons[page];
assert_eq!(button.height, 36.);
@@ -1365,3 +1377,69 @@ fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() {
"field labels must not act as window chrome"
);
}
#[test]
fn holiday_function_editor_keeps_state_when_focus_and_text_rebuild_the_page() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/calendar-editor.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Settings(Page::Calendar)),
),
)
.0;
let _ = app.update(Message::CalendarFunction(
calendar_controls::FunctionInput::Toggle,
));
let mut tree = None;
for action in [
cosmic::widget::text_editor::Action::Click(Point::new(20., 20.)),
cosmic::widget::text_editor::Action::SelectAll,
cosmic::widget::text_editor::Action::Edit(cosmic::widget::text_editor::Edit::Paste(
std::sync::Arc::new("fn japanese_holidays(year) { #{} }".into()),
)),
cosmic::widget::text_editor::Action::Move(
cosmic::widget::text_editor::Motion::DocumentStart,
),
] {
let mut view = app.page_view();
let mounted = tree.is_some();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
if mounted {
cosmic::iced::advanced::widget::tree::NAMED
.with(|named| *named.borrow_mut() = tree.take_all_named());
}
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1280., 760.)),
);
cosmic::iced::advanced::widget::tree::NAMED.with(|named| named.borrow_mut().clear());
let mut focus = cosmic::iced::advanced::widget::operation::focusable::focus::<()>(
widget::Id::new("calendar-function-editor"),
);
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut focus);
drop(view);
let _ = app.update(Message::CalendarFunction(
calendar_controls::FunctionInput::Edit(action),
));
}
assert!(
app.pending.is_empty(),
"editor changes require explicit Apply"
);
}
+11 -11
View File
@@ -45,7 +45,7 @@ impl App {
let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target];
let file = self.draft.string(path, "");
let filename = if file.is_empty() {
"No override file".to_owned()
ferese_config::locale::translate("No override file")
} else {
PathBuf::from(&file)
.file_name()
@@ -60,9 +60,9 @@ impl App {
.push(widget::tooltip(
self.label(filename, 11.)
.class(cosmic::theme::Text::Color(palette.muted)),
self.label(
self.ui_label(
if file.is_empty() {
"Choose a partial KDL theme file".to_owned()
ferese_config::locale::translate("Choose a partial KDL theme file")
} else {
file.clone()
},
@@ -73,9 +73,9 @@ impl App {
let mut scopes = row([]).spacing(2);
for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() {
scopes = scopes.push(
button::custom(self.label(label, 12.))
button::custom(self.label(ferese_config::locale::text(label), 12.))
.name(format!("Theme file: {label}"))
.padding([4, 8])
.padding([6, 10])
.class(visuals::button_style(
palette,
index == self.theme_file_target,
@@ -88,8 +88,8 @@ impl App {
.align_y(Alignment::Center)
.push(
container(scopes)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.)),
.padding(5)
.class(visuals::segment_group(palette)),
)
.push(self.settings_button(
if file.is_empty() {
@@ -159,7 +159,7 @@ impl App {
);
if scheduled {
let mut times = row([]).spacing(6).align_y(Alignment::Center);
for field in schema::fields(Page::Appearance)
for field in schema::fields(Page::Theme)
.into_iter()
.filter(|field| field.path.starts_with("theme.schedule."))
{
@@ -175,7 +175,7 @@ impl App {
let submit = field.clone();
let input = text_input(*default, value)
.font(self.font)
.padding([4, 8])
.padding([6, 10])
.size(12)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
@@ -190,7 +190,7 @@ impl App {
row([])
.spacing(6)
.align_y(Alignment::Center)
.push(self.label(field.label, 12.))
.push(self.label(ferese_config::locale::translate(&field.label), 12.))
.push(input),
);
}
@@ -209,7 +209,7 @@ impl App {
) -> Element<'_, Message> {
use ferese_config::theme::Appearance::{Dark, Light};
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
self.label(
self.ui_label(
match appearance {
Some(Light) => "Light theme",
Some(Dark) => "Dark theme",
+14 -4
View File
@@ -4,9 +4,19 @@ use cosmic::widget::icon as svg_icon;
pub use ferese_theme::Palette;
pub const PAGE_TITLE_SIZE: f32 = 16.;
pub const SECTION_TITLE_SIZE: f32 = 14.;
pub use ferese_theme::controls::{
button_style, navigation_style, settings_input as input_style, surface,
};
pub use ferese_theme::controls::{navigation_style, settings_input as input_style, surface};
pub fn button_style(mut p: Palette, selected: bool) -> cosmic::theme::Button {
p.radius = p.radius.min(5.);
ferese_theme::controls::button_style(p, selected)
}
pub fn segment_group(p: Palette) -> cosmic::theme::Container<'static> {
surface(
ferese_theme::mix(p.background, p.sidebar, 0.35),
p.radius.min(6.),
)
}
use crate::Message;
use crate::schema::Page;
@@ -33,7 +43,7 @@ pub fn panel_button(p: Palette, selected: bool) -> cosmic::theme::Button {
p.muted
}),
icon_color: Some(if enabled { p.text } else { p.muted }),
border_radius: 8.into(),
border_radius: p.radius.min(5.).into(),
border_width: if focused {
2.
} else if selected {