Split shell, settings, and compositor into focused modules
This commit is contained in:
1 parent
2b9f30fa5d
commit
cbf048566a
38 files changed
+9998
-9817
No files matched your search
@@ -0,0 +1,247 @@
|
||||
use cosmic::widget::column;
|
||||
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, displays, fonts, row, set, slider,
|
||||
text_input, visuals, widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
pub(super) fn label<'a>(
|
||||
&self,
|
||||
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||
size: f32,
|
||||
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||
ferese_theme::text(text, self.font).size(size)
|
||||
}
|
||||
|
||||
pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
self.label(text.to_owned(), 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn field(&self, mut field: Field) -> Element<'static, Message> {
|
||||
if field.path == "theme.material.opacity" {
|
||||
let appearance = self.resolved.theme.appearance;
|
||||
let name = match appearance {
|
||||
ferese_config::theme::Appearance::Light => "light",
|
||||
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.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
self.label(field.description.clone(), 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
);
|
||||
}
|
||||
let labels = labels.width(Length::Fill);
|
||||
let path = field.path.clone();
|
||||
let control: Element<'static, Message> = match field.kind.clone() {
|
||||
Kind::Font => {
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, ""));
|
||||
let families = fonts::families();
|
||||
let selected = if value.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
families.iter().position(|family| family == &value)
|
||||
};
|
||||
let selection_path = path.clone();
|
||||
let commit = field.clone();
|
||||
column([])
|
||||
.spacing(6)
|
||||
.push(
|
||||
cosmic::iced::widget::pick_list(
|
||||
families,
|
||||
selected.map(|index| families[index].clone()),
|
||||
move |family: String| {
|
||||
let value = if family == "Default font" {
|
||||
String::new()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
Message::SelectFont(selection_path.clone(), value)
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
.width(225),
|
||||
)
|
||||
.push(
|
||||
text_input("Default font", value)
|
||||
.font(self.font)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(field.clone()))
|
||||
.on_unfocus(Message::Commit(commit))
|
||||
.width(225)
|
||||
.size(12),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
Kind::Range {
|
||||
default,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
integer,
|
||||
} => {
|
||||
let value = self
|
||||
.ranges
|
||||
.get(&path)
|
||||
.copied()
|
||||
.unwrap_or_else(|| self.draft.number(&path, default));
|
||||
let display = if integer || step >= 1. {
|
||||
format!("{value:.0}{suffix}")
|
||||
} else {
|
||||
format!("{value:.2}{suffix}")
|
||||
};
|
||||
let release = field.clone();
|
||||
row([])
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(14)
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
.push(self.label(display, 12.).width(65))
|
||||
.into()
|
||||
}
|
||||
Kind::Choice { default, choices } => {
|
||||
let value = self.draft.string(&path, default);
|
||||
let mut options = row([]).spacing(2);
|
||||
for (key, label) in choices {
|
||||
options = options.push(
|
||||
button::custom(self.label(*label, 12.))
|
||||
.padding([6, 9])
|
||||
.class(visuals::button_style(palette, value == *key))
|
||||
.on_press(Message::Change(set(&path, *key))),
|
||||
);
|
||||
}
|
||||
container(options)
|
||||
.padding(3)
|
||||
.class(visuals::surface(palette.sidebar, 10.))
|
||||
.into()
|
||||
}
|
||||
Kind::Text { default, argv } => {
|
||||
let value = self.inputs.get(&path).cloned().unwrap_or_else(|| {
|
||||
if argv {
|
||||
self.draft.argv(&path)
|
||||
} else {
|
||||
self.draft.string(&path, default)
|
||||
}
|
||||
});
|
||||
let commit = field.clone();
|
||||
let is_color = path.starts_with("theme.colors.");
|
||||
let swatch = visuals::color(&value, palette.accent);
|
||||
let input = text_input(default, value)
|
||||
.font(self.font)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(field.clone()))
|
||||
.on_unfocus(Message::Commit(commit))
|
||||
.width(if is_color { 155 } else { 225 })
|
||||
.size(12);
|
||||
if is_color {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
|
||||
.push(input)
|
||||
.into()
|
||||
} else {
|
||||
input.into()
|
||||
}
|
||||
}
|
||||
};
|
||||
container(
|
||||
row([])
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center)
|
||||
.push(labels)
|
||||
.push(control),
|
||||
)
|
||||
.padding([12, 12])
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> {
|
||||
let matcher = self.draft.string(&format!("{prefix}.match"), "");
|
||||
let configured = self.draft.string(&format!("{prefix}.mode"), "");
|
||||
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
|
||||
let Some(display) = self
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.connector == matcher || display.identity == matcher)
|
||||
else {
|
||||
return self.note("Connect this display to choose a supported refresh rate.");
|
||||
};
|
||||
let modes = displays::choices(display, &configured);
|
||||
let mut controls = row([]).spacing(8).align_y(Alignment::Center);
|
||||
for mode in &modes {
|
||||
let selected = !automatic
|
||||
&& configured
|
||||
.split('@')
|
||||
.nth(1)
|
||||
.and_then(|rate| rate.parse::<f64>().ok())
|
||||
.is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.);
|
||||
controls = controls.push(
|
||||
ferese_theme::controls::text_button(
|
||||
mode.label(),
|
||||
self.font,
|
||||
visuals::Palette::from_resolved(&self.resolved.presented),
|
||||
selected,
|
||||
)
|
||||
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)),
|
||||
);
|
||||
}
|
||||
let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh));
|
||||
if supports_auto && let Some(mode) = modes.last() {
|
||||
controls = controls.push(
|
||||
ferese_theme::controls::text_button(
|
||||
"Auto",
|
||||
self.font,
|
||||
visuals::Palette::from_resolved(&self.resolved.presented),
|
||||
automatic,
|
||||
)
|
||||
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)),
|
||||
);
|
||||
}
|
||||
let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into());
|
||||
let profile = display.profile.as_deref().unwrap_or("automatic configuration");
|
||||
column![
|
||||
self.label("Refresh rate", 14.),
|
||||
self.note(&format!("Current: {current} · active profile: {profile}")),
|
||||
controls,
|
||||
self.note(if supports_auto {
|
||||
"Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off."
|
||||
} else {
|
||||
"This resolution has no supported 60 Hz mode for automatic switching."
|
||||
}),
|
||||
].spacing(8).into()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,531 @@
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row, schema, scrollable,
|
||||
text_input, visuals, widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
pub(super) fn page_view(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut sidebar = column([])
|
||||
.spacing(3)
|
||||
.push(
|
||||
widget::mouse_area(
|
||||
row([])
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::brand_icon())
|
||||
.push(self.label("Ferese", 16.))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.on_press(Message::DragWindow)
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab),
|
||||
)
|
||||
.push(widget::Space::new().height(14))
|
||||
.push(
|
||||
text_input("Search settings", self.search.clone())
|
||||
.on_input(Message::Search)
|
||||
.font(self.font)
|
||||
.style(visuals::input_style(palette))
|
||||
.size(12),
|
||||
)
|
||||
.push(widget::Space::new().height(8));
|
||||
for page in Page::ALL {
|
||||
sidebar = sidebar.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::icon(
|
||||
page,
|
||||
if self.page == page {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.push(self.label(page.title(), 13.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 8])
|
||||
.class(visuals::navigation_style(palette, self.page == page))
|
||||
.on_press(Message::Page(page)),
|
||||
);
|
||||
}
|
||||
let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill)))
|
||||
.width(204)
|
||||
.height(Length::Fill)
|
||||
.padding([20, 10])
|
||||
.class(visuals::surface(palette.sidebar, 0.));
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
column([])
|
||||
.spacing(5)
|
||||
.push(self.label(
|
||||
if self.search.is_empty() {
|
||||
self.page.title()
|
||||
} else {
|
||||
"Search"
|
||||
},
|
||||
24.,
|
||||
))
|
||||
.push(
|
||||
self.label(
|
||||
if self.search.is_empty() {
|
||||
self.page.subtitle()
|
||||
} else {
|
||||
"Find a setting for your desktop."
|
||||
},
|
||||
12.,
|
||||
)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if self.page == Page::Appearance && self.search.is_empty() {
|
||||
let automatic = self.draft.string("theme.mode", "dark") == "auto";
|
||||
heading = heading.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Auto", 12.))
|
||||
.push(
|
||||
ferese_theme::controls::switch(automatic, palette)
|
||||
.name(if automatic {
|
||||
"Auto appearance: on"
|
||||
} else {
|
||||
"Auto appearance: off"
|
||||
})
|
||||
.on_press(Message::AutoAppearance(!automatic)),
|
||||
),
|
||||
);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let source = if scheduled { "Schedule" } else { "System" };
|
||||
let label = if automatic {
|
||||
format!(
|
||||
"{source} · {}",
|
||||
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"Light"
|
||||
} else {
|
||||
"Dark"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
source.to_owned()
|
||||
};
|
||||
let explanation = if scheduled {
|
||||
format!(
|
||||
"Auto uses Light from {} and Dark from {} ({})",
|
||||
self.draft.string("theme.schedule.light_at", "07:00"),
|
||||
self.draft.string("theme.schedule.dark_at", "19:00"),
|
||||
self.draft.string("theme.schedule.timezone", "system"),
|
||||
)
|
||||
} else {
|
||||
"Auto follows the GTK/GNOME appearance preference".to_owned()
|
||||
};
|
||||
heading = heading.push(widget::tooltip(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details)
|
||||
.name("Automatic appearance settings")
|
||||
.on_press(Message::AutoDetails(!self.auto_details)),
|
||||
self.label(explanation, 12.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
|
||||
let mut body = column([]).spacing(12);
|
||||
|
||||
if !self.search.is_empty() {
|
||||
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
|
||||
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
|
||||
}
|
||||
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) {
|
||||
body = body.push(
|
||||
button::custom(
|
||||
column([])
|
||||
.spacing(5)
|
||||
.push(self.label(page.title(), 16.))
|
||||
.push(self.label(page.subtitle(), 12.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(20)
|
||||
.class(visuals::button_style(palette, false))
|
||||
.on_press(Message::Page(page)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if matches!(self.page, Page::Bar | Page::Windows) {
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
body = body.push(
|
||||
widget::checkbox(split)
|
||||
.label("Use different themes for light and dark")
|
||||
.text_size(12)
|
||||
.font(self.font)
|
||||
.on_toggle(Message::SplitThemes),
|
||||
);
|
||||
if let Some(note) = &self.resolved.fallback_note {
|
||||
body = body.push(self.note(note));
|
||||
}
|
||||
}
|
||||
|
||||
if self.page == Page::Wallpaper {
|
||||
if let Some(handle) = &self.thumbnail {
|
||||
body = body.push(
|
||||
widget::image(handle.clone())
|
||||
.width(Length::Fill)
|
||||
.height(190)
|
||||
.content_fit(cosmic::iced::ContentFit::Contain),
|
||||
);
|
||||
} else {
|
||||
body = body.push(self.note(if self.thumbnail_loading {
|
||||
"Loading wallpaper preview…"
|
||||
} else {
|
||||
self.thumbnail_error
|
||||
.as_deref()
|
||||
.unwrap_or("Choose an image to preview your wallpaper.")
|
||||
}));
|
||||
}
|
||||
body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
|
||||
}
|
||||
let fields = schema::fields(self.page);
|
||||
|
||||
if !fields.is_empty() {
|
||||
let mut group = column([]).spacing(1);
|
||||
if self.page == Page::Appearance && self.auto_details {
|
||||
group = group.push(self.auto_controls());
|
||||
}
|
||||
for field in fields {
|
||||
if self.page == Page::Appearance
|
||||
&& (field.path == "theme.mode"
|
||||
|| field.path.starts_with("theme.schedule.")
|
||||
|| field.path.ends_with(".file"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
group = group.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Advanced customization", 13.))
|
||||
.push(visuals::action_icon(
|
||||
if self.advanced_theme {
|
||||
"M6 15l6-6 6 6"
|
||||
} else {
|
||||
"M6 9l6 6 6-6"
|
||||
},
|
||||
palette.muted,
|
||||
)),
|
||||
)
|
||||
.name(if self.advanced_theme {
|
||||
"Collapse advanced customization"
|
||||
} else {
|
||||
"Expand advanced customization"
|
||||
})
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::button_style(palette, false))
|
||||
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
|
||||
);
|
||||
if self.advanced_theme {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
|
||||
match self.page {
|
||||
Page::LockScreen => {
|
||||
body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."));
|
||||
body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock));
|
||||
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
|
||||
}
|
||||
Page::Desktop => {
|
||||
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty);
|
||||
body = body.push(self.label("Sticky notes", 16.));
|
||||
body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through."));
|
||||
for index in 0..self.draft.records("desktop_widgets.notes") {
|
||||
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
|
||||
let mut group = column([]).spacing(8);
|
||||
|
||||
for field in schema::note_fields(index) {
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
|
||||
if let Some(editor) = self.note_editors.get(&id) {
|
||||
let id = id.clone();
|
||||
group = group.push(
|
||||
widget::TextEditor::new(&editor.content)
|
||||
.height(160)
|
||||
.font(self.font)
|
||||
.size(14.)
|
||||
.on_action(move |action| Message::NoteAction(id.clone(), action)),
|
||||
);
|
||||
}
|
||||
group = group.push(button::destructive("Remove note").on_press_maybe(
|
||||
can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)),
|
||||
));
|
||||
body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
body = body.push(
|
||||
button::standard("Add note").on_press_maybe(
|
||||
(can_change_list && self.draft.records("desktop_widgets.notes") < 32)
|
||||
.then_some(Message::AddNote),
|
||||
),
|
||||
);
|
||||
}
|
||||
Page::Startup => {
|
||||
body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."));
|
||||
|
||||
for index in 0..self.draft.records("autostart") {
|
||||
let prefix = format!("autostart.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(Field::new(
|
||||
format!("{prefix}.command"),
|
||||
"Program",
|
||||
"A program and its arguments, not a shell script.",
|
||||
Kind::Text {
|
||||
default: "",
|
||||
argv: true,
|
||||
},
|
||||
)))
|
||||
.push(self.field(Field::new(
|
||||
format!("{prefix}.enabled"),
|
||||
"Open at login",
|
||||
"",
|
||||
Kind::Toggle(true),
|
||||
)))
|
||||
.push(self.field(Field::new(
|
||||
format!("{prefix}.restart"),
|
||||
"Restart if it exits",
|
||||
"Managed by the Ferese session.",
|
||||
Kind::Toggle(true),
|
||||
)))
|
||||
.push(
|
||||
container(button::destructive("Remove item").on_press_maybe(
|
||||
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
|
||||
))
|
||||
.padding(12),
|
||||
);
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
body = body.push(
|
||||
row([])
|
||||
.spacing(10)
|
||||
.push(
|
||||
text_input("Program and arguments", self.new_command.clone())
|
||||
.on_input(Message::NewCommand),
|
||||
)
|
||||
.push(button::standard("Add login item").on_press_maybe(
|
||||
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
|
||||
)),
|
||||
);
|
||||
}
|
||||
Page::Shortcuts => {
|
||||
body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none."));
|
||||
let mut gestures = row([]).spacing(6);
|
||||
for (keys, label) in [
|
||||
("Swipe3Up", "Customize swipe up"),
|
||||
("Swipe3Down", "Customize swipe down"),
|
||||
("Swipe3Left", "Customize swipe left"),
|
||||
("Swipe3Right", "Customize swipe right"),
|
||||
] {
|
||||
let exists = (0..self.draft.records("bindings")).any(|index| {
|
||||
self.draft
|
||||
.string(&format!("bindings.{index}.keys"), "")
|
||||
.eq_ignore_ascii_case(keys)
|
||||
});
|
||||
|
||||
if !exists {
|
||||
gestures = gestures.push(
|
||||
button::standard(label)
|
||||
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
body = body.push(gestures);
|
||||
|
||||
for index in 0..self.draft.records("bindings") {
|
||||
let prefix = format!("bindings.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.keys"),
|
||||
"Shortcut",
|
||||
"For example: Super+Return or Swipe3Up",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.action"),
|
||||
"Action",
|
||||
"Action such as toggle-overview, spawn, focus, move, or none.",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.argument"),
|
||||
"Argument",
|
||||
"Command name, direction, or action argument.",
|
||||
"",
|
||||
)));
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
if self.draft.records("bindings") == 0 {
|
||||
body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."));
|
||||
}
|
||||
}
|
||||
Page::Displays => {
|
||||
body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions."));
|
||||
for profile in 0..self.draft.records("output_profiles") {
|
||||
let prefix = format!("output_profiles.{profile}");
|
||||
body =
|
||||
body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.));
|
||||
|
||||
for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
|
||||
let prefix = format!("{prefix}.outputs.{output}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.refresh_controls(&prefix))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.match"),
|
||||
"Display",
|
||||
"Output name or matching pattern.",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.mode"),
|
||||
"Resolution",
|
||||
"For example: 2560x1440@60. Leave unchanged to retain automatic selection.",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::range(
|
||||
format!("{prefix}.scale"),
|
||||
"Scale",
|
||||
"Logical size of text and controls.",
|
||||
1.,
|
||||
0.75,
|
||||
3.,
|
||||
0.25,
|
||||
"×",
|
||||
false,
|
||||
)));
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
}
|
||||
if self.draft.records("output_profiles") == 0 {
|
||||
body =
|
||||
body.push(self.note(
|
||||
"No saved profiles. Ferese currently configures connected displays automatically.",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut content = column([]).spacing(16).push(
|
||||
widget::mouse_area(heading.width(Length::Fill))
|
||||
.on_press(Message::DragWindow)
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab),
|
||||
);
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
content = content.push(
|
||||
container(self.label(error.clone(), 12.))
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.error, 10.)),
|
||||
);
|
||||
}
|
||||
|
||||
content = content.push(
|
||||
scrollable(
|
||||
container(body)
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 0.,
|
||||
right: 12.,
|
||||
bottom: 0.,
|
||||
left: 2.,
|
||||
})
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
|
||||
cosmic::iced::widget::scrollable::Scrollbar::new()
|
||||
.width(10)
|
||||
.scroller_width(3),
|
||||
))
|
||||
.height(Length::Fill),
|
||||
);
|
||||
let footer = row([])
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center)
|
||||
.push(widget::tooltip(
|
||||
visuals::action_icon(
|
||||
if self.saving {
|
||||
"M12 3v9l5 3 M21 12a9 9 0 1 1-9-9"
|
||||
} else {
|
||||
"M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0"
|
||||
},
|
||||
palette.muted,
|
||||
),
|
||||
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
|
||||
widget::tooltip::Position::Top,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon(
|
||||
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
|
||||
if self.undo.is_some() && !self.saving {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.padding(4)
|
||||
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
|
||||
self.label("Undo last change", 11.),
|
||||
widget::tooltip::Position::Top,
|
||||
))
|
||||
.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon(
|
||||
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
|
||||
palette.text,
|
||||
))
|
||||
.padding(4)
|
||||
.on_press_maybe((!self.saving).then_some(Message::Reload)),
|
||||
self.label("Reload configuration", 11.),
|
||||
widget::tooltip::Position::Top,
|
||||
));
|
||||
row([])
|
||||
.push(sidebar)
|
||||
.push(
|
||||
container(
|
||||
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 20.,
|
||||
right: 20.,
|
||||
bottom: 8.,
|
||||
left: 20.,
|
||||
})
|
||||
.max_width(840)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(visuals::surface(palette.background, 0.)),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use super::{
|
||||
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row,
|
||||
schema, set, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
pub(super) fn theme_file_picker(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
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()
|
||||
} else {
|
||||
PathBuf::from(&file)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let labels = column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Theme overrides", 13.))
|
||||
.push(widget::tooltip(
|
||||
self.label(filename, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
self.label(
|
||||
if file.is_empty() {
|
||||
"Choose a partial KDL theme file".to_owned()
|
||||
} else {
|
||||
file.clone()
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
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.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([6, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
let mut controls = row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
container(scopes)
|
||||
.padding(3)
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(
|
||||
ferese_theme::controls::text_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
self.font,
|
||||
palette,
|
||||
false,
|
||||
)
|
||||
.on_press(Message::PickThemeFile(path)),
|
||||
);
|
||||
if !file.is_empty() {
|
||||
controls = controls.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
|
||||
.padding(5)
|
||||
.on_press(Message::Change(Edit::Unset(path.into()))),
|
||||
self.label("Remove override", 11.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
container(
|
||||
row([])
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center)
|
||||
.push(labels)
|
||||
.push(controls),
|
||||
)
|
||||
.padding([12, 12])
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn auto_controls(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let mut sources = row([]).spacing(4);
|
||||
for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
|
||||
sources = sources.push(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule"))
|
||||
.name(format!("Auto source: {label}"))
|
||||
.on_press(Message::Change(set("theme.schedule.source", source))),
|
||||
);
|
||||
}
|
||||
let description = if scheduled {
|
||||
"Switch between light and dark at set times."
|
||||
} else {
|
||||
"Follow the GTK/GNOME appearance preference."
|
||||
};
|
||||
let mut options = column([]).spacing(8).push(
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Automatic appearance", 13.))
|
||||
.push(self.note(description)),
|
||||
)
|
||||
.push(sources),
|
||||
);
|
||||
if scheduled {
|
||||
let mut times = row([]).spacing(16).align_y(Alignment::Center);
|
||||
for field in schema::fields(Page::Appearance)
|
||||
.into_iter()
|
||||
.filter(|field| field.path.starts_with("theme.schedule."))
|
||||
{
|
||||
let path = field.path.clone();
|
||||
let Kind::Text { default, .. } = &field.kind else {
|
||||
continue;
|
||||
};
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, default));
|
||||
let submit = field.clone();
|
||||
let input = text_input(*default, value)
|
||||
.font(self.font)
|
||||
.size(12)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(field.label, 12.))
|
||||
.push(input),
|
||||
);
|
||||
}
|
||||
options = options.push(times);
|
||||
}
|
||||
container(options)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn theme_gallery(
|
||||
&self,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> Element<'static, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let selected_id = visuals::family_selection(&self.draft, appearance);
|
||||
let choices = self.resolved.families.clone();
|
||||
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
|
||||
self.label(
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
15.,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if appearance != Some(Dark) {
|
||||
if self.undo.is_some() {
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Undo", self.font, palette, false)
|
||||
.on_press_maybe((!self.saving).then_some(Message::Undo)),
|
||||
);
|
||||
}
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Import…", self.font, palette, false)
|
||||
.on_press(Message::ImportTheme),
|
||||
);
|
||||
}
|
||||
let mut gallery = column([]).spacing(10).push(heading);
|
||||
for (chunk, families) in choices.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(12);
|
||||
for (offset, family) in families.iter().enumerate() {
|
||||
let selected = family.id == selected_id;
|
||||
let index = chunk * 3 + offset;
|
||||
let id = family.id.clone();
|
||||
let active = selected
|
||||
.then_some(self.resolved.theme.appearance)
|
||||
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
||||
let ids = ids.clone();
|
||||
let tile_id = gallery_id(&id, appearance);
|
||||
tiles = tiles.push(gallery::tile(
|
||||
family,
|
||||
appearance,
|
||||
active,
|
||||
selected,
|
||||
palette,
|
||||
self.font,
|
||||
tile_id,
|
||||
Message::Family(id, appearance),
|
||||
move |key| {
|
||||
gallery::neighbor(index, ids.len(), key)
|
||||
.map(|index| Message::Family(ids[index].clone(), appearance))
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in families.len()..3 {
|
||||
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
||||
}
|
||||
gallery = gallery.push(tiles);
|
||||
}
|
||||
gallery::group(
|
||||
container(gallery).width(Length::Fill).max_width(640),
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn gallery_id(
|
||||
id: &str,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}"))
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use super::{
|
||||
Background, BarMetrics, Border, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment,
|
||||
bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window,
|
||||
};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
|
||||
let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
|
||||
self.snapshot.outputs.iter().find(|output| output.name == name)
|
||||
}
|
||||
|
||||
pub(super) fn bar_hidden(&self, id: window::Id) -> bool {
|
||||
output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id))
|
||||
}
|
||||
|
||||
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||
if self.bar_hidden(id) {
|
||||
return container(text("")).width(Length::Fill).height(Length::Fill).into();
|
||||
}
|
||||
|
||||
let focused_output = self.output_for_bar(id);
|
||||
let shell_theme = self.config.theme.for_bar();
|
||||
let bar = BarMetrics::from(shell_theme);
|
||||
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
||||
.spacing(3)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
|
||||
workspace_row = workspace_row.push(motion::button(
|
||||
button::custom(overview_control(bar, color(shell_theme.accent)))
|
||||
.height(bar.control_height)
|
||||
.padding([0, 7])
|
||||
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
||||
color(shell_theme.text_primary),
|
||||
self.overview_active,
|
||||
1.0,
|
||||
));
|
||||
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
||||
.spacing(1)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
|
||||
for workspace in &self.snapshot.workspaces {
|
||||
let active = workspace_active_on_bar(workspace.id, focused_output);
|
||||
let owner = workspace
|
||||
.output
|
||||
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
|
||||
let occupied = self
|
||||
.snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.any(|window| window.workspace == workspace.id);
|
||||
let indicator = workspace_indicator(
|
||||
&workspace.name,
|
||||
active,
|
||||
workspace.active && !active,
|
||||
occupied,
|
||||
bar,
|
||||
shell_theme,
|
||||
);
|
||||
let workspace_button = button::custom(indicator)
|
||||
.name(format!(
|
||||
"Workspace {}{}{}",
|
||||
workspace.name,
|
||||
if active { ", active" } else { "" },
|
||||
owner.map_or(String::new(), |output| format!(", on {}", output.name))
|
||||
))
|
||||
.height(bar.control_height)
|
||||
.padding(0)
|
||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
|
||||
|
||||
let selector = motion::button(
|
||||
workspace_button,
|
||||
color(if active {
|
||||
shell_theme.accent
|
||||
} else {
|
||||
shell_theme.text_primary
|
||||
}),
|
||||
false,
|
||||
1.0,
|
||||
);
|
||||
// Iced tooltips are overlays inside this bar-height layer surface;
|
||||
// viewport clamping puts them back over the selector. Keep the
|
||||
// accessible button name, without an overlay stealing its target.
|
||||
workspace_buttons = workspace_buttons.push(selector);
|
||||
}
|
||||
workspace_row = workspace_row.push(
|
||||
container(workspace_buttons)
|
||||
.padding([0, 3])
|
||||
.class(theme::Container::custom(move |_| bar_group_style(shell_theme))),
|
||||
);
|
||||
|
||||
let foreground = color(shell_theme.text_primary);
|
||||
let left = cosmic::iced::widget::scrollable(workspace_row)
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||
cosmic::iced::widget::scrollable::Scrollbar::default()
|
||||
.width(0)
|
||||
.scroller_width(0),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(bar.control_height);
|
||||
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
|
||||
let clock = container(motion::button(
|
||||
button::custom(
|
||||
row![
|
||||
text(date).size(12).class(theme::Text::Color(foreground)),
|
||||
text(time).size(bar.text_size).class(theme::Text::Color(foreground)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
)
|
||||
.padding([4, 8])
|
||||
.height(bar.control_height)
|
||||
.name("Open calendar")
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
cosmic::Action::App(Message::OpenMenuOn(
|
||||
id,
|
||||
status_ui::Menu::Calendar,
|
||||
cosmic::iced::Rectangle {
|
||||
x: (bounds.x - offset.x).round() as i32,
|
||||
y: (bounds.y - offset.y).round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
))
|
||||
}),
|
||||
foreground,
|
||||
self.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
|
||||
1.0,
|
||||
))
|
||||
.height(bar.control_height)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)));
|
||||
let right = row![
|
||||
self.view_status_bar().map(move |action| match action {
|
||||
cosmic::Action::App(Message::OpenMenu(kind, anchor)) =>
|
||||
cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)),
|
||||
other => other,
|
||||
}),
|
||||
clock
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
let available = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == id)
|
||||
.and_then(|output| output.size)
|
||||
.map_or(0., |(width, _)| width as f32)
|
||||
- 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32);
|
||||
let title_width = (available - 920.).clamp(0., 360.);
|
||||
let title = if self.config.status.window_title && title_width > 0. {
|
||||
focused_bar_title(&self.snapshot, focused_output)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let center = text(title)
|
||||
.size(bar.text_size)
|
||||
.width(title_width)
|
||||
.height(bar.control_height)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
|
||||
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
||||
))
|
||||
.class(theme::Text::Color(foreground));
|
||||
let right = cosmic::iced::widget::scrollable(container(right).width(Length::Shrink))
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||
cosmic::iced::widget::scrollable::Scrollbar::default()
|
||||
.width(0)
|
||||
.scroller_width(0),
|
||||
))
|
||||
.anchor_right()
|
||||
.width(Length::Shrink)
|
||||
.height(bar.control_height);
|
||||
let content = row![
|
||||
container(left).width(Length::Fill),
|
||||
center,
|
||||
container(right)
|
||||
.width(Length::Fill)
|
||||
.align_x(alignment::Horizontal::Right),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.height(Length::Fill);
|
||||
|
||||
let compositor_material = self
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|entry| entry.bar == id && entry.effects.is_some());
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding([0, shell_theme.panel_padding.round() as u16])
|
||||
.class(theme::Container::custom(move |_| {
|
||||
bar_style(shell_theme, compositor_material)
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action<Message>> {
|
||||
container(bar_icon(
|
||||
ferese_theme::icons::FERESE,
|
||||
bar.overview_icon_size,
|
||||
foreground,
|
||||
))
|
||||
.width(bar.overview_icon_size)
|
||||
.height(bar.control_height)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
|
||||
let output = snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.focused)
|
||||
.or_else(|| snapshot.outputs.first())
|
||||
.map(|output| output.id);
|
||||
output_bar_hidden(snapshot, output)
|
||||
}
|
||||
|
||||
pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
|
||||
let active_workspace = snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|candidate| Some(candidate.id) == output)
|
||||
.map(|output| output.active_workspace);
|
||||
|
||||
active_workspace.is_some_and(|workspace| {
|
||||
snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.any(|window| window.workspace == workspace && window.fullscreen)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workspace_indicator(
|
||||
name: &str,
|
||||
active: bool,
|
||||
active_elsewhere: bool,
|
||||
occupied: bool,
|
||||
bar: BarMetrics,
|
||||
shell_theme: ShellTheme,
|
||||
) -> Element<'static, cosmic::Action<Message>> {
|
||||
let foreground = if active {
|
||||
shell_theme.accent
|
||||
} else if occupied {
|
||||
shell_theme.text_primary
|
||||
} else {
|
||||
shell_theme.text_muted
|
||||
};
|
||||
container(
|
||||
text(name.to_owned())
|
||||
.size(12)
|
||||
.class(theme::Text::Color(color(foreground))),
|
||||
)
|
||||
.width(24)
|
||||
.height(bar.control_height)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
workspace_selector_style(active, active_elsewhere, occupied, shell_theme)
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str {
|
||||
let Some(output) = output else {
|
||||
return "";
|
||||
};
|
||||
snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.focused && window.workspace == output.active_workspace)
|
||||
.map_or("", |window| {
|
||||
if window.title.trim().is_empty() {
|
||||
&window.app_id
|
||||
} else {
|
||||
&window.title
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool {
|
||||
output.is_some_and(|output| output.active_workspace == workspace)
|
||||
}
|
||||
|
||||
pub(super) fn workspace_selector_style(
|
||||
active: bool,
|
||||
active_elsewhere: bool,
|
||||
occupied: bool,
|
||||
shell_theme: ShellTheme,
|
||||
) -> container::Style {
|
||||
container::Style {
|
||||
background: if active {
|
||||
Some(Background::Color(color_with_opacity(shell_theme.accent, 0.16)))
|
||||
} else if occupied {
|
||||
Some(Background::Color(color_with_opacity(shell_theme.text_primary, 0.04)))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
border: Border {
|
||||
color: color_with_opacity(shell_theme.accent, 0.55),
|
||||
width: if active_elsewhere { 1.0 } else { 0.0 },
|
||||
radius: shell_theme.material_radius.min(14.0).into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style {
|
||||
let mut style = ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius);
|
||||
if compositor_material {
|
||||
style.background = None;
|
||||
}
|
||||
style.text_color = Some(color(theme.text_primary));
|
||||
style.icon_color = style.text_color;
|
||||
style.snap = true;
|
||||
style
|
||||
}
|
||||
|
||||
pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(color_with_opacity(theme.text_primary, 0.035))),
|
||||
border: Border {
|
||||
color: color_with_opacity(theme.text_muted, 0.12),
|
||||
width: 1.0,
|
||||
radius: theme.material_radius.min(16.0).into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use super::{
|
||||
Anchor, Background, Border, Color, Element, FereseShell, IcedMargin, IcedOutput, KeyboardInteractivity, Layer,
|
||||
Length, Limits, Message, NoteDrag, SctkLayerSurfaceSettings, Task, Zoned, alignment, button, color, config,
|
||||
configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor,
|
||||
set_margin, stacked_clock_digits, text, theme, window,
|
||||
};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn flush_note_changes(&mut self) -> Task<Message> {
|
||||
if self.note_saving || self.note_pending.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let Some(path) = config::config_path() else {
|
||||
self.note_error = Some("Configuration path unavailable.".into());
|
||||
return Task::none();
|
||||
};
|
||||
self.note_saving = true;
|
||||
self.note_inflight = std::mem::take(&mut self.note_pending);
|
||||
let edits = self.note_inflight.clone();
|
||||
cosmic::task::future(async move {
|
||||
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(note_store::save(&path, &edits));
|
||||
});
|
||||
Message::NotesStored(
|
||||
receive
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Note save worker stopped.".into())),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_note_text(&mut self, id: &str, value: String) -> Task<Message> {
|
||||
if let Some(note) = self.config.desktop_widgets.notes.iter_mut().find(|note| note.id == id)
|
||||
&& (note.text != value || self.note_error.is_some())
|
||||
{
|
||||
note.text = value.clone();
|
||||
self.note_pending.push(note_store::Edit::Text(id.to_owned(), value));
|
||||
}
|
||||
self.flush_note_changes()
|
||||
}
|
||||
|
||||
pub(super) fn finish_note_edit(&mut self) -> Task<Message> {
|
||||
if let Some(editor) = self.note_editor.take() {
|
||||
self.set_note_text(&editor.id, editor.content.text())
|
||||
} else {
|
||||
self.flush_note_changes()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task<Message> {
|
||||
self.begin_widget_drag(Some(id), surface)
|
||||
}
|
||||
|
||||
pub(super) fn begin_widget_drag(&mut self, id: Option<String>, surface: window::Id) -> Task<Message> {
|
||||
if self.note_drag.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(entry) = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface))
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(size) = entry.size else {
|
||||
return Task::none();
|
||||
};
|
||||
let (origin, dimensions) = if let Some(id) = &id {
|
||||
let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| ¬e.id == id) else {
|
||||
return Task::none();
|
||||
};
|
||||
(note_origin(note, size), (note.width, note.height))
|
||||
} else {
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
(
|
||||
widget_origin(
|
||||
clock.anchor,
|
||||
clock.margin_x,
|
||||
clock.margin_y,
|
||||
(clock.width, clock.height),
|
||||
size,
|
||||
),
|
||||
(clock.width, clock.height),
|
||||
)
|
||||
};
|
||||
let output = entry.output.clone();
|
||||
let mut obstacles = Vec::new();
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
|
||||
if id.is_some() && clock.on_output(entry.name.as_deref()) {
|
||||
let position = widget_origin(
|
||||
clock.anchor,
|
||||
clock.margin_x,
|
||||
clock.margin_y,
|
||||
(clock.width, clock.height),
|
||||
size,
|
||||
);
|
||||
obstacles.push(cosmic::iced::Rectangle::new(
|
||||
position,
|
||||
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
|
||||
));
|
||||
}
|
||||
|
||||
for note in &self.config.desktop_widgets.notes {
|
||||
if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
|
||||
obstacles.push(cosmic::iced::Rectangle::new(
|
||||
note_origin(note, size),
|
||||
cosmic::iced::Size::new(note.width as f32, note.height as f32),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let overlay = window::Id::unique();
|
||||
self.note_drag = Some(NoteDrag {
|
||||
id,
|
||||
source: surface,
|
||||
overlay,
|
||||
origin,
|
||||
position: origin,
|
||||
start: self.note_pointer.get(&surface).copied().unwrap_or_default(),
|
||||
output_size: size,
|
||||
obstacles,
|
||||
});
|
||||
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| SctkLayerSurfaceSettings {
|
||||
id: overlay,
|
||||
layer: Layer::Overlay,
|
||||
keyboard_interactivity: KeyboardInteractivity::None,
|
||||
input_zone: Some(Vec::new()),
|
||||
anchor: Anchor::TOP | Anchor::LEFT,
|
||||
margin: IcedMargin {
|
||||
top: origin.y.round() as i32,
|
||||
left: origin.x.round() as i32,
|
||||
..Default::default()
|
||||
},
|
||||
output: IcedOutput::Output(output.clone()),
|
||||
namespace: "ferese-shell-note-drag".into(),
|
||||
exclusive_zone: -1,
|
||||
size: Some((Some(dimensions.0), Some(dimensions.1))),
|
||||
size_limits: Limits::NONE,
|
||||
},
|
||||
Some(Box::new(Self::view_note_drag)),
|
||||
);
|
||||
|
||||
Task::batch([
|
||||
self.finish_note_edit(),
|
||||
cosmic::task::message(cosmic::Action::Surface(action)),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn view_note_drag(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(drag) = &self.note_drag else {
|
||||
return text("").into();
|
||||
};
|
||||
|
||||
if let Some(id) = &drag.id {
|
||||
self.view_note_content(id, drag.source)
|
||||
} else {
|
||||
self.view_clock_content()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_note_drag(&mut self, save: bool) -> Task<Message> {
|
||||
let Some(drag) = self.note_drag.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
let mut tasks = vec![destroy_layer_surface(drag.overlay)];
|
||||
|
||||
if save {
|
||||
if drag.id.is_none() {
|
||||
let clock = &mut self.config.desktop_widgets.clock;
|
||||
clock.anchor = ferese_core::desktop::Anchor::TopLeft;
|
||||
clock.margin_x = drag.position.x.round() as i32;
|
||||
clock.margin_y = drag.position.y.round() as i32;
|
||||
for entry in &self.outputs {
|
||||
if let Some(id) = entry.clock {
|
||||
tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT));
|
||||
tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x));
|
||||
}
|
||||
}
|
||||
self.note_pending
|
||||
.push(note_store::Edit::ClockPosition(clock.margin_x, clock.margin_y));
|
||||
}
|
||||
|
||||
if let Some(note) = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.notes
|
||||
.iter_mut()
|
||||
.find(|note| Some(¬e.id) == drag.id.as_ref())
|
||||
{
|
||||
note.anchor = ferese_core::desktop::Anchor::TopLeft;
|
||||
note.margin_x = drag.position.x.round() as i32;
|
||||
note.margin_y = drag.position.y.round() as i32;
|
||||
|
||||
for entry in &self.outputs {
|
||||
if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) {
|
||||
tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT));
|
||||
tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x));
|
||||
}
|
||||
}
|
||||
|
||||
self.note_pending.push(note_store::Edit::Position(
|
||||
note.id.clone(),
|
||||
note.margin_x,
|
||||
note.margin_y,
|
||||
));
|
||||
}
|
||||
|
||||
tasks.push(self.flush_note_changes());
|
||||
}
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn rebuild_notes(&mut self, reset: bool) -> Task<Message> {
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for entry in &mut self.outputs {
|
||||
if reset {
|
||||
for (_, id) in entry.notes.drain(..) {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
}
|
||||
|
||||
for note in &self.config.desktop_widgets.notes {
|
||||
if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = window::Id::unique();
|
||||
entry.notes.push((note.id.clone(), id));
|
||||
let note = note.clone();
|
||||
let note_id = note.id.clone();
|
||||
let output = entry.output.clone();
|
||||
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| {
|
||||
let (anchor, margin) = widget_placement(note.anchor, note.margin_x, note.margin_y);
|
||||
SctkLayerSurfaceSettings {
|
||||
id,
|
||||
layer: Layer::Bottom,
|
||||
keyboard_interactivity: if note.interactive {
|
||||
KeyboardInteractivity::OnDemand
|
||||
} else {
|
||||
KeyboardInteractivity::None
|
||||
},
|
||||
input_zone: if note.interactive { None } else { Some(Vec::new()) },
|
||||
anchor,
|
||||
margin,
|
||||
output: IcedOutput::Output(output.clone()),
|
||||
namespace: "ferese-shell-sticky-note".into(),
|
||||
exclusive_zone: -1,
|
||||
size: Some((Some(note.width), Some(note.height))),
|
||||
size_limits: Limits::NONE,
|
||||
}
|
||||
},
|
||||
Some(Box::new(move |app| app.view_note(¬e_id, id))),
|
||||
);
|
||||
|
||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
||||
}
|
||||
}
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||
if self
|
||||
.note_drag
|
||||
.as_ref()
|
||||
.is_some_and(|drag| drag.id.as_deref() == Some(id))
|
||||
{
|
||||
return container(text("")).width(Length::Fill).height(Length::Fill).into();
|
||||
}
|
||||
|
||||
self.view_note_content(id, surface)
|
||||
}
|
||||
|
||||
pub(super) fn view_note_content(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) else {
|
||||
return text("").into();
|
||||
};
|
||||
let alignment = match note.alignment {
|
||||
ferese_core::desktop::Alignment::Left => alignment::Horizontal::Left,
|
||||
ferese_core::desktop::Alignment::Center => alignment::Horizontal::Center,
|
||||
ferese_core::desktop::Alignment::Right => alignment::Horizontal::Right,
|
||||
};
|
||||
let font = configured_font(
|
||||
note.font_family
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or(self.config.font_family.as_deref()),
|
||||
);
|
||||
let mut foreground = color(
|
||||
note.color
|
||||
.as_deref()
|
||||
.and_then(config::parse_color)
|
||||
.unwrap_or(self.config.theme.text_primary),
|
||||
);
|
||||
|
||||
foreground.a *= note.opacity;
|
||||
|
||||
let background = if note.background.as_deref() == Some("") {
|
||||
None
|
||||
} else {
|
||||
let mut tint = color(
|
||||
note.background
|
||||
.as_deref()
|
||||
.and_then(config::parse_color)
|
||||
.unwrap_or(self.config.theme.surface_base),
|
||||
);
|
||||
tint.a *= note.opacity;
|
||||
Some(Background::Color(tint))
|
||||
};
|
||||
let mut body = cosmic::widget::column([]).spacing(note.gap).align_x(alignment);
|
||||
|
||||
if note.interactive {
|
||||
let title = ferese_theme::text(if note.title.is_empty() { "⋮⋮" } else { ¬e.title }, font)
|
||||
.size(note.title_size)
|
||||
.class(theme::Text::Color(foreground));
|
||||
let header = cosmic::widget::mouse_area(container(title).width(Length::Fill))
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
||||
.on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface)));
|
||||
let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id);
|
||||
|
||||
body = body.push(
|
||||
row([]).push(header).push(motion::button(
|
||||
button::custom(text(if editing { "Done" } else { "Edit" }))
|
||||
.padding([4, 8])
|
||||
.on_press(cosmic::Action::App(if editing {
|
||||
Message::FinishNoteEdit
|
||||
} else {
|
||||
Message::BeginNoteEdit(id.to_owned())
|
||||
})),
|
||||
foreground,
|
||||
false,
|
||||
1.0,
|
||||
)),
|
||||
);
|
||||
} else if !note.title.is_empty() {
|
||||
body = body.push(
|
||||
ferese_theme::text(
|
||||
note.title.clone(),
|
||||
cosmic::font::Font {
|
||||
weight: cosmic::iced::font::Weight::Semibold,
|
||||
..font
|
||||
},
|
||||
)
|
||||
.size(note.title_size)
|
||||
.width(Length::Fill)
|
||||
.align_x(alignment)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(editor) = &self.note_editor
|
||||
&& editor.id == id
|
||||
{
|
||||
let id = id.to_owned();
|
||||
body = body.push(
|
||||
cosmic::widget::TextEditor::new(&editor.content)
|
||||
.style(|theme, status| {
|
||||
use cosmic::iced::widget::text_editor::Catalog;
|
||||
let mut style = theme.style(&<cosmic::Theme as Catalog>::default(), status);
|
||||
style.border.radius = motion::radius(f32::MAX).into();
|
||||
style
|
||||
})
|
||||
.height(Length::Fill)
|
||||
.font(font)
|
||||
.size(note.text_size)
|
||||
.on_action(move |action| cosmic::Action::App(Message::NoteAction(id.clone(), action))),
|
||||
);
|
||||
} else {
|
||||
body = body.push(
|
||||
ferese_theme::text(note.text.clone(), font)
|
||||
.size(note.text_size)
|
||||
.width(Length::Fill)
|
||||
.align_x(alignment)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(error) = &self.note_error {
|
||||
body = body.push(text(error.clone()).size(10).class(theme::Text::Color(foreground)));
|
||||
}
|
||||
|
||||
let radius = self.config.theme.material_radius;
|
||||
|
||||
container(body)
|
||||
.padding(note.padding)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background,
|
||||
border: Border {
|
||||
radius: radius.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn rebuild_clocks(&mut self, reset: bool) -> Task<Message> {
|
||||
self.desktop_clock = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.clock
|
||||
.labels(&Zoned::now())
|
||||
.unwrap_or_default();
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for entry in &mut self.outputs {
|
||||
if !reset && entry.clock.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = entry.clock.take() {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
|
||||
let clock = self.config.desktop_widgets.clock.clone();
|
||||
|
||||
if !clock.on_output(entry.name.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = window::Id::unique();
|
||||
entry.clock = Some(id);
|
||||
let output = entry.output.clone();
|
||||
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| {
|
||||
let (anchor, margin) = clock_placement(&clock);
|
||||
SctkLayerSurfaceSettings {
|
||||
id,
|
||||
layer: Layer::Bottom,
|
||||
keyboard_interactivity: KeyboardInteractivity::OnDemand,
|
||||
input_zone: None,
|
||||
anchor,
|
||||
margin,
|
||||
output: IcedOutput::Output(output.clone()),
|
||||
namespace: "ferese-shell-desktop-clock".into(),
|
||||
exclusive_zone: -1,
|
||||
size: Some((Some(clock.width), Some(clock.height))),
|
||||
size_limits: Limits::NONE,
|
||||
}
|
||||
},
|
||||
Some(Box::new(move |app| app.view_desktop_clock(id))),
|
||||
);
|
||||
|
||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
||||
}
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||
if self
|
||||
.note_drag
|
||||
.as_ref()
|
||||
.is_some_and(|drag| drag.id.is_none() && drag.source == id)
|
||||
{
|
||||
return container(text("")).width(Length::Fill).height(Length::Fill).into();
|
||||
}
|
||||
|
||||
cosmic::widget::mouse_area(self.view_clock_content())
|
||||
.on_press(cosmic::Action::App(Message::BeginClockDrag(id)))
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn view_clock_content(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
use ferese_core::desktop::Alignment as ClockAlignment;
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
let alignment = match clock.alignment {
|
||||
ClockAlignment::Left => alignment::Horizontal::Left,
|
||||
ClockAlignment::Center => alignment::Horizontal::Center,
|
||||
ClockAlignment::Right => alignment::Horizontal::Right,
|
||||
};
|
||||
let pixel = clock.style == ferese_core::desktop::ClockStyle::Pixel;
|
||||
let custom_font = clock.font_family.as_deref().filter(|family| !family.trim().is_empty());
|
||||
let mut font = configured_font(if pixel {
|
||||
custom_font.or(Some("Cantarell"))
|
||||
} else {
|
||||
custom_font.or(self.config.font_family.as_deref())
|
||||
});
|
||||
if pixel && custom_font.is_none() {
|
||||
font.weight = cosmic::iced::font::Weight::Black;
|
||||
} else if clock.bold {
|
||||
font.weight = cosmic::iced::font::Weight::Bold;
|
||||
}
|
||||
let date_font = configured_font(custom_font.or(self.config.font_family.as_deref()));
|
||||
let tint = |custom: &Option<String>, fallback| {
|
||||
let mut tint = color(custom.as_deref().and_then(config::parse_color).unwrap_or(fallback));
|
||||
tint.a *= clock.opacity;
|
||||
tint
|
||||
};
|
||||
let foreground = tint(&clock.color, self.config.theme.text_primary);
|
||||
let accent = color(self.config.theme.accent);
|
||||
let custom_color = clock.color.as_deref().and_then(config::parse_color).is_some();
|
||||
let digit_colors = [0.8, 0.45].map(|text_mix| {
|
||||
if custom_color {
|
||||
return foreground;
|
||||
}
|
||||
Color {
|
||||
r: foreground.r * text_mix + accent.r * (1.0 - text_mix),
|
||||
g: foreground.g * text_mix + accent.g * (1.0 - text_mix),
|
||||
b: foreground.b * text_mix + accent.b * (1.0 - text_mix),
|
||||
a: foreground.a,
|
||||
}
|
||||
});
|
||||
let mut labels = cosmic::widget::column([]).spacing(clock.gap).align_x(alignment);
|
||||
|
||||
if pixel && clock.show_date {
|
||||
labels = labels.push(
|
||||
ferese_theme::text(self.desktop_clock.1.clone(), date_font)
|
||||
.size(clock.date_size)
|
||||
.class(theme::Text::Color(tint(
|
||||
&clock.date_color,
|
||||
self.config.theme.text_primary,
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(digits) = pixel.then(|| stacked_clock_digits(&self.desktop_clock.0)).flatten() {
|
||||
let available_height = (clock.height as f32
|
||||
- clock.padding * 2.0
|
||||
- if clock.show_date {
|
||||
clock.date_size * 1.3 + clock.gap
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
.max(16.0);
|
||||
let size = clock
|
||||
.time_size
|
||||
.min(available_height / 1.6)
|
||||
.min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5);
|
||||
let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment);
|
||||
|
||||
for (line, digits) in digits.into_iter().enumerate() {
|
||||
let mut digit_row = row([]).spacing(0);
|
||||
|
||||
for (position, digit) in digits.chars().enumerate() {
|
||||
digit_row = digit_row.push(
|
||||
ferese_theme::text(digit.to_string(), font)
|
||||
.size(size)
|
||||
.line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8))
|
||||
.class(theme::Text::Color(digit_colors[(line + position) % 2])),
|
||||
);
|
||||
}
|
||||
|
||||
stack = stack.push(digit_row);
|
||||
}
|
||||
|
||||
labels = labels.push(stack);
|
||||
} else {
|
||||
let characters = self.desktop_clock.0.chars().count().max(1) as f32;
|
||||
let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0);
|
||||
let available_height = (clock.height as f32
|
||||
- clock.padding * 2.0
|
||||
- if clock.show_date {
|
||||
clock.date_size * 1.3 + clock.gap
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
.max(1.0);
|
||||
let size = clock
|
||||
.time_size
|
||||
.min(available_width / (characters * 0.75))
|
||||
.min(available_height / 1.3);
|
||||
|
||||
labels = labels.push(
|
||||
ferese_theme::text(self.desktop_clock.0.clone(), font)
|
||||
.size(size)
|
||||
.width(Length::Fill)
|
||||
.align_x(alignment)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if !pixel && clock.show_date {
|
||||
labels = labels.push(
|
||||
ferese_theme::text(self.desktop_clock.1.clone(), font)
|
||||
.size(clock.date_size)
|
||||
.width(Length::Fill)
|
||||
.align_x(alignment)
|
||||
.class(theme::Text::Color(tint(
|
||||
&clock.date_color,
|
||||
self.config.theme.text_muted,
|
||||
))),
|
||||
);
|
||||
}
|
||||
let background = clock.background.as_deref().and_then(config::parse_color).map(|rgba| {
|
||||
let mut tint = color(rgba);
|
||||
tint.a *= clock.opacity;
|
||||
Background::Color(tint)
|
||||
});
|
||||
let radius = self.config.theme.material_radius;
|
||||
|
||||
container(labels)
|
||||
.padding(clock.padding)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(alignment)
|
||||
.center_y(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background,
|
||||
border: Border {
|
||||
radius: radius.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep each axis, allowing edge sliding without tunnelling through a widget
|
||||
/// when pointer events skip ahead. Coordinates match integer layer margins.
|
||||
pub(super) fn avoid_widget_overlap(
|
||||
previous: cosmic::iced::Point,
|
||||
requested: cosmic::iced::Point,
|
||||
size: (u32, u32),
|
||||
obstacles: &[cosmic::iced::Rectangle],
|
||||
) -> cosmic::iced::Point {
|
||||
let mut position = previous;
|
||||
let (width, height) = (size.0 as f32, size.1 as f32);
|
||||
let mut x = requested.x.round();
|
||||
for obstacle in obstacles {
|
||||
if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y {
|
||||
if x > position.x && position.x + width <= obstacle.x {
|
||||
x = x.min((obstacle.x - width).floor());
|
||||
} else if x < position.x && position.x >= obstacle.x + obstacle.width {
|
||||
x = x.max((obstacle.x + obstacle.width).ceil());
|
||||
}
|
||||
}
|
||||
}
|
||||
position.x = x;
|
||||
let mut y = requested.y.round();
|
||||
for obstacle in obstacles {
|
||||
if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x {
|
||||
if y > position.y && position.y + height <= obstacle.y {
|
||||
y = y.min((obstacle.y - height).floor());
|
||||
} else if y < position.y && position.y >= obstacle.y + obstacle.height {
|
||||
y = y.max((obstacle.y + obstacle.height).ceil());
|
||||
}
|
||||
}
|
||||
}
|
||||
position.y = y;
|
||||
position
|
||||
}
|
||||
|
||||
pub(super) fn clamp_note_position(
|
||||
position: cosmic::iced::Point,
|
||||
output: (i32, i32),
|
||||
size: (u32, u32),
|
||||
) -> cosmic::iced::Point {
|
||||
cosmic::iced::Point::new(
|
||||
position.x.clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)),
|
||||
position.y.clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn note_origin(note: &ferese_core::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point {
|
||||
widget_origin(
|
||||
note.anchor,
|
||||
note.margin_x,
|
||||
note.margin_y,
|
||||
(note.width, note.height),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn widget_origin(
|
||||
anchor: ferese_core::desktop::Anchor,
|
||||
margin_x: i32,
|
||||
margin_y: i32,
|
||||
size: (u32, u32),
|
||||
output: (i32, i32),
|
||||
) -> cosmic::iced::Point {
|
||||
use ferese_core::desktop::Anchor as A;
|
||||
let x = match anchor {
|
||||
A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32,
|
||||
A::TopRight | A::CenterRight | A::BottomRight => output.0 as f32 - size.0 as f32 - margin_x as f32,
|
||||
_ => (output.0 as f32 - size.0 as f32) / 2.,
|
||||
};
|
||||
let y = match anchor {
|
||||
A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32,
|
||||
A::BottomLeft | A::BottomCenter | A::BottomRight => output.1 as f32 - size.1 as f32 - margin_y as f32,
|
||||
_ => (output.1 as f32 - size.1 as f32) / 2.,
|
||||
};
|
||||
clamp_note_position(cosmic::iced::Point::new(x, y), output, size)
|
||||
}
|
||||
|
||||
pub(super) fn clock_placement(clock: &ferese_core::desktop::Clock) -> (Anchor, IcedMargin) {
|
||||
widget_placement(clock.anchor, clock.margin_x, clock.margin_y)
|
||||
}
|
||||
|
||||
pub(super) fn widget_placement(
|
||||
position: ferese_core::desktop::Anchor,
|
||||
margin_x: i32,
|
||||
margin_y: i32,
|
||||
) -> (Anchor, IcedMargin) {
|
||||
use ferese_core::desktop::Anchor as Position;
|
||||
let horizontal = match position {
|
||||
Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT,
|
||||
Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT,
|
||||
_ => Anchor::empty(),
|
||||
};
|
||||
let vertical = match position {
|
||||
Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP,
|
||||
Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM,
|
||||
_ => Anchor::empty(),
|
||||
};
|
||||
(
|
||||
horizontal | vertical,
|
||||
IcedMargin {
|
||||
top: if vertical == Anchor::TOP { margin_y } else { 0 },
|
||||
bottom: if vertical == Anchor::BOTTOM { margin_y } else { 0 },
|
||||
left: if horizontal == Anchor::LEFT { margin_x } else { 0 },
|
||||
right: if horizontal == Anchor::RIGHT { margin_x } else { 0 },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn current_time() -> String {
|
||||
format_bar_time(&Zoned::now())
|
||||
}
|
||||
+8
-1595
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,556 @@
|
||||
use super::{
|
||||
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event,
|
||||
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
|
||||
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
|
||||
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position,
|
||||
config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden,
|
||||
registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface,
|
||||
};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> {
|
||||
if matches!(event, wayland::OutputEvent::Removed) {
|
||||
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
||||
let entry = self.outputs.remove(index);
|
||||
let menu = if self.bar_surface_id == entry.bar {
|
||||
self.destroy_menu()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
|
||||
let mut tasks = vec![destroy_layer_surface(entry.bar), menu, self.rebuild_system_modal()];
|
||||
if self.note_drag.as_ref().is_some_and(|drag| {
|
||||
entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source)
|
||||
}) {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
}
|
||||
|
||||
if let Some(wallpaper) = entry.wallpaper {
|
||||
tasks.push(destroy_layer_surface(wallpaper));
|
||||
}
|
||||
|
||||
if let Some(clock) = entry.clock {
|
||||
tasks.push(destroy_layer_surface(clock));
|
||||
}
|
||||
|
||||
for (_, id) in entry.notes {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let size = match &event {
|
||||
wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size),
|
||||
wayland::OutputEvent::InfoUpdate(info) => info.logical_size,
|
||||
_ => None,
|
||||
};
|
||||
let name = match event {
|
||||
wayland::OutputEvent::Created(info) => info.and_then(|info| info.name),
|
||||
wayland::OutputEvent::InfoUpdate(info) => info.name,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
|
||||
if size.is_some() {
|
||||
entry.size = size;
|
||||
}
|
||||
|
||||
let changed = name.is_some() && entry.name != name;
|
||||
if name.is_some() {
|
||||
entry.name = name;
|
||||
}
|
||||
|
||||
return if changed {
|
||||
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)])
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
}
|
||||
|
||||
let bar_surface_id = window::Id::unique();
|
||||
let wallpaper_surface_id = window::Id::unique();
|
||||
let shell_theme = self.config.theme;
|
||||
let bar = BarMetrics::from(shell_theme);
|
||||
let wallpaper_output = output.clone();
|
||||
let bar_output = output.clone();
|
||||
let hidden = output_bar_hidden(
|
||||
&self.snapshot,
|
||||
self.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| Some(output.name.as_str()) == name.as_deref())
|
||||
.map(|output| output.id),
|
||||
);
|
||||
self.outputs.push(OutputSurfaces {
|
||||
output,
|
||||
name,
|
||||
bar: bar_surface_id,
|
||||
wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER")
|
||||
.is_none()
|
||||
.then_some(wallpaper_surface_id),
|
||||
effects: None,
|
||||
clock: None,
|
||||
notes: Vec::new(),
|
||||
size,
|
||||
hidden,
|
||||
});
|
||||
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| SctkLayerSurfaceSettings {
|
||||
id: wallpaper_surface_id,
|
||||
layer: Layer::Background,
|
||||
keyboard_interactivity: KeyboardInteractivity::None,
|
||||
input_zone: Some(Vec::new()),
|
||||
anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT,
|
||||
output: IcedOutput::Output(wallpaper_output.clone()),
|
||||
namespace: "ferese-shell-wallpaper".to_owned(),
|
||||
// Wallpaper covers the full output, including beneath the bar
|
||||
// and its floating margins. Zero would use the remaining workspace.
|
||||
exclusive_zone: -1,
|
||||
size: Some((None, None)),
|
||||
size_limits: Limits::NONE,
|
||||
..Default::default()
|
||||
},
|
||||
Some(Box::new(Self::view_wallpaper)),
|
||||
);
|
||||
let bar_action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| SctkLayerSurfaceSettings {
|
||||
id: bar_surface_id,
|
||||
input_zone: hidden.then(Vec::new),
|
||||
layer: Layer::Top,
|
||||
keyboard_interactivity: KeyboardInteractivity::None,
|
||||
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
|
||||
output: IcedOutput::Output(bar_output.clone()),
|
||||
namespace: "ferese-shell-top-bar".to_owned(),
|
||||
margin: IcedMargin {
|
||||
top: shell_theme.bar_margin_top,
|
||||
right: shell_theme.bar_margin_horizontal,
|
||||
bottom: 0,
|
||||
left: shell_theme.bar_margin_horizontal,
|
||||
},
|
||||
size: Some((None, Some(bar.height.round() as u32))),
|
||||
size_limits: Limits::NONE,
|
||||
// Reserve breathing room below the visible bar; layer-shell
|
||||
// accounts for the top margin separately.
|
||||
exclusive_zone: (bar.height.round() as i32).saturating_add(shell_theme.bar_window_gap),
|
||||
},
|
||||
Some(Box::new(move |app| app.view_layer(bar_surface_id))),
|
||||
);
|
||||
|
||||
// Submit the small interactive surface before the full-screen image.
|
||||
let mut surfaces = vec![bar_action];
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() {
|
||||
surfaces.push(wallpaper_action);
|
||||
}
|
||||
|
||||
let tasks = surfaces
|
||||
.into_iter()
|
||||
.map(cosmic::Action::Surface)
|
||||
.map(cosmic::task::message);
|
||||
|
||||
Task::batch([
|
||||
Task::batch(tasks),
|
||||
self.rebuild_clocks(false),
|
||||
self.rebuild_notes(false),
|
||||
self.rebuild_system_modal(),
|
||||
cosmic::task::message(cosmic::Action::App(Message::ShowGuide)),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
|
||||
if let Event::Mouse(mouse) = &event {
|
||||
if !self
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match mouse {
|
||||
cosmic::iced::mouse::Event::CursorMoved { position } => {
|
||||
self.note_pointer.insert(id, *position);
|
||||
|
||||
if let Some(drag) = &mut self.note_drag
|
||||
&& drag.source == id
|
||||
{
|
||||
let delta = *position - drag.start;
|
||||
let dimensions = if let Some(id) = &drag.id {
|
||||
self.config
|
||||
.desktop_widgets
|
||||
.notes
|
||||
.iter()
|
||||
.find(|note| ¬e.id == id)
|
||||
.map(|note| (note.width, note.height))
|
||||
} else {
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
Some((clock.width, clock.height))
|
||||
};
|
||||
|
||||
if let Some(dimensions) = dimensions {
|
||||
let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions);
|
||||
drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles);
|
||||
// Move a compact, cached buffer in the compositor instead of
|
||||
// repainting an output-sized buffer for every pointer event.
|
||||
return set_margin(
|
||||
drag.overlay,
|
||||
drag.position.y.round() as i32,
|
||||
0,
|
||||
0,
|
||||
drag.position.x.round() as i32,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left)
|
||||
if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) =>
|
||||
{
|
||||
return self.finish_note_drag(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
..
|
||||
}) = &event
|
||||
&& !modifiers.control()
|
||||
&& self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|
||||
{
|
||||
return self.navigate_system_modal(modifiers.shift());
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
) {
|
||||
if self.system_modal.is_some() {
|
||||
return self.close_system_modal();
|
||||
}
|
||||
if self.note_drag.is_some() {
|
||||
return self.finish_note_drag(false);
|
||||
}
|
||||
|
||||
if self.note_editor.is_some() {
|
||||
return self.finish_note_edit();
|
||||
}
|
||||
|
||||
if self.notifications.history_open {
|
||||
return self.close_notification_history();
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => {
|
||||
self.output_event(event, output)
|
||||
}
|
||||
Event::Window(window::Event::Opened { .. })
|
||||
if self.outputs.iter().any(|entry| entry.bar == id)
|
||||
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
||||
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|
||||
|| self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|surface| surface.id == id) =>
|
||||
{
|
||||
Task::batch([
|
||||
self.focus_system_modal(id),
|
||||
window::run(id, native_wayland_surface)
|
||||
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))),
|
||||
])
|
||||
}
|
||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if self.menu.is_some() => self.close_menu(),
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.id == popup_id
|
||||
{
|
||||
match event {
|
||||
wayland::PopupEvent::Done => {
|
||||
if menu.kind == status_ui::Menu::Notifications {
|
||||
self.notifications.history_open = false;
|
||||
self.notifications.hovered = None;
|
||||
}
|
||||
self.menu = None;
|
||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
return self.sync_notification_surface();
|
||||
}
|
||||
wayland::PopupEvent::Configured { .. } => {
|
||||
menu.prepare_surface(&surface);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(effects) = &menu.effects {
|
||||
let _ = effects.set_opacity(menu.progress());
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => {
|
||||
self.menu = None;
|
||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
Task::none()
|
||||
}
|
||||
Event::Window(window::Event::Closed)
|
||||
if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) =>
|
||||
{
|
||||
self.close_system_modal()
|
||||
}
|
||||
Event::Window(window::Event::Closed) => Task::none(),
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) =>
|
||||
{
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.effects.is_none()
|
||||
{
|
||||
menu.prepare_surface(&surface);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
|
||||
if self.outputs.iter().any(|entry| entry.bar == frame_id) =>
|
||||
{
|
||||
self.attach_effects(frame_id, &surface);
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
|
||||
if self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.id == layer_id) =>
|
||||
{
|
||||
let entry = self.notification_surface.as_mut().unwrap();
|
||||
|
||||
if entry.effects.is_none() {
|
||||
match EffectsBinding::attach_role(&surface, None, 1.0) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: notification effects unavailable: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
|
||||
if self.outputs.iter().any(|entry| entry.bar == layer_id) =>
|
||||
{
|
||||
self.attach_effects(layer_id, &surface);
|
||||
self.attach_power_material(layer_id, &surface);
|
||||
Task::none()
|
||||
}
|
||||
_ => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
|
||||
let hidden = self.bar_hidden(id);
|
||||
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if entry.effects.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
|
||||
match EffectsBinding::attach(surface, !hidden) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(handle) = self.wallpaper.as_ref() else {
|
||||
return container(text(""))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(theme::Container::custom(wallpaper_fallback_style))
|
||||
.into();
|
||||
};
|
||||
let content_fit = match self.config.wallpaper.mode {
|
||||
WallpaperMode::Fill => ContentFit::Cover,
|
||||
WallpaperMode::Fit => ContentFit::Contain,
|
||||
};
|
||||
|
||||
container(
|
||||
image(handle.clone())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.content_fit(content_fit),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(theme::Container::custom(wallpaper_fallback_style))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a surface while iced guarantees the native handles are alive. Keep
|
||||
// the borrowed-display connection alive until the effects binding takes over.
|
||||
pub(super) fn native_wayland_surface(
|
||||
window: &dyn cosmic::iced::window::Window,
|
||||
) -> Result<(Connection, wl_surface::WlSurface), String> {
|
||||
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
let display = window.display_handle().map_err(|error| error.to_string())?;
|
||||
let handle = window.window_handle().map_err(|error| error.to_string())?;
|
||||
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw())
|
||||
else {
|
||||
return Err("shell material requires a Wayland surface".to_owned());
|
||||
};
|
||||
// SAFETY: iced lends matching, live Wayland handles for this callback.
|
||||
// This backend borrows the display; it never disconnects iced's connection.
|
||||
// Both handles belong to the runtime, which outlives the shell bindings.
|
||||
let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
|
||||
let connection = Connection::from_backend(backend);
|
||||
// SAFETY: raw-window-handle identifies this pointer as a live wl_surface.
|
||||
let id = unsafe {
|
||||
wayland_client::backend::ObjectId::from_ptr(wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast())
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
|
||||
Ok((connection, surface))
|
||||
}
|
||||
|
||||
pub(super) fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
|
||||
let [red, green, blue] = config::default_background();
|
||||
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(red, green, blue))),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct EffectsBinding {
|
||||
connection: Connection,
|
||||
_manager: FereseEffectsManagerV1,
|
||||
pub(super) surface: FereseSurfaceEffectsV1,
|
||||
_queue: EventQueue<EffectsState>,
|
||||
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
|
||||
opacity: std::cell::Cell<Option<u32>>,
|
||||
}
|
||||
|
||||
impl EffectsBinding {
|
||||
pub(super) fn attach(surface: &wl_surface::WlSurface, visible: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Self::attach_role(
|
||||
surface,
|
||||
visible.then_some(ferese_surface_effects_v1::Role::Panel),
|
||||
if visible { 1.0 } else { 0.0 },
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn attach_role(
|
||||
surface: &wl_surface::WlSurface,
|
||||
role: Option<ferese_surface_effects_v1::Role>,
|
||||
opacity: f32,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let backend = surface
|
||||
.backend()
|
||||
.upgrade()
|
||||
.ok_or("libcosmic Wayland connection is no longer alive")?;
|
||||
let connection = Connection::from_backend(backend);
|
||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||
let qh = queue.handle();
|
||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?;
|
||||
let effects = manager.get_surface_effects(surface, &qh, ());
|
||||
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
if effects.version() >= 3 {
|
||||
effects.set_opacity(opacity);
|
||||
}
|
||||
if let Some(role) = role {
|
||||
effects.set_role(role);
|
||||
} else {
|
||||
effects.clear_role();
|
||||
}
|
||||
connection.flush()?;
|
||||
|
||||
Ok(Self {
|
||||
connection,
|
||||
_manager: manager,
|
||||
surface: effects,
|
||||
_queue: queue,
|
||||
regions: Default::default(),
|
||||
opacity: std::cell::Cell::new(Some(opacity)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
|
||||
}
|
||||
|
||||
pub(super) fn set_material_regions(
|
||||
&self,
|
||||
regions: &[[i32; 5]],
|
||||
role: ferese_surface_effects_v1::Role,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) {
|
||||
return Ok(());
|
||||
}
|
||||
self.surface.set_regions(
|
||||
regions
|
||||
.iter()
|
||||
.flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes()))
|
||||
.collect(),
|
||||
);
|
||||
if regions.is_empty() {
|
||||
self.surface.clear_role();
|
||||
} else {
|
||||
self.surface.set_role(role);
|
||||
}
|
||||
self.connection.flush()?;
|
||||
*self.regions.borrow_mut() = Some(regions.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.set_opacity(1.0)?;
|
||||
if visible {
|
||||
self.surface.set_role(ferese_surface_effects_v1::Role::Panel);
|
||||
} else {
|
||||
self.surface.clear_role();
|
||||
}
|
||||
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
|
||||
self.surface.set_opacity(opacity);
|
||||
self.connection.flush()?;
|
||||
self.opacity.set(Some(opacity));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct EffectsState;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_connection: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
|
||||
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
|
||||
Reference in new issue
Block a user