Consolidate desktop controls with editable holidays and a running-window tray
This commit is contained in:
1 parent
9908dde9f8
commit
326465952a
55 files changed
+4207
-450
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<_>>();
|
||||
|
||||
@@ -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 ¢ers.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"
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -112,6 +112,9 @@ impl FereseShell {
|
||||
.iter()
|
||||
.filter(|item| item.available(availability))
|
||||
{
|
||||
if item.kind == ItemKind::WindowTray && self.snapshot.windows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(item.kind, ItemKind::FocusedWindow)
|
||||
&& focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty()
|
||||
{
|
||||
@@ -474,6 +477,12 @@ impl FereseShell {
|
||||
.into();
|
||||
return (container(element).max_width(360).into(), !title.is_empty());
|
||||
}
|
||||
ItemKind::WindowTray => {
|
||||
return (
|
||||
self.view_window_tray(id, &item.id.0, width, control_height),
|
||||
!self.snapshot.windows.is_empty(),
|
||||
);
|
||||
}
|
||||
ItemKind::Activities => {
|
||||
return self.view_activity_item(
|
||||
id,
|
||||
@@ -542,7 +551,7 @@ impl FereseShell {
|
||||
button::custom(bar_content(content, bar.group_item_height))
|
||||
.padding([0, 4])
|
||||
.height(bar.group_item_height)
|
||||
.name(ferese_config::tr!("Open calendar"))
|
||||
.name(ferese_config::tr!("Open calendar and controls"))
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
cosmic::Action::App(Message::OpenMenu(
|
||||
status_ui::Menu::Calendar,
|
||||
@@ -1288,12 +1297,10 @@ mod island_tests {
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["quick-settings", "night-light", "recording", "clock"]
|
||||
["window-tray", "recording", "clock"]
|
||||
);
|
||||
assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.);
|
||||
assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.);
|
||||
assert_eq!(
|
||||
end.items[3].1.x - end.items[2].1.x,
|
||||
end.items[2].1.x - end.items[1].1.x,
|
||||
if islands { 21. } else { 34. }
|
||||
);
|
||||
if islands {
|
||||
@@ -1301,7 +1308,7 @@ mod island_tests {
|
||||
end.islands[0],
|
||||
Rectangle::new(
|
||||
(0., 0.).into(),
|
||||
(129. + 2. * padding, panel.resolved_geometry().height).into()
|
||||
(108. + 2. * padding, panel.resolved_geometry().height).into()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -431,7 +431,13 @@ fn parse_document(
|
||||
theme_mode: mode,
|
||||
panels: config
|
||||
.panels
|
||||
.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
|
||||
.unwrap_or_else(|| vec![crate::panel::Panel::default()])
|
||||
.into_iter()
|
||||
.map(|mut panel| {
|
||||
panel.consolidate_clock_controls();
|
||||
panel
|
||||
})
|
||||
.collect(),
|
||||
status: StatusConfig {
|
||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||
..config.status
|
||||
|
||||
@@ -30,6 +30,7 @@ mod rendering;
|
||||
mod status;
|
||||
mod status_ui;
|
||||
mod system_modal;
|
||||
mod window_tray;
|
||||
mod workspace_ui;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -269,6 +270,8 @@ enum Message {
|
||||
MediaArt(media::Artwork),
|
||||
ControlReady,
|
||||
ActivateWorkspace(u64),
|
||||
ActivateWindow(u64),
|
||||
HoverWindow(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
|
||||
HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
|
||||
WorkspaceTooltipDelay(u64),
|
||||
WorkspaceTooltipBounds(u64, Option<cosmic::iced::Rectangle>),
|
||||
@@ -1121,6 +1124,17 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
Message::ActivateWindow(id) => {
|
||||
if self.snapshot.windows.iter().any(|window| window.id == id)
|
||||
&& let Some(control) = &self.control
|
||||
{
|
||||
control.activate_window(id);
|
||||
}
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
Message::HoverWindow(bar, window, target, entered) => {
|
||||
self.hover_window(bar, window, target, entered)
|
||||
}
|
||||
Message::HoverWorkspace(bar, workspace, target, entered) => {
|
||||
self.hover_workspace(bar, workspace, target, entered)
|
||||
}
|
||||
@@ -1141,6 +1155,13 @@ impl cosmic::Application for FereseShell {
|
||||
if let Some(control) = &self.control {
|
||||
control.set_background_title_updates(
|
||||
self.launcher.is_some()
|
||||
|| self.config.panels.iter().any(|panel| {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| item.visible && item.kind == panel::ItemKind::WindowTray)
|
||||
})
|
||||
|| self.outputs.iter().any(|output| {
|
||||
output.runtime.hub.as_ref().is_some_and(|hub| {
|
||||
hub.borrow().hub.mode()
|
||||
|
||||
@@ -112,10 +112,19 @@ fn icon_well<'a>(
|
||||
|
||||
fn age_label(age: Duration) -> String {
|
||||
match age.as_secs() {
|
||||
0..60 => "now".into(),
|
||||
60..3600 => format!("{}m ago", age.as_secs() / 60),
|
||||
3600..86400 => format!("{}h ago", age.as_secs() / 3600),
|
||||
seconds => format!("{}d ago", seconds / 86400),
|
||||
0..60 => ferese_config::locale::translate("now"),
|
||||
60..3600 => ferese_config::locale::format(
|
||||
"{count}m ago",
|
||||
&[("count", (age.as_secs() / 60).to_string())],
|
||||
),
|
||||
3600..86400 => ferese_config::locale::format(
|
||||
"{count}h ago",
|
||||
&[("count", (age.as_secs() / 3600).to_string())],
|
||||
),
|
||||
seconds => ferese_config::locale::format(
|
||||
"{count}d ago",
|
||||
&[("count", (seconds / 86400).to_string())],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,17 +715,20 @@ impl FereseShell {
|
||||
let groups = self.notifications.history_groups();
|
||||
let count = self.notifications.entries.len();
|
||||
let subtitle = if count == 0 {
|
||||
"A quiet moment".to_owned()
|
||||
ferese_config::locale::translate("A quiet moment")
|
||||
} else {
|
||||
format!(
|
||||
"{count} {} · {} {}",
|
||||
if count == 1 {
|
||||
"notification"
|
||||
} else {
|
||||
"notifications"
|
||||
},
|
||||
groups.len(),
|
||||
if groups.len() == 1 { "app" } else { "apps" }
|
||||
let key = match (count == 1, groups.len() == 1) {
|
||||
(true, true) => "{count} notification · {apps} app",
|
||||
(true, false) => "{count} notification · {apps} apps",
|
||||
(false, true) => "{count} notifications · {apps} app",
|
||||
(false, false) => "{count} notifications · {apps} apps",
|
||||
};
|
||||
ferese_config::locale::format(
|
||||
key,
|
||||
&[
|
||||
("count", count.to_string()),
|
||||
("apps", groups.len().to_string()),
|
||||
],
|
||||
)
|
||||
};
|
||||
let heading = column([])
|
||||
@@ -775,9 +787,9 @@ impl FereseShell {
|
||||
.push(
|
||||
self.render
|
||||
.text(if self.notifications.dnd {
|
||||
"Popups paused"
|
||||
ferese_config::tr!("Popups paused")
|
||||
} else {
|
||||
"Popups enabled"
|
||||
ferese_config::tr!("Popups enabled")
|
||||
})
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
@@ -790,7 +802,11 @@ impl FereseShell {
|
||||
button::custom(
|
||||
container(
|
||||
self.render
|
||||
.text(if self.notifications.dnd { "On" } else { "Off" })
|
||||
.text(if self.notifications.dnd {
|
||||
ferese_config::tr!("On")
|
||||
} else {
|
||||
ferese_config::tr!("Off")
|
||||
})
|
||||
.size(12)
|
||||
.font(bold)
|
||||
.class(theme::Text::Color(dnd_color)),
|
||||
|
||||
@@ -1090,7 +1090,6 @@ impl FereseShell {
|
||||
.width(Length::Fill);
|
||||
let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center);
|
||||
let mut tabs = tabs.push(navigation);
|
||||
tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity));
|
||||
if let Some(battery) =
|
||||
self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity)
|
||||
{
|
||||
@@ -3447,9 +3446,9 @@ mod tests {
|
||||
let mut shell = shell();
|
||||
let id = shell.outputs[0].bar;
|
||||
for (playback, counts) in [
|
||||
(None, [0, 2, 2]),
|
||||
(Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]),
|
||||
(Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]),
|
||||
(None, [0, 1, 1]),
|
||||
(Some(ferese_ipc::media::Playback::Playing), [0, 1, 1]),
|
||||
(Some(ferese_ipc::media::Playback::Paused), [0, 1, 1]),
|
||||
] {
|
||||
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
|
||||
playback.map(|status| ferese_ipc::media::Player {
|
||||
@@ -3462,7 +3461,7 @@ mod tests {
|
||||
assert_eq!(shortcuts.len(), count);
|
||||
assert!(!shortcuts.contains(&ItemKind::NightLight));
|
||||
assert!(!shortcuts.contains(&ItemKind::Network));
|
||||
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1);
|
||||
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 0);
|
||||
assert!(!shortcuts.contains(&ItemKind::DisplayMode));
|
||||
assert!(!shortcuts.contains(&ItemKind::Audio));
|
||||
assert!(
|
||||
|
||||
@@ -21,6 +21,13 @@ impl Rendering {
|
||||
self.radius.min(limit)
|
||||
}
|
||||
|
||||
pub fn with_radius_limit(self, limit: f32) -> Self {
|
||||
Self {
|
||||
radius: self.radius(limit),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text<'a>(
|
||||
self,
|
||||
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||
|
||||
@@ -563,11 +563,8 @@ fn audio() -> Option<Audio> {
|
||||
let (volume, muted) =
|
||||
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
|
||||
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
|
||||
let output = info
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("node.description = "))
|
||||
.map(|s| s.trim_matches('"').to_owned())
|
||||
.unwrap_or_else(|| "Default output".into());
|
||||
let output = audio_output_name(&info)
|
||||
.unwrap_or_else(|| ferese_config::locale::translate("Default output"));
|
||||
|
||||
Some(Audio {
|
||||
volume,
|
||||
@@ -576,6 +573,17 @@ fn audio() -> Option<Audio> {
|
||||
})
|
||||
}
|
||||
|
||||
fn audio_output_name(info: &str) -> Option<String> {
|
||||
info.lines()
|
||||
.find_map(|line| {
|
||||
line.trim()
|
||||
.trim_start_matches('*')
|
||||
.trim_start()
|
||||
.strip_prefix("node.description = ")
|
||||
})
|
||||
.map(|s| s.trim_matches('"').to_owned())
|
||||
}
|
||||
|
||||
fn read(path: &Path, name: &str) -> Option<String> {
|
||||
fs::read_to_string(path.join(name))
|
||||
.ok()
|
||||
@@ -1050,6 +1058,20 @@ mod tests {
|
||||
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), None);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn audio_device_name_accepts_wpctl_marked_and_unmarked_properties() {
|
||||
for info in [
|
||||
" * node.description = \"USB Audio S/PDIF Output\"",
|
||||
" node.description = \"USB Audio S/PDIF Output\"",
|
||||
] {
|
||||
assert_eq!(
|
||||
audio_output_name(info).as_deref(),
|
||||
Some("USB Audio S/PDIF Output")
|
||||
);
|
||||
}
|
||||
assert_eq!(audio_output_name("node.name = \"unrelated\""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_parser_handles_mute_and_rejects_invalid_values() {
|
||||
assert_eq!(parse_audio("Volume: 0.42 [MUTED]"), Some((42, true)));
|
||||
|
||||
@@ -30,14 +30,22 @@ pub(super) fn view<'a>(
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
if combined {
|
||||
volume_heading = volume_heading.push(shell_switch(
|
||||
shell.render,
|
||||
"Mute",
|
||||
a.muted,
|
||||
Action::Mute(!a.muted),
|
||||
theme,
|
||||
p,
|
||||
));
|
||||
volume_heading = volume_heading
|
||||
.push(
|
||||
shell
|
||||
.render
|
||||
.text(ferese_config::tr!("Mute"))
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
)
|
||||
.push(shell_switch(
|
||||
shell.render,
|
||||
"Mute",
|
||||
a.muted,
|
||||
Action::Mute(!a.muted),
|
||||
theme,
|
||||
p,
|
||||
));
|
||||
}
|
||||
let audio = column![
|
||||
volume_heading,
|
||||
@@ -63,6 +71,18 @@ pub(super) fn view<'a>(
|
||||
audio.into()
|
||||
};
|
||||
rows = rows.push(audio);
|
||||
} else {
|
||||
rows = rows.push(
|
||||
column![
|
||||
shell.render.text(ferese_config::tr!("Volume")).size(13),
|
||||
shell
|
||||
.render
|
||||
.text(ferese_config::tr!("No audio output available"))
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -60,39 +60,20 @@ impl FereseShell {
|
||||
} else {
|
||||
theme.text_muted
|
||||
});
|
||||
let mut indicator = if kind == Menu::Battery {
|
||||
let indicator = if kind == Menu::Notifications {
|
||||
notification_indicator(
|
||||
!self.notifications.entries.is_empty()
|
||||
|| self
|
||||
.status
|
||||
.notifications
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.count > 0),
|
||||
)
|
||||
} else if kind == Menu::Battery {
|
||||
self.battery_indicator(theme, 1.0, battery_percentage)
|
||||
} else {
|
||||
accented_icon(source, metrics.icon_size, foreground, color(theme.accent)).into()
|
||||
};
|
||||
if kind == Menu::Notifications {
|
||||
let unread = self
|
||||
.status
|
||||
.notifications
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.count > 0 && !n.dnd);
|
||||
let dot = container(cosmic::widget::Space::new())
|
||||
.width(4)
|
||||
.height(4)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: unread.then_some(Background::Color(foreground)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: self.render.radius(2.0).into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
// Keep the popup anchor stable when reading clears the badge.
|
||||
indicator = cosmic::iced::widget::stack![
|
||||
indicator,
|
||||
container(dot)
|
||||
.width(metrics.icon_size)
|
||||
.height(metrics.icon_size)
|
||||
.align_x(Alignment::End),
|
||||
]
|
||||
.into();
|
||||
}
|
||||
let mut content = row![indicator].spacing(4).align_y(Alignment::Center);
|
||||
|
||||
if kind == Menu::Recording
|
||||
@@ -107,7 +88,9 @@ impl FereseShell {
|
||||
}
|
||||
let content = bar_content(content, metrics.group_item_height);
|
||||
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
||||
let icon_width = if kind == Menu::Battery {
|
||||
let icon_width = if kind == Menu::Notifications {
|
||||
3.
|
||||
} else if kind == Menu::Battery {
|
||||
crate::battery_indicator::WIDTH
|
||||
} else {
|
||||
f32::from(metrics.icon_size)
|
||||
@@ -120,7 +103,14 @@ impl FereseShell {
|
||||
} else {
|
||||
status_label(kind, &self.status)
|
||||
})
|
||||
.padding([0.0, ((metrics.height - icon_width) * 0.5).max(4.0)])
|
||||
.padding([
|
||||
0.0,
|
||||
if kind == Menu::Notifications {
|
||||
4.
|
||||
} else {
|
||||
((metrics.height - icon_width) * 0.5).max(4.0)
|
||||
},
|
||||
])
|
||||
.height(metrics.group_item_height)
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
if kind == Menu::Recording {
|
||||
@@ -144,6 +134,27 @@ impl FereseShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_indicator<'a>(has_notifications: bool) -> Element<'a, cosmic::Action<Message>> {
|
||||
container(cosmic::widget::Space::new())
|
||||
.id("notification-indicator")
|
||||
.width(3)
|
||||
.height(13)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(if has_notifications {
|
||||
cosmic::iced::Color::WHITE
|
||||
} else {
|
||||
cosmic::iced::Color::TRANSPARENT
|
||||
})),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: 1.5.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_label(kind: Menu, s: &Snapshot) -> String {
|
||||
match kind {
|
||||
Menu::Tray => "Application tray".into(),
|
||||
@@ -303,11 +314,12 @@ mod tests {
|
||||
let ordinary = measure(&shell, Menu::System, false);
|
||||
for (count, dnd) in [(0, false), (3, false), (0, false), (3, true)] {
|
||||
shell.status.notifications = Some(status::Notifications { count, dnd });
|
||||
let notification = measure(&shell, Menu::Notifications, false);
|
||||
assert_eq!(
|
||||
measure(&shell, Menu::Notifications, false),
|
||||
ordinary,
|
||||
"notification badges must stay inside the normal icon hitbox"
|
||||
notification.width, 11.,
|
||||
"notification mark keeps its narrow stable allocation"
|
||||
);
|
||||
assert_eq!(notification.height, ordinary.height);
|
||||
}
|
||||
for percent in [0, 9, 50, 100] {
|
||||
shell.status.battery = Some(status::Battery {
|
||||
|
||||
@@ -23,21 +23,38 @@ pub(super) fn view<'a>(
|
||||
rows = rows.push(
|
||||
shell
|
||||
.render
|
||||
.text(match b.status.as_str() {
|
||||
.text(ferese_config::locale::text(match b.status.as_str() {
|
||||
"Charging" => "Connected to power",
|
||||
"Discharging" => "Running on battery power",
|
||||
"Full" => "Battery fully charged",
|
||||
_ => "Battery status reported by the system",
|
||||
})
|
||||
}))
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
}
|
||||
rows = rows.push(ferese_theme::menus::section_label(
|
||||
"Power mode",
|
||||
shell.render.font,
|
||||
power_controls(shell, rows, style, true)
|
||||
}
|
||||
|
||||
pub(super) fn power_controls<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
show_unavailable: bool,
|
||||
) -> MenuRows<'a> {
|
||||
let MenuStyle {
|
||||
theme,
|
||||
muted,
|
||||
));
|
||||
opacity: p,
|
||||
..
|
||||
} = style;
|
||||
if shell.status.power_profiles.is_some() || show_unavailable {
|
||||
rows = rows.push(ferese_theme::menus::section_label(
|
||||
ferese_config::tr!("Power mode"),
|
||||
shell.render.font,
|
||||
muted,
|
||||
));
|
||||
}
|
||||
if let Some(profiles) = &shell.status.power_profiles {
|
||||
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
|
||||
for (index, (label, icon, profile)) in [
|
||||
@@ -69,7 +86,7 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
}
|
||||
rows = rows.push(modes);
|
||||
} else {
|
||||
} else if show_unavailable {
|
||||
rows = rows.push(
|
||||
shell
|
||||
.render
|
||||
@@ -114,7 +131,7 @@ pub(super) fn view<'a>(
|
||||
|
||||
if shell.status.reboot || shell.status.poweroff || shell.status.suspend {
|
||||
rows = rows.push(ferese_theme::menus::section_label(
|
||||
"Power",
|
||||
ferese_config::tr!("Power"),
|
||||
shell.render.font,
|
||||
muted,
|
||||
));
|
||||
@@ -171,9 +188,12 @@ fn power_tile<'a>(
|
||||
opacity,
|
||||
);
|
||||
let tile = container(
|
||||
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center),
|
||||
column![
|
||||
bar_icon(icon, 19, foreground),
|
||||
render.text(ferese_config::locale::text(label)).size(11)
|
||||
]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
@@ -184,7 +204,13 @@ fn power_tile<'a>(
|
||||
profile,
|
||||
))));
|
||||
|
||||
motion::button(render, btn, foreground, selected, opacity)
|
||||
motion::button(
|
||||
render.with_radius_limit(6.),
|
||||
btn,
|
||||
foreground,
|
||||
selected,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
|
||||
fn power_tile_action<'a>(
|
||||
@@ -197,9 +223,12 @@ fn power_tile_action<'a>(
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let foreground = color_with_opacity(theme.text_primary, opacity);
|
||||
let tile = container(
|
||||
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center),
|
||||
column![
|
||||
bar_icon(icon, 19, foreground),
|
||||
render.text(ferese_config::locale::text(label)).size(11)
|
||||
]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
@@ -208,5 +237,11 @@ fn power_tile_action<'a>(
|
||||
.padding([8, 2])
|
||||
.on_press(cosmic::Action::App(Message::ConfirmPower(action)));
|
||||
|
||||
motion::button(render, btn, foreground, false, opacity)
|
||||
motion::button(
|
||||
render.with_radius_limit(6.),
|
||||
btn,
|
||||
foreground,
|
||||
false,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub(super) fn view<'a>(
|
||||
offset: i32,
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
settings: &ferese_config::calendar::Settings,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let primary = color_with_opacity(theme.text_primary, opacity);
|
||||
ferese_theme::calendar::grid(
|
||||
@@ -40,6 +41,7 @@ pub(super) fn view<'a>(
|
||||
opacity,
|
||||
),
|
||||
],
|
||||
settings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ pub(super) fn hub<'a>(
|
||||
offset: i32,
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
settings: &ferese_config::calendar::Settings,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let primary = color_with_opacity(theme.text_primary, opacity);
|
||||
let navigation = |label: &'static str, message: Message| {
|
||||
@@ -76,8 +79,9 @@ pub(super) fn hub<'a>(
|
||||
opacity,
|
||||
[
|
||||
navigation("‹", Message::CalendarMonth(-1)),
|
||||
navigation("Today", Message::CalendarToday),
|
||||
navigation(ferese_config::tr!("Today"), Message::CalendarToday),
|
||||
navigation("›", Message::CalendarMonth(1)),
|
||||
],
|
||||
settings,
|
||||
)
|
||||
}
|
||||
@@ -151,7 +151,15 @@ pub(super) fn shell_switch<'a>(
|
||||
render,
|
||||
button::custom(toggle)
|
||||
.padding(4)
|
||||
.name(format!("{label}: {}", if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{}: {}",
|
||||
ferese_config::locale::translate(label),
|
||||
if enabled {
|
||||
ferese_config::tr!("On")
|
||||
} else {
|
||||
ferese_config::tr!("Off")
|
||||
}
|
||||
))
|
||||
.on_press(cosmic::Action::App(Message::Control(action))),
|
||||
color(palette.text_primary),
|
||||
false,
|
||||
@@ -168,7 +176,7 @@ pub(super) fn toggle_row<'a>(
|
||||
opacity: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
ferese_theme::menus::row(
|
||||
label,
|
||||
ferese_config::locale::translate(label),
|
||||
shell_switch(render, label, on, action, palette, opacity),
|
||||
render.font,
|
||||
)
|
||||
|
||||
@@ -286,16 +286,16 @@ mod tests {
|
||||
#[test]
|
||||
fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.status.network = Some(crate::status::Network {
|
||||
enabled: true,
|
||||
connection: Some("Test network".into()),
|
||||
signal: 90,
|
||||
shell.status.audio = Some(crate::status::Audio {
|
||||
volume: 50,
|
||||
muted: false,
|
||||
output: String::new(),
|
||||
});
|
||||
let bar = shell.outputs[0].bar;
|
||||
let mut anchor = PopoverAnchor {
|
||||
parent: bar,
|
||||
panel: shell.config.panels[0].id.clone(),
|
||||
item: Some(crate::panel::ItemId("network".into())),
|
||||
item: Some(crate::panel::ItemId("audio".into())),
|
||||
rectangle: Rectangle {
|
||||
x: 1000,
|
||||
y: 0,
|
||||
@@ -303,15 +303,15 @@ mod tests {
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
drop(shell.open_menu(Menu::Network, anchor.clone()));
|
||||
drop(shell.open_menu(Menu::Audio, anchor.clone()));
|
||||
let popup = shell.menu.as_ref().unwrap().id;
|
||||
anchor.rectangle.x = 800;
|
||||
drop(shell.open_menu(Menu::Network, anchor.clone()));
|
||||
drop(shell.open_menu(Menu::Audio, anchor.clone()));
|
||||
let menu = shell.menu.as_ref().unwrap();
|
||||
assert_eq!(menu.id, popup);
|
||||
assert_eq!(menu.anchor, anchor);
|
||||
assert!(!menu.motion.closing());
|
||||
drop(shell.open_menu(Menu::Network, anchor));
|
||||
drop(shell.open_menu(Menu::Audio, anchor));
|
||||
assert!(shell.menu.is_none());
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ mod tests {
|
||||
let anchor = PopoverAnchor {
|
||||
parent: window::Id::unique(),
|
||||
panel: panel.id.clone(),
|
||||
item: Some(crate::panel::ItemId("network".into())),
|
||||
item: Some(crate::panel::ItemId("audio".into())),
|
||||
rectangle: Rectangle {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -453,10 +453,7 @@ mod tests {
|
||||
};
|
||||
assert!(anchor.belongs_to(&panel));
|
||||
let items = &mut panel.end.groups[0].items;
|
||||
let network = items
|
||||
.iter()
|
||||
.position(|item| item.id.0 == "network")
|
||||
.unwrap();
|
||||
let network = items.iter().position(|item| item.id.0 == "audio").unwrap();
|
||||
items[network].visible = false;
|
||||
assert!(!anchor.belongs_to(&panel));
|
||||
panel.end.groups[0].items.remove(network);
|
||||
|
||||
@@ -58,7 +58,7 @@ pub enum Menu {
|
||||
|
||||
impl Menu {
|
||||
fn action_error(self, error: &status::ActionError) -> Option<&str> {
|
||||
let relevant = self == Self::System
|
||||
let relevant = matches!(self, Self::System | Self::Calendar)
|
||||
|| matches!(
|
||||
(self, &error.action),
|
||||
(
|
||||
@@ -78,16 +78,15 @@ impl Menu {
|
||||
|
||||
pub(crate) fn width(self) -> f32 {
|
||||
match self {
|
||||
Self::System => 360.0,
|
||||
Self::System | Self::Calendar => 360.0,
|
||||
Self::Battery => 328.0,
|
||||
Self::Calendar => 268.0,
|
||||
Self::Notifications | Self::Media => 368.0,
|
||||
_ => 300.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn height_limit(self) -> f32 {
|
||||
if self == Self::Calendar { 270.0 } else { 720.0 }
|
||||
720.0
|
||||
}
|
||||
|
||||
fn title(self) -> &'static str {
|
||||
@@ -236,12 +235,18 @@ impl FereseShell {
|
||||
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
|
||||
Menu::Audio => audio::view(self, rows, style, false),
|
||||
Menu::Battery => battery::view(self, rows, style),
|
||||
Menu::Calendar => rows.push(calendar::hub(
|
||||
self.render,
|
||||
self.calendar_offset,
|
||||
theme,
|
||||
opacity,
|
||||
)),
|
||||
Menu::Calendar => system::view(
|
||||
self,
|
||||
rows.push(calendar::hub(
|
||||
self.render,
|
||||
self.calendar_offset,
|
||||
theme,
|
||||
opacity,
|
||||
&self.config.status.calendar,
|
||||
)),
|
||||
style,
|
||||
true,
|
||||
),
|
||||
Menu::Media => media::hub(self, rows, style),
|
||||
Menu::Notifications => {
|
||||
let mut rows = notification_controls(self, rows, style, true);
|
||||
@@ -471,9 +476,18 @@ impl FereseShell {
|
||||
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
|
||||
Menu::Audio => audio::view(self, rows, style, false),
|
||||
Menu::Battery => battery::view(self, rows, style),
|
||||
Menu::Calendar => {
|
||||
rows.push(calendar::view(self.render, self.calendar_offset, theme, p))
|
||||
}
|
||||
Menu::Calendar => system::view(
|
||||
self,
|
||||
rows.push(calendar::view(
|
||||
self.render,
|
||||
self.calendar_offset,
|
||||
theme,
|
||||
p,
|
||||
&self.config.status.calendar,
|
||||
)),
|
||||
style,
|
||||
true,
|
||||
),
|
||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
|
||||
Menu::CaptureStatus => rows.push(self.view_hub_menu(kind, p)),
|
||||
@@ -844,7 +858,8 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(
|
||||
menu.action_error(&error),
|
||||
(menu == relevant || menu == Menu::System).then_some("control failed"),
|
||||
(menu == relevant || matches!(menu, Menu::System | Menu::Calendar))
|
||||
.then_some("control failed"),
|
||||
"{menu:?} showed the wrong control error: {:?}",
|
||||
error.action
|
||||
);
|
||||
@@ -858,8 +873,8 @@ mod tests {
|
||||
assert_eq!(Menu::Network.width(), 300.0);
|
||||
assert_eq!(Menu::Bluetooth.width(), 300.0);
|
||||
assert_eq!(Menu::Battery.width(), 328.0);
|
||||
assert_eq!(Menu::Calendar.width(), 268.0);
|
||||
assert_eq!(Menu::Calendar.height_limit(), 270.0);
|
||||
assert_eq!(Menu::Calendar.width(), 360.0);
|
||||
assert_eq!(Menu::Calendar.height_limit(), 720.0);
|
||||
assert_eq!(Menu::Audio.width(), 300.0);
|
||||
assert_eq!(Menu::Notifications.width(), 368.0);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use cosmic::{Element, theme};
|
||||
|
||||
use super::bar::status_icon;
|
||||
use super::controls::control_card;
|
||||
use super::{Menu, MenuRows, MenuStyle, audio, battery, notification_controls};
|
||||
use super::{Menu, MenuRows, MenuStyle, audio, battery};
|
||||
use crate::status::{self, Action};
|
||||
use crate::{FereseShell, Message, accented_icon, color, color_with_opacity, motion};
|
||||
|
||||
@@ -28,11 +28,17 @@ pub(super) fn view<'a>(
|
||||
(Mode::Auto, "◐ Auto"),
|
||||
] {
|
||||
modes = modes.push(motion::button(
|
||||
shell.render,
|
||||
button::custom(shell.render.text(label).font(shell.render.font).size(13))
|
||||
.width(Length::Fill)
|
||||
.padding(9)
|
||||
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
|
||||
shell.render.with_radius_limit(6.),
|
||||
button::custom(
|
||||
shell
|
||||
.render
|
||||
.text(ferese_config::locale::text(label))
|
||||
.font(shell.render.font)
|
||||
.size(13),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(9)
|
||||
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
|
||||
primary,
|
||||
shell.config.theme_mode == mode,
|
||||
p,
|
||||
@@ -80,9 +86,9 @@ pub(super) fn view<'a>(
|
||||
shell
|
||||
.render
|
||||
.text(if theme.appearance == Appearance::Light {
|
||||
"Auto · currently light"
|
||||
ferese_config::tr!("Auto · currently light")
|
||||
} else {
|
||||
"Auto · currently dark"
|
||||
ferese_config::tr!("Auto · currently dark")
|
||||
})
|
||||
.font(shell.render.font)
|
||||
.size(12)
|
||||
@@ -96,9 +102,11 @@ pub(super) fn view<'a>(
|
||||
shell.render,
|
||||
"Wi-Fi",
|
||||
if n.enabled {
|
||||
n.connection.as_deref().unwrap_or("Not connected")
|
||||
n.connection
|
||||
.as_deref()
|
||||
.unwrap_or(ferese_config::tr!("Not connected"))
|
||||
} else {
|
||||
"Off"
|
||||
ferese_config::tr!("Off")
|
||||
},
|
||||
status_icon(Menu::Network, &shell.status).0,
|
||||
n.enabled,
|
||||
@@ -112,9 +120,12 @@ pub(super) fn view<'a>(
|
||||
shell.render,
|
||||
"Bluetooth",
|
||||
if b.enabled {
|
||||
b.devices.first().map(String::as_str).unwrap_or("On")
|
||||
b.devices
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or(ferese_config::tr!("On"))
|
||||
} else {
|
||||
"Off"
|
||||
ferese_config::tr!("Off")
|
||||
},
|
||||
status_icon(Menu::Bluetooth, &shell.status).0,
|
||||
b.enabled,
|
||||
@@ -155,8 +166,7 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
rows = audio::view(shell, rows, style, true);
|
||||
rows = battery::brightness(shell, rows, style);
|
||||
rows = super::night_light::view(shell, rows, style);
|
||||
rows = notification_controls(shell, rows, style, true);
|
||||
rows = battery::power_controls(shell, rows, style, false);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -246,12 +256,18 @@ fn connection_control<'a>(
|
||||
.class(ferese_theme::controls::filled_button(
|
||||
fill,
|
||||
foreground,
|
||||
render.radius(14.),
|
||||
render.radius(6.),
|
||||
opacity,
|
||||
))
|
||||
.into()
|
||||
} else {
|
||||
motion::button(render, button, foreground, false, opacity)
|
||||
motion::button(
|
||||
render.with_radius_limit(6.),
|
||||
button,
|
||||
foreground,
|
||||
false,
|
||||
opacity,
|
||||
)
|
||||
};
|
||||
container(tile).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
@@ -296,6 +312,122 @@ pub(super) fn heading<'a>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn combined_controls_include_audio_and_confirmed_power_without_dnd_or_night_light() {
|
||||
use cosmic::iced::advanced::renderer::Headless;
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget};
|
||||
use cosmic::iced::{Event, Rectangle, Size, mouse};
|
||||
#[derive(Default)]
|
||||
struct Labels(Vec<(String, Rectangle)>);
|
||||
impl widget::Operation for Labels {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
||||
self.0.push((text.into(), bounds));
|
||||
}
|
||||
}
|
||||
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||
cosmic::iced::Font::default(),
|
||||
cosmic::iced::Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let mut app = crate::tests::shell_with_measured_panel();
|
||||
app.status.audio = Some(status::Audio {
|
||||
volume: 45,
|
||||
muted: false,
|
||||
output: "Test output".into(),
|
||||
});
|
||||
app.status.power_profiles = Some(status::PowerProfiles {
|
||||
active: "balanced".into(),
|
||||
available: [true; 3],
|
||||
});
|
||||
app.status.notifications = Some(status::Notifications {
|
||||
count: 1,
|
||||
dnd: false,
|
||||
});
|
||||
app.status.poweroff = true;
|
||||
app.status.reboot = true;
|
||||
app.status.suspend = true;
|
||||
let theme = app.config.theme;
|
||||
let style = MenuStyle {
|
||||
theme,
|
||||
primary: color(theme.text_primary),
|
||||
muted: color(theme.text_muted),
|
||||
opacity: 1.,
|
||||
};
|
||||
let mut view: Element<'_, cosmic::Action<Message>> =
|
||||
view(&app, column([]), style, true).into();
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(328., 1000.)),
|
||||
);
|
||||
let mut labels = Labels::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||
for key in [
|
||||
"Volume",
|
||||
"Power mode",
|
||||
"Balanced",
|
||||
"Power off",
|
||||
"Restart",
|
||||
"Suspend",
|
||||
] {
|
||||
assert!(
|
||||
labels
|
||||
.0
|
||||
.iter()
|
||||
.any(|(text, _)| text == ferese_config::locale::text(key)),
|
||||
"missing {key}"
|
||||
);
|
||||
}
|
||||
for key in ["Do Not Disturb", "Night light"] {
|
||||
assert!(
|
||||
!labels
|
||||
.0
|
||||
.iter()
|
||||
.any(|(text, _)| text == ferese_config::locale::text(key))
|
||||
);
|
||||
}
|
||||
let point = labels
|
||||
.0
|
||||
.iter()
|
||||
.find(|(text, _)| text == ferese_config::tr!("Power off"))
|
||||
.unwrap()
|
||||
.1
|
||||
.center();
|
||||
let mut messages = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&Event::Mouse(event),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Available(point),
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&node.bounds(),
|
||||
);
|
||||
}
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
cosmic::Action::App(Message::ConfirmPower(Action::Poweroff))
|
||||
)));
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, cosmic::Action::App(Message::ExecutePower)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_captions_fit_one_short_line() {
|
||||
assert_eq!(super::connection_caption("88%"), "88%");
|
||||
|
||||
@@ -556,9 +556,11 @@ impl FereseShell {
|
||||
let rows = match &modal.content {
|
||||
Content::Power(action) => {
|
||||
let mut rows = column![
|
||||
self.render.text(action.title()).size(17),
|
||||
self.render
|
||||
.text(action.description())
|
||||
.text(ferese_config::locale::text(action.title()))
|
||||
.size(17),
|
||||
self.render
|
||||
.text(ferese_config::locale::text(action.description()))
|
||||
.size(13)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
]
|
||||
@@ -588,9 +590,12 @@ impl FereseShell {
|
||||
.as_ref()
|
||||
.is_some_and(|approval| approval.force())
|
||||
{
|
||||
format!("{} anyway", action.label())
|
||||
ferese_config::locale::format(
|
||||
"{action} anyway",
|
||||
&[("action", ferese_config::locale::translate(action.label()))],
|
||||
)
|
||||
} else {
|
||||
action.label().to_owned()
|
||||
ferese_config::locale::translate(action.label())
|
||||
};
|
||||
if let Some(error) = &modal.error {
|
||||
rows = rows.push(self.render.text(error).size(13));
|
||||
@@ -600,7 +605,7 @@ impl FereseShell {
|
||||
row![
|
||||
Space::new().width(Length::Fill),
|
||||
ferese_theme::controls::text_button(
|
||||
"Cancel",
|
||||
ferese_config::tr!("Cancel"),
|
||||
self.render.font,
|
||||
palette,
|
||||
false
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Length, window};
|
||||
use cosmic::widget::{button, container, icon, mouse_area, row};
|
||||
|
||||
use crate::control::{ShellSnapshot, WindowSnapshot};
|
||||
use crate::{FereseShell, Message, color, motion};
|
||||
|
||||
// Launch order stays stable as focus changes. Each open window gets its own
|
||||
// entry, including multiple windows belonging to the same application.
|
||||
fn windows(snapshot: &ShellSnapshot) -> Vec<&WindowSnapshot> {
|
||||
let mut windows: Vec<_> = snapshot.windows.iter().collect();
|
||||
windows.sort_by_key(|window| window.id);
|
||||
windows
|
||||
}
|
||||
|
||||
pub(super) fn caption(window: &WindowSnapshot) -> &str {
|
||||
if !window.title.trim().is_empty() {
|
||||
&window.title
|
||||
} else if !window.app_id.is_empty() {
|
||||
&window.app_id
|
||||
} else {
|
||||
ferese_config::tr!("Window")
|
||||
}
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn view_window_tray(
|
||||
&self,
|
||||
bar: window::Id,
|
||||
instance: &str,
|
||||
allocated: Option<f32>,
|
||||
height: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let windows = windows(&self.snapshot);
|
||||
if windows.is_empty() {
|
||||
return container(row([])).width(0).into();
|
||||
}
|
||||
let palette = self.config.theme.for_bar();
|
||||
let foreground = color(palette.text_primary);
|
||||
let mut entries = row([]).spacing(2);
|
||||
let natural_width = windows.len() as f32 * 32. - 2.;
|
||||
for window in windows {
|
||||
let foreground = if window.urgent {
|
||||
color(palette.accent)
|
||||
} else {
|
||||
foreground
|
||||
};
|
||||
let entry = button::custom(
|
||||
container(icon(self.workspace_ui.app_icon(&window.app_id)).size(18))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
)
|
||||
.name(caption(window))
|
||||
.width(30)
|
||||
.height(height)
|
||||
.padding(4)
|
||||
.on_press(cosmic::Action::App(Message::ActivateWindow(window.id)));
|
||||
let entry = motion::button(
|
||||
self.render.with_radius_limit(5.),
|
||||
entry,
|
||||
foreground,
|
||||
window.focused,
|
||||
1.,
|
||||
);
|
||||
let target = cosmic::iced::advanced::widget::Id::new(format!(
|
||||
"window-tray:{bar:?}:{instance}:{}",
|
||||
window.id
|
||||
));
|
||||
let entry = mouse_area(container(entry).id(target.clone()))
|
||||
.on_enter(cosmic::Action::App(Message::HoverWindow(
|
||||
bar,
|
||||
window.id,
|
||||
target.clone(),
|
||||
true,
|
||||
)))
|
||||
.on_exit(cosmic::Action::App(Message::HoverWindow(
|
||||
bar,
|
||||
window.id,
|
||||
target.clone(),
|
||||
false,
|
||||
)));
|
||||
let entry = if self.workspace_ui.hovering(bar, &target) {
|
||||
entry
|
||||
} else {
|
||||
entry.on_move(move |_| {
|
||||
cosmic::Action::App(Message::HoverWindow(bar, window.id, target.clone(), true))
|
||||
})
|
||||
};
|
||||
entries = entries.push(entry);
|
||||
}
|
||||
cosmic::iced::widget::scrollable(entries)
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||
cosmic::iced::widget::scrollable::Scrollbar::default()
|
||||
.width(0)
|
||||
.scroller_width(0),
|
||||
))
|
||||
.width(allocated.unwrap_or(natural_width.min(360.)).max(0.))
|
||||
.height(height)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn window(id: u64, title: &str) -> WindowSnapshot {
|
||||
WindowSnapshot {
|
||||
id,
|
||||
title: title.into(),
|
||||
app_id: "test.app".into(),
|
||||
workspace: 1,
|
||||
geometry: None,
|
||||
focused: false,
|
||||
urgent: false,
|
||||
fullscreen: false,
|
||||
maximized: false,
|
||||
floating: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_icon_activates_its_own_window_at_the_drawn_bounds() {
|
||||
use cosmic::iced::advanced::renderer::Headless;
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget};
|
||||
use cosmic::iced::{Event, Rectangle, Size, mouse};
|
||||
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||
cosmic::iced::Font::default(),
|
||||
cosmic::iced::Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let mut app = crate::tests::shell_with_measured_panel();
|
||||
app.snapshot.windows = vec![window(1, "One"), window(2, "Two"), window(3, "Three")];
|
||||
let bar = app.outputs[0].bar;
|
||||
struct Bounds(Vec<(widget::Id, Option<Rectangle>)>);
|
||||
impl widget::Operation for Bounds {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if let Some((_, found)) = self.0.iter_mut().find(|(target, _)| Some(&*target) == id)
|
||||
{
|
||||
*found = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bounds = Bounds(
|
||||
(1..=3)
|
||||
.map(|id| {
|
||||
(
|
||||
widget::Id::new(format!("window-tray:{bar:?}:test:{id}")),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let mut view = app.view_window_tray(bar, "test", None, 28.);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(360., 36.)),
|
||||
)
|
||||
.move_to((300., 0.));
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
|
||||
for (index, (_, bounds)) in bounds.0.iter().enumerate() {
|
||||
let point = bounds.expect("icon bounds").center();
|
||||
let mut messages = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&Event::Mouse(event),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Available(point),
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&node.bounds(),
|
||||
);
|
||||
}
|
||||
let activated: Vec<_> = messages
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
cosmic::Action::App(Message::ActivateWindow(id)) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(activated, [index as u64 + 1], "icon at {point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_changes_preserve_order_and_closed_windows_disappear() {
|
||||
let mut snapshot = ShellSnapshot::default();
|
||||
snapshot.windows = vec![window(3, "Third"), window(1, "First"), window(2, "Second")];
|
||||
snapshot.windows[0].focused = true;
|
||||
assert_eq!(
|
||||
windows(&snapshot).iter().map(|w| w.id).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
snapshot.windows[0].focused = false;
|
||||
snapshot.windows[1].focused = true;
|
||||
snapshot.windows.retain(|w| w.id != 2);
|
||||
assert_eq!(
|
||||
windows(&snapshot).iter().map(|w| w.id).collect::<Vec<_>>(),
|
||||
[1, 3]
|
||||
);
|
||||
assert_eq!(caption(windows(&snapshot)[0]), "First");
|
||||
snapshot.windows.clear();
|
||||
assert!(windows(&snapshot).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_titles_fall_back_to_app_identity() {
|
||||
assert_eq!(caption(&window(1, " ")), "test.app");
|
||||
}
|
||||
}
|
||||
@@ -13,23 +13,22 @@ use crate::{FereseShell, ShellSnapshot, motion};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn update_workspace_ui(&mut self) -> Task<Message> {
|
||||
let needs_icons = [
|
||||
&self.config.panels[0].start,
|
||||
&self.config.panels[0].center,
|
||||
&self.config.panels[0].end,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| {
|
||||
item.visible
|
||||
&& matches!(
|
||||
item.kind,
|
||||
crate::panel::ItemKind::Workspaces {
|
||||
style: ferese_panel::WorkspaceStyle::AppIcons
|
||||
}
|
||||
)
|
||||
});
|
||||
let needs_icons = self
|
||||
.config
|
||||
.panels
|
||||
.iter()
|
||||
.flat_map(|panel| [&panel.start, &panel.center, &panel.end])
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| {
|
||||
item.visible
|
||||
&& matches!(
|
||||
item.kind,
|
||||
crate::panel::ItemKind::Workspaces {
|
||||
style: ferese_panel::WorkspaceStyle::AppIcons
|
||||
} | crate::panel::ItemKind::WindowTray
|
||||
)
|
||||
});
|
||||
self.workspace_ui
|
||||
.update(&self.snapshot, self.config.animations);
|
||||
let icons = self.workspace_ui.load_icons(&self.snapshot, needs_icons);
|
||||
@@ -39,6 +38,9 @@ impl FereseShell {
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == hover.workspace)
|
||||
|| hover
|
||||
.window
|
||||
.is_some_and(|id| !self.snapshot.windows.iter().any(|window| window.id == id))
|
||||
}) {
|
||||
self.dismiss_workspace_tooltip()
|
||||
} else {
|
||||
@@ -88,20 +90,19 @@ fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<Str
|
||||
.into_iter()
|
||||
.map(|app_id| {
|
||||
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str()));
|
||||
let source = entry
|
||||
.and_then(fde::DesktopEntry::icon)
|
||||
.unwrap_or("application-x-executable");
|
||||
let handle = if source.starts_with('/') {
|
||||
icon::from_path(source.into())
|
||||
} else {
|
||||
icon::from_name(source)
|
||||
.prefer_svg(true)
|
||||
.size(32)
|
||||
.fallback(Some(icon::IconFallback::Names(vec![
|
||||
"application-x-executable".into(),
|
||||
])))
|
||||
.handle()
|
||||
};
|
||||
let path = entry.and_then(fde::DesktopEntry::icon).and_then(|source| {
|
||||
if source.starts_with('/') {
|
||||
Some(std::path::PathBuf::from(source))
|
||||
} else {
|
||||
icon::from_name(source).prefer_svg(true).size(32).path()
|
||||
}
|
||||
});
|
||||
// Named::handle returns an empty SVG when neither an application
|
||||
// icon nor a theme fallback exists. Keep every live window visible.
|
||||
let handle = path
|
||||
.filter(|path| path.is_file())
|
||||
.map(icon::from_path)
|
||||
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION));
|
||||
(app_id, handle)
|
||||
})
|
||||
.collect();
|
||||
@@ -123,6 +124,7 @@ pub(super) struct WorkspaceUi {
|
||||
struct Hover {
|
||||
bar: window::Id,
|
||||
workspace: u64,
|
||||
window: Option<u64>,
|
||||
target: Id,
|
||||
serial: u64,
|
||||
}
|
||||
@@ -218,6 +220,7 @@ impl FereseShell {
|
||||
self.workspace_ui.hovered = Some(Hover {
|
||||
bar,
|
||||
workspace,
|
||||
window: None,
|
||||
target,
|
||||
serial,
|
||||
});
|
||||
@@ -227,6 +230,23 @@ impl FereseShell {
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn hover_window(
|
||||
&mut self,
|
||||
bar: window::Id,
|
||||
id: u64,
|
||||
target: Id,
|
||||
entered: bool,
|
||||
) -> Task<Message> {
|
||||
let Some(window) = self.snapshot.windows.iter().find(|window| window.id == id) else {
|
||||
return self.dismiss_workspace_tooltip();
|
||||
};
|
||||
let task = self.hover_workspace(bar, window.workspace, target, entered);
|
||||
if entered && let Some(hover) = &mut self.workspace_ui.hovered {
|
||||
hover.window = Some(id);
|
||||
}
|
||||
task
|
||||
}
|
||||
|
||||
pub(super) fn locate_workspace_tooltip(&self, serial: u64) -> Task<Message> {
|
||||
let Some(hover) = self
|
||||
.workspace_ui
|
||||
@@ -274,12 +294,16 @@ impl FereseShell {
|
||||
{
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
if !self
|
||||
.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == hover.workspace)
|
||||
{
|
||||
if hover.window.map_or_else(
|
||||
|| {
|
||||
!self
|
||||
.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == hover.workspace)
|
||||
},
|
||||
|id| !self.snapshot.windows.iter().any(|window| window.id == id),
|
||||
) {
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
let id = window::Id::unique();
|
||||
@@ -323,26 +347,36 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
fn view_workspace_tooltip(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(workspace) = self.workspace_ui.hovered.as_ref().and_then(|hover| {
|
||||
self.snapshot
|
||||
let Some(hover) = self.workspace_ui.hovered.as_ref() else {
|
||||
return self.render.text(ferese_config::tr!("")).into();
|
||||
};
|
||||
let label = if let Some(id) = hover.window {
|
||||
let Some(window) = self.snapshot.windows.iter().find(|window| window.id == id) else {
|
||||
return self.render.text(ferese_config::tr!("")).into();
|
||||
};
|
||||
crate::window_tray::caption(window).to_owned()
|
||||
} else {
|
||||
let Some(workspace) = self
|
||||
.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.id == hover.workspace)
|
||||
}) else {
|
||||
return self.render.text(ferese_config::tr!("")).into();
|
||||
else {
|
||||
return self.render.text(ferese_config::tr!("")).into();
|
||||
};
|
||||
let active = self
|
||||
.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.active_workspace == workspace.id);
|
||||
ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
|
||||
name: (&workspace.name).into(),
|
||||
index: workspace.index,
|
||||
window_count: workspace.window_count,
|
||||
active,
|
||||
visible_elsewhere: false,
|
||||
})
|
||||
};
|
||||
let active = self
|
||||
.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.active_workspace == workspace.id);
|
||||
let label = ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
|
||||
name: (&workspace.name).into(),
|
||||
index: workspace.index,
|
||||
window_count: workspace.window_count,
|
||||
active,
|
||||
visible_elsewhere: false,
|
||||
});
|
||||
cosmic::widget::autosize::autosize(
|
||||
container(
|
||||
self.render
|
||||
@@ -503,6 +537,13 @@ impl WorkspaceUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn app_icon(&self, app_id: &str) -> icon::Handle {
|
||||
self.icons
|
||||
.get(app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -550,6 +591,22 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_tooltips_are_cancelled_when_the_window_closes() {
|
||||
let mut app = crate::tests::shell_with_measured_panel();
|
||||
app.snapshot = snapshot(1);
|
||||
add_window(&mut app.snapshot, "test.app");
|
||||
let bar = app.outputs[0].bar;
|
||||
let target = Id::new("window-tray:test:0");
|
||||
drop(app.hover_window(bar, 0, target, true));
|
||||
assert_eq!(app.workspace_ui.hovered.as_ref().unwrap().window, Some(0));
|
||||
let serial = app.workspace_ui.serial;
|
||||
app.snapshot.windows.clear();
|
||||
drop(app.update_workspace_ui());
|
||||
assert!(app.workspace_ui.hovered.is_none());
|
||||
assert_eq!(app.locate_workspace_tooltip(serial).units(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_is_deferred_and_only_one_request_runs_at_a_time() {
|
||||
let mut ui = WorkspaceUi::default();
|
||||
|
||||
Reference in new issue
Block a user