Unify shell opacity and add native authentication and system controls

This commit is contained in:
Samuel Onoja committed 2026-09-28 09:26:14 +01:00
1 parent 8832a9bf2e
commit c8e89fb758
35 files changed
+1670 -129

No files matched your search

Generated
+16
View File
@@ -1721,6 +1721,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ferese-polkit-agent"
version = "0.1.0"
dependencies = [
"ferese-config",
"glib",
"libc",
"libcosmic",
"libloading",
"serde",
"serde_json",
"tokio",
"zbus",
"zeroize",
]
[[package]]
name = "ferese-protocols"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ members = [
"shell/ferese-shell",
"shell/ferese-settings",
"shell/ferese-lock",
"shell/ferese-polkit-agent",
"portal",
]
resolver = "3"
+4 -1
View File
@@ -23,7 +23,7 @@ cohesive experience—beautiful by default and unmistakably yours.
- **Flexible layouts:** scrolling columns, tree tiling, floating windows and a
workspace overview.
- **A native shell:** menu bar, control center, settings, notifications and
desktop widgets designed to belong together.
desktop widgets designed to belong together, including themed authentication prompts.
- **Personal by design:** themes, wallpapers, accent colors, blur, borders and
motion that update without restarting your session.
- **Ready for real setups:** gestures, multi-monitor support, fractional
@@ -84,6 +84,9 @@ These are the built-in defaults; every shortcut and gesture can be changed.
| Super + M | Switch scrolling / tree layout |
| Super + Shift + S | Select a screenshot area |
Click the bar date for a calendar. Click the battery for power modes and
confirmed Restart, Power off and Suspend actions.
Three-finger swipes move between workspaces and windows. Open **Control Center →
Settings → Shortcuts** to change them or assign gestures to other actions.
+15 -28
View File
@@ -190,7 +190,7 @@ pub struct ThemeSettings {
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub panel_opacity: f64,
pub shell_opacity: f64,
pub inactive_dim: InactiveDimSettings,
pub window_radius: f64,
pub shadow_offset_y: f64,
@@ -253,8 +253,6 @@ struct ThemeConfig {
#[serde(default)]
background: crate::wallpaper::WallpaperConfig,
#[serde(default)]
surface: ThemeSurfaceConfig,
#[serde(default)]
colors: ThemeColorsConfig,
#[serde(default)]
geometry: ThemeGeometryConfig,
@@ -331,19 +329,10 @@ struct OverviewTypographyConfig {
font_family: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeSurfaceConfig {
#[serde(default)]
bar: ThemeBarConfig,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeBarConfig {
opacity: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ThemeMaterialConfig {
#[serde(default = "default_shell_opacity")]
opacity: f64,
#[serde(default)]
style: MaterialStyle,
#[serde(default = "default_backdrop_blur")]
@@ -354,11 +343,16 @@ impl Default for ThemeMaterialConfig {
fn default() -> Self {
Self {
style: MaterialStyle::Solid,
opacity: default_shell_opacity(),
blur_radius: default_backdrop_blur(),
}
}
}
fn default_shell_opacity() -> f64 {
ferese_config::DEFAULT_MATERIAL_OPACITY
}
fn default_backdrop_blur() -> f64 {
12.0
}
@@ -1095,10 +1089,7 @@ impl Config {
&self.theme.colors.surface_base,
"colors.surface_base",
)?,
panel_opacity: unit_theme_value(
self.theme.surface.bar.opacity.unwrap_or(0.78),
"surface.bar.opacity",
)?,
shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?,
inactive_dim: InactiveDimSettings {
enabled: self.appearance.inactive_dim.enabled,
amount: unit_theme_value(
@@ -2034,7 +2025,7 @@ mod tests {
focus_ring_gradient: None,
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78,
shell_opacity: 0.78,
inactive_dim: InactiveDimSettings {
enabled: false,
amount: 0.15,
@@ -2125,24 +2116,20 @@ mod tests {
}
#[test]
fn bar_opacity_uses_config_and_rejects_invalid_values() {
assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78);
fn shell_opacity_uses_config_and_rejects_invalid_values() {
assert_eq!(parse("").theme_settings().unwrap().shell_opacity, 0.78);
for opacity in [0.0, 0.65, 1.0] {
assert_eq!(
parse(&format!(
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
))
parse(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
.theme_settings()
.unwrap()
.panel_opacity,
.shell_opacity,
opacity
);
}
for opacity in ["-0.1", "1.1", "#nan", "#inf"] {
assert!(
Config::parse_source(&format!(
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
))
Config::parse_source(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
+11 -9
View File
@@ -37,7 +37,11 @@ pub(crate) struct ResolvedMaterial {
pub shadow: [f64; 3],
}
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial {
pub(crate) fn resolve_material(
role: SemanticRole,
style: MaterialStyle,
opacity: f32,
) -> ResolvedMaterial {
let shadow = match role {
SemanticRole::Panel => [1.0, 5.0, 0.04],
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
@@ -46,11 +50,9 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> Reso
SemanticRole::Hud => [1.0, 4.0, 0.04],
SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
};
let opacity = match (style, role) {
(MaterialStyle::Solid, _) => 1.0,
(_, SemanticRole::Panel) => 0.78,
(_, SemanticRole::Hud | SemanticRole::Modal) => 0.88,
_ => 0.84,
let opacity = match style {
MaterialStyle::Solid => 1.0,
MaterialStyle::Translucent => opacity.clamp(0.0, 1.0),
};
ResolvedMaterial {
style,
@@ -430,10 +432,10 @@ mod tests {
SemanticRole::Notification,
SemanticRole::Modal,
] {
let solid = resolve_material(role, MaterialStyle::Solid);
let translucent = resolve_material(role, MaterialStyle::Translucent);
let solid = resolve_material(role, MaterialStyle::Solid, 0.6);
let translucent = resolve_material(role, MaterialStyle::Translucent, 0.6);
assert_eq!(solid.opacity, 1.0);
assert!(translucent.opacity < 1.0);
assert_eq!(translucent.opacity, 0.6);
assert_eq!(solid.shadow, translucent.shadow);
}
}
+3
View File
@@ -71,6 +71,9 @@ pub fn resolve(
) -> WindowRuleResult {
let app_id = app_id.map(normalize_app_id);
let mut result = WindowRuleResult::default();
if app_id.as_deref() == Some("dev.ferese.Authentication") {
result.floating = Some(true);
}
for rule in rules {
let app_id_matches = rule
+11 -12
View File
@@ -1532,7 +1532,11 @@ fn overview_strip_elements(
}
}
let mut color = state.theme_settings.surface_base_color.0;
color[3] = alpha * state.theme_settings.panel_opacity as f32;
color[3] = alpha * crate::effects::resolve_material(
crate::effects::SemanticRole::Panel,
state.theme_settings.material_style,
state.theme_settings.shell_opacity as f32,
).opacity;
if let Some(strip) = overview_chrome_element(
state,
renderer,
@@ -2306,13 +2310,12 @@ fn material_element(
capture_geometry: Rectangle<i32, Logical>,
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let (role, generation) = crate::effects::surface_role(surface)?;
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style);
let material = crate::effects::resolve_material(
role,
state.theme_settings.material_style,
state.theme_settings.shell_opacity as f32,
);
let presentation_alpha = crate::effects::surface_opacity(surface);
if role == crate::effects::SemanticRole::Panel
&& material.style == crate::config::MaterialStyle::Translucent
{
material.opacity = state.theme_settings.panel_opacity as f32;
}
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let transform = output.current_transform().invert();
@@ -2330,11 +2333,7 @@ fn material_element(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
geometry.size,
);
let background_opacity = if role == crate::effects::SemanticRole::Panel {
material.opacity
} else {
1.0
};
let background_opacity = material.opacity;
let blur = material_blur_radius(
material.style,
material.opacity,
+2
View File
@@ -3,6 +3,8 @@ use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::{fmt, path::PathBuf};
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
#[derive(Debug)]
pub struct Error(String);
+1 -2
View File
@@ -134,6 +134,7 @@ these six visually distinct.
| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and the sharing dialog. Text/icons stay opaque; 0 and 1 skip blur. Solid mode is always opaque |
| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
| `theme.colors` key | Default | Meaning |
@@ -172,10 +173,8 @@ The target follows [normal-text contrast guidance](https://www.w3.org/WAI/WCAG22
| Section / key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface_base` |
| `theme.surface.bar.opacity` | number 0–1 | omitted | Translucent bar background opacity, including blur; defaults to `0.78`. Text/icons stay opaque; 0 and 1 skip blur. Fallback retains background alpha |
| `theme.surface.bar.text_primary` | color | `"#F0F3FA"` | Bar text/icons |
| `theme.surface.bar.text_muted` | color | `"#AAB4C7"` | Inactive bar foreground |
| `theme.surface.popover.opacity` | number 0–1 | `0.96` | Shell popover fill alpha |
| `theme.shadow.soft.offset_y` | number | `4` | Window shadow vertical offset |
| `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness |
| `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength |
+13 -6
View File
@@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device.
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
native development libraries below. The installer builds Ferese, its shell,
Settings, command-line tools, locker, and ScreenCast portal together. It does not install system
Settings, command-line tools, locker, polkit agent, and ScreenCast portal together. It does not install system
packages or enable services for you.
### Fedora
@@ -23,7 +23,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \
clang clang-devel pipewire-devel pipewire wireplumber xdg-desktop-portal xdg-desktop-portal-gtk \
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \
xdg-desktop-portal xdg-desktop-portal-gtk \
gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
```
@@ -32,7 +33,7 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
```sh
sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \
libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
desktop-file-utils foot pam clang pipewire libpipewire wireplumber \
desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \
xdg-desktop-portal xdg-desktop-portal-gtk \
gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire
```
@@ -45,7 +46,7 @@ sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber polkitd libpolkit-agent-1-0 \
xdg-desktop-portal xdg-desktop-portal-gtk \
libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good
@@ -94,7 +95,7 @@ The installer adds:
- Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and
`previous` links for upgrades and rollback.
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
`feresectl`, `ferese-lock`, `ferese-screenshot`,
`feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and
`xdg-desktop-portal-ferese`, and `ferese-record`.
- D-Bus activation and Ferese-specific ScreenCast portal registration.
- A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
@@ -105,6 +106,12 @@ The installer adds:
Your existing configuration and other desktop sessions are preserved. The
installer does not change your default session or login manager.
Ferese starts its own polkit authentication agent with the shell. Password
prompts follow the configured font, accent, surface color, and corner radius.
Authentication still uses polkit's system helper and policy. The agent starts
only inside a Ferese session; logging into another desktop leaves its agent
unchanged. Restart your Ferese session after upgrading to use the new prompt.
### Initial configuration
Ferese has built-in defaults. To start from the example without replacing an
@@ -255,7 +262,7 @@ Installer options:
`--dry-run` prints the planned commands. `--offline` requires dependencies to be
cached already. `--skip-build` installs the binaries already in `target/release/`;
use it only after building all session components from the intended revision.
use it only after building all six components from the intended revision.
Run `./scripts/install.sh --help` for all options.
## Logout and recovery
+1 -1
View File
@@ -29,7 +29,7 @@ opens a display picker. Select a display and press **Share**. Nothing is selecte
by default. Applications that request multiple displays can share up to four.
The picker uses your Ferese theme colors, font, and shell corner radius. Its
background follows `theme.material.style`: opaque in solid mode, or translucent
using `theme.surface.popover.opacity`. Text and icons remain opaque.
using the shared **Appearance → Shell opacity** setting (`theme.material.opacity`). Text and icons remain opaque.
Other applications keep a **screen sharing** window with a **Stop sharing** button.
Ferese’s built-in recorder uses the top-bar stop control instead, leaving your
+3 -1
View File
@@ -18,10 +18,12 @@ layout {
}
theme {
material {
opacity 0.95
}
surface {
bar {
background "#111821"
opacity 0.95
text-primary "#F4F7FB"
text-muted "#8793A2"
}
+14 -1
View File
@@ -9,4 +9,17 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true
fi
release_dir=$(dirname -- "$(readlink -f -- "$0")")
exec "$release_dir/ferese-shell"
env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \
"$release_dir/ferese-polkit-agent" &
agent_pid=$!
"$release_dir/ferese-shell" &
shell_pid=$!
trap 'kill "$shell_pid" "$agent_pid" 2>/dev/null || true' EXIT INT TERM
if wait "$shell_pid"; then
status=0
else
status=$?
fi
kill "$agent_pid" 2>/dev/null || true
wait "$agent_pid" 2>/dev/null || true
exit "$status"
+2 -2
View File
@@ -322,9 +322,9 @@ fn appearance() -> (
&string("theme.material.style", "solid"),
document
.as_ref()
.and_then(|d| d.get("theme.surface.popover.opacity"))
.and_then(|d| d.get("theme.material.opacity"))
.and_then(|v| v.as_f64())
.unwrap_or(0.96),
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY),
);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
let family: &'static str =
+5 -4
View File
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations.
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
@@ -39,9 +39,10 @@ if [[ -e $session_entry ]]; then
echo "Different session entry exists: $session_entry" >&2; exit 1;
}
fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done
"$repo_dir/target/release/ferese-polkit-agent" --check
for name in ferese.png ferese.svg; do
[[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; }
done
@@ -79,7 +80,7 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
if [[ -e $session_entry ]]; then
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
@@ -101,7 +102,7 @@ if [[ -L $install_root/current ]]; then
fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
+2 -2
View File
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
--target-dir "$repo_dir/target" --release --locked
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese)
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese)
if $offline; then build+=(--offline); fi
if ! $skip_build; then
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
@@ -75,7 +75,7 @@ fi
cd -- "$repo_dir"
if ! $skip_build; then "${build[@]}"; fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent; do
[[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name"
done
"${installer[@]}"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "ferese-polkit-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
[dependencies]
ferese-config = { path = "../../crates/ferese-config" }
glib = "0.21.5"
libc.workspace = true
libcosmic = { workspace = true, features = ["wgpu"] }
libloading = "0.8.9"
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
zbus = "5.19.0"
zeroize = "1.9"
+172
View File
@@ -0,0 +1,172 @@
mod prompt;
mod session;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use zbus::zvariant::{OwnedValue, Value};
const AGENT_PATH: &str = "/dev/ferese/PolkitAgent";
const AUTHORITY: &str = "org.freedesktop.PolicyKit1";
const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PromptEvent {
Start { message: String, user: String },
Request { prompt: String, echo: bool },
Info { text: String },
Error { text: String },
Response { value: String },
Cancel,
}
type Identity = (String, HashMap<String, OwnedValue>);
#[derive(Default)]
struct Agent {
active: Mutex<HashMap<String, Arc<AtomicBool>>>,
}
#[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
impl Agent {
async fn begin_authentication(
&self,
_action_id: String,
message: String,
_icon_name: String,
_details: HashMap<String, String>,
cookie: String,
identities: Vec<Identity>,
) -> zbus::fdo::Result<()> {
let uid = choose_identity(&identities).ok_or_else(|| {
zbus::fdo::Error::Failed("No supported authentication identity".into())
})?;
let cancelled = Arc::new(AtomicBool::new(false));
{
let mut active = self.active.lock().unwrap();
if active.contains_key(&cookie) {
return Err(zbus::fdo::Error::Failed("Duplicate request".into()));
}
active.insert(cookie.clone(), cancelled.clone());
}
let result = tokio::task::spawn_blocking({
let cookie = cookie.clone();
move || {
session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
}
})
.await
.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
self.active.lock().unwrap().remove(&cookie);
match result {
Ok(true) => Ok(()),
Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())),
Err(error) => Err(zbus::fdo::Error::Failed(error)),
}
}
fn cancel_authentication(&self, cookie: String) {
if let Some(cancelled) = self.active.lock().unwrap().get(&cookie) {
cancelled.store(true, Ordering::Release);
}
}
}
fn choose_identity(identities: &[Identity]) -> Option<u32> {
let available: Vec<_> = identities
.iter()
.filter(|(kind, _)| kind == "unix-user")
.filter_map(|(_, details)| {
details
.get("uid")
.and_then(|uid| u32::try_from(uid.clone()).ok())
.filter(|uid| *uid <= i32::MAX as u32)
})
.collect();
let current = unsafe { libc::geteuid() };
available
.iter()
.copied()
.find(|uid| *uid == current)
.or_else(|| available.first().copied())
}
fn session_subject() -> Result<(String, HashMap<String, Value<'static>>), String> {
let id = std::env::var("XDG_SESSION_ID")
.map_err(|_| "XDG_SESSION_ID is required for polkit registration".to_owned())?;
if id.is_empty() {
return Err("XDG_SESSION_ID is empty".into());
}
let mut details = HashMap::new();
details.insert("session-id".to_owned(), Value::from(id));
Ok(("unix-session".into(), details))
}
async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
let subject = session_subject()?;
let connection = zbus::connection::Builder::system()?
.serve_at(AGENT_PATH, Agent::default())?
.build()
.await?;
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY).await?;
let locale = std::env::var("LANG").unwrap_or_default();
authority
.call::<_, _, ()>(
"RegisterAuthenticationAgent",
&(subject, locale, AGENT_PATH),
)
.await?;
std::future::pending::<()>().await;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 0);
}
match std::env::args().nth(1).as_deref() {
None => tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run_agent()),
Some("--prompt") => prompt::run(),
Some("--check") => session::check().map_err(Into::into),
_ => Err("Usage: ferese-polkit-agent [--check]".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chooses_current_identity_when_available() {
let current = unsafe { libc::geteuid() };
let identity = |uid: u32| {
(
"unix-user".to_owned(),
HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]),
)
};
assert_eq!(
choose_identity(&[identity(0), identity(current)]),
Some(current)
);
}
}
+489
View File
@@ -0,0 +1,489 @@
use crate::PromptEvent;
use cosmic::{
Element,
app::{Core, Settings, Task},
iced::{Alignment, Background, Border, Color, Length, Size, Subscription, window},
theme,
widget::{self, button, column, container, row, text},
};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Write},
sync::{Arc, Mutex},
time::Duration,
};
use zeroize::{Zeroize, Zeroizing};
#[derive(Clone)]
struct Appearance {
surface: Color,
translucent: bool,
text: Color,
muted: Color,
accent: Color,
radius: f32,
font: cosmic::font::Font,
}
impl Appearance {
fn load() -> Self {
let document = ferese_config::config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| ferese_config::Document::parse(&source).ok());
let setting = |key: &str, fallback: &str| {
document
.as_ref()
.and_then(|doc| doc.get(key))
.and_then(|value| value.as_str())
.unwrap_or(fallback)
.to_owned()
};
let color = |key, fallback| parse_color(&setting(key, fallback), fallback);
let family: &'static str =
Box::leak(setting("theme.typography.font_family", "Inter").into_boxed_str());
let translucent = setting("theme.material.style", "solid") == "translucent";
let shell_opacity = document
.as_ref()
.and_then(|doc| doc.get("theme.material.opacity"))
.and_then(|value| value.as_f64())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.) as f32;
let mut surface = color("theme.colors.surface_base", "#111821");
surface.a = if translucent { shell_opacity } else { 1. };
Self {
surface,
translucent,
text: color("theme.colors.text_primary", "#F4F7FB"),
muted: color("theme.colors.text_muted", "#8793A2"),
accent: color("theme.colors.accent", "#3D7BE6"),
radius: document
.as_ref()
.and_then(|doc| doc.get("theme.geometry.shell_radius"))
.and_then(|value| value.as_f64())
.unwrap_or(14.)
.clamp(8., 36.) as f32,
font: cosmic::font::Font {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
}
}
fn theme(&self) -> cosmic::Theme {
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
let rgba = |color: Color| Srgba::new(color.r, color.g, color.b, color.a);
let builder = if self.surface.r + self.surface.g + self.surface.b > 1.5 {
ThemeBuilder::light()
} else {
ThemeBuilder::dark()
};
let mut corners = cosmic::cosmic_theme::CornerRadii::default();
corners.radius_xs = [self.radius.min(4.); 4];
corners.radius_s = [self.radius.min(8.); 4];
corners.radius_m = [self.radius; 4];
corners.radius_l = [self.radius; 4];
corners.radius_xl = [self.radius; 4];
cosmic::Theme::custom(Arc::new(
builder
.corner_radii(corners)
.bg_color(rgba(Color {
a: if self.translucent { 0. } else { 1. },
..self.surface
}))
.primary_container_bg(rgba(self.surface))
.text_tint(rgba(self.text).color)
.accent(rgba(self.accent).color)
.build(),
))
}
}
fn parse_color(input: &str, fallback: &str) -> Color {
let parse = |source: &str| {
let value = u32::from_str_radix(source.strip_prefix('#')?, 16).ok()?;
(source.len() == 7)
.then(|| Color::from_rgb8((value >> 16) as u8, (value >> 8) as u8, value as u8))
};
parse(input)
.or_else(|| parse(fallback))
.unwrap_or(Color::BLACK)
}
#[derive(Clone)]
enum Message {
Tick,
Input(String),
Submit,
Cancel,
}
impl std::fmt::Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AuthenticationPromptMessage")
}
}
struct Prompt {
core: Core,
appearance: Appearance,
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
description: String,
user: String,
question: String,
echo: bool,
answer: Zeroizing<String>,
waiting: bool,
error: Option<String>,
info: Option<String>,
height: f32,
}
fn content_height(description: &str, status: Option<&str>) -> f32 {
let lines: usize = description
.lines()
.map(|line| line.chars().count().div_ceil(48).max(1))
.sum();
let status_height = status.map_or(0, |message| {
14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3)
});
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
}
fn input_style(palette: &Appearance) -> theme::TextInput {
let text = palette.text;
let muted = palette.muted;
let accent = palette.accent;
let radius = palette.radius.min(10.);
let appearance = move |focused: bool| widget::text_input::Appearance {
background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(),
border_radius: radius.into(),
border_width: 1.,
border_offset: None,
border_color: if focused {
accent.scale_alpha(0.82)
} else {
muted.scale_alpha(0.26)
},
icon_color: Some(muted),
text_color: Some(text),
placeholder_color: muted,
selected_text_color: text,
selected_fill: accent.scale_alpha(0.35),
label_color: text,
};
theme::TextInput::Custom {
active: Box::new(move |_| appearance(false)),
hovered: Box::new(move |_| appearance(true)),
focused: Box::new(move |_| appearance(true)),
error: Box::new(move |_| appearance(false)),
disabled: Box::new(move |_| appearance(false)),
}
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let stdin = std::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let Some(Ok(first)) = lines.next() else {
return Err("Missing authentication request".into());
};
let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else {
return Err("Invalid authentication request".into());
};
let incoming = Arc::new(Mutex::new(VecDeque::new()));
let queue = incoming.clone();
std::thread::spawn(move || {
for line in lines {
let Ok(line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
queue.lock().unwrap().push_back(event);
}
}
queue.lock().unwrap().push_back(PromptEvent::Cancel);
});
let appearance = Appearance::load();
let height = content_height(&message, None);
cosmic::app::run::<Prompt>(
Settings::default()
.size(Size::new(434., height))
.client_decorations(false)
.transparent(true)
.theme(appearance.theme())
.default_font(appearance.font)
.default_text_size(14.)
.antialiasing(true),
(incoming, message, user, appearance),
)?;
Ok(())
}
fn reply(mut event: PromptEvent) {
if let Ok(mut line) = serde_json::to_string(&event) {
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{line}");
let _ = stdout.flush();
line.zeroize();
}
if let PromptEvent::Response { value } = &mut event {
value.zeroize();
}
}
fn focus() -> Task<Message> {
widget::text_input::focus(widget::Id::new("auth-response"))
}
impl Prompt {
fn resize_to_content(&mut self) -> Task<Message> {
let height = content_height(
&self.description,
self.error.as_deref().or(self.info.as_deref()),
);
if (height - self.height).abs() < 1.0 {
return Task::none();
}
self.height = height;
self.core
.main_window_id()
.map(|id| window::resize(id, Size::new(434., height)))
.unwrap_or_else(Task::none)
}
}
impl cosmic::Application for Prompt {
type Executor = cosmic::executor::Default;
type Flags = (
Arc<Mutex<VecDeque<PromptEvent>>>,
String,
String,
Appearance,
);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Authentication";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(
mut core: Core,
(incoming, description, user, appearance): Self::Flags,
) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
let height = content_height(&description, None);
let bounds = core
.main_window_id()
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
.unwrap_or_else(Task::none);
(
Self {
core,
appearance,
incoming,
description,
user,
question: "Waiting for authentication…".into(),
echo: false,
answer: Zeroizing::new(String::new()),
waiting: true,
error: None,
info: None,
height,
},
Task::batch([focus(), bounds]),
)
}
fn subscription(&self) -> Subscription<Message> {
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Tick => {
let events: Vec<_> = self.incoming.lock().unwrap().drain(..).collect();
let mut refocus = false;
for event in events {
match event {
PromptEvent::Request { prompt, echo } => {
self.answer.zeroize();
self.question = prompt;
self.echo = echo;
self.waiting = false;
refocus = true;
}
PromptEvent::Info { text } => self.info = Some(text),
PromptEvent::Error { text } => self.error = Some(text),
PromptEvent::Cancel => return cosmic::iced::exit(),
_ => {}
}
}
let resize = self.resize_to_content();
if refocus {
return Task::batch([resize, focus()]);
}
return resize;
}
Message::Input(mut value) => {
if !self.waiting && value.len() <= 4096 {
self.answer.zeroize();
*self.answer = std::mem::take(&mut value);
self.error = None;
}
value.zeroize();
}
Message::Submit if !self.waiting && !self.answer.is_empty() => {
let value = std::mem::take(&mut *self.answer);
reply(PromptEvent::Response { value });
self.waiting = true;
}
Message::Cancel => {
self.answer.zeroize();
reply(PromptEvent::Cancel);
return cosmic::iced::exit();
}
_ => {}
}
Task::none()
}
fn on_escape(&mut self) -> Task<Message> {
self.update(Message::Cancel)
}
fn view(&self) -> Element<'_, Message> {
let palette = &self.appearance;
let mut input = widget::text_input(&self.question, self.answer.as_str())
.id(widget::Id::new("auth-response"))
.font(palette.font)
.padding([7, 12])
.style(input_style(palette));
if !self.echo {
input = input.password();
}
if !self.waiting {
input = input.on_input(Message::Input).on_submit(|mut value| {
value.zeroize();
Message::Submit
});
}
let action = button::custom(text("Authenticate").font(palette.font).size(13))
.class(theme::Button::Suggested)
.height(Length::Fixed(36.))
.padding([8, 16])
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
let lock_svg = std::str::from_utf8(include_bytes!(
"../../ferese-shell/assets/icons/status/lock.svg"
))
.expect("embedded lock icon is valid UTF-8")
.replace(
"currentColor",
&format!(
"#{:02x}{:02x}{:02x}",
(palette.accent.r * 255.) as u8,
(palette.accent.g * 255.) as u8,
(palette.accent.b * 255.) as u8,
),
);
let lock_icon = widget::icon::from_svg_bytes(lock_svg.into_bytes())
.symbolic(false)
.icon()
.size(20);
let heading = row![
container(lock_icon)
.width(40)
.height(40)
.center_x(40)
.center_y(40)
.class(theme::Container::custom(move |_| {
widget::container::Style {
background: Some(Background::Color(palette.accent.scale_alpha(0.13))),
border: Border {
radius: 12.0.into(),
..Default::default()
},
..Default::default()
}
})),
column![
text("Authentication required").font(palette.font).size(18),
text(format!("Confirm as {}", self.user))
.font(palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
]
.spacing(2),
]
.spacing(12)
.align_y(Alignment::Center);
let mut body = column![
heading,
text(&self.description).font(palette.font).size(13),
input
]
.spacing(14)
.width(Length::Fill);
if let Some(error) = &self.error {
body = body.push(
text(error)
.font(palette.font)
.size(12)
.class(theme::Text::Color(Color::from_rgb8(235, 98, 98))),
);
} else if let Some(info) = &self.info {
body = body.push(
text(info)
.font(palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
);
}
body = body.push(
row![
button::custom(text("Cancel").font(palette.font).size(13))
.class(theme::Button::Text)
.padding([8, 14])
.on_press(Message::Cancel),
action
]
.spacing(8)
.align_y(Alignment::Center),
);
container(body)
.padding(20)
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(move |_| {
widget::container::Style {
background: Some(Background::Color(palette.surface)),
text_color: Some(palette.text),
border: Border {
radius: palette.radius.into(),
..Default::default()
},
..Default::default()
}
}))
.into()
}
}
#[cfg(test)]
mod tests {
use super::content_height;
#[test]
fn dialog_grows_for_long_messages_and_caps_its_height() {
assert_eq!(content_height("Short request", None), 214.);
assert!(content_height(&"Long message ".repeat(20), None) > 214.);
assert_eq!(content_height(&"Long message ".repeat(100), None), 360.);
assert!(content_height("Short request", Some("Password not accepted")) > 214.);
}
}
+325
View File
@@ -0,0 +1,325 @@
use crate::PromptEvent;
use glib::gobject_ffi;
use libloading::Library;
use std::{
ffi::{CStr, CString, c_char, c_int, c_void},
io::{BufRead, BufReader, BufWriter, Write},
process::{Child, ChildStdin, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Sender},
},
time::Duration,
};
use zeroize::Zeroize;
enum SessionEvent {
Request(String, bool),
Info(String),
Error(String),
Completed(bool),
}
type Object = *mut c_void;
struct Api {
_agent: Library,
_polkit: Library,
user_new: unsafe extern "C" fn(c_int) -> Object,
session_new: unsafe extern "C" fn(Object, *const c_char) -> Object,
initiate: unsafe extern "C" fn(Object),
response: unsafe extern "C" fn(Object, *const c_char),
cancel: unsafe extern "C" fn(Object),
}
impl Api {
fn load() -> Result<Self, String> {
unsafe {
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
let user_new = *polkit
.get(b"polkit_unix_user_new\0")
.map_err(|e| e.to_string())?;
let session_new = *agent
.get(b"polkit_agent_session_new\0")
.map_err(|e| e.to_string())?;
let initiate = *agent
.get(b"polkit_agent_session_initiate\0")
.map_err(|e| e.to_string())?;
let response = *agent
.get(b"polkit_agent_session_response\0")
.map_err(|e| e.to_string())?;
let cancel = *agent
.get(b"polkit_agent_session_cancel\0")
.map_err(|e| e.to_string())?;
Ok(Self {
_agent: agent,
_polkit: polkit,
user_new,
session_new,
initiate,
response,
cancel,
})
}
}
}
pub fn check() -> Result<(), String> {
Api::load().map(|_| ())
}
fn username(uid: u32) -> String {
let output = Command::new("getent")
.args(["passwd", &uid.to_string()])
.output();
output
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.and_then(|entry| entry.split(':').next().map(str::to_owned))
.filter(|name| !name.is_empty())
.unwrap_or_else(|| uid.to_string())
}
fn send(writer: &mut BufWriter<ChildStdin>, event: &PromptEvent) -> Result<(), String> {
serde_json::to_writer(&mut *writer, event).map_err(|e| e.to_string())?;
writer.write_all(b"\n").map_err(|e| e.to_string())?;
writer.flush().map_err(|e| e.to_string())
}
fn prompt_process(
message: String,
user: String,
) -> Result<(Child, BufWriter<ChildStdin>, mpsc::Receiver<PromptEvent>), String> {
let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?)
.arg("--prompt")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
let stdin = child.stdin.take().ok_or("Prompt has no input")?;
let stdout = child.stdout.take().ok_or("Prompt has no output")?;
let mut writer = BufWriter::new(stdin);
send(&mut writer, &PromptEvent::Start { message, user })?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let Ok(mut line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
line.zeroize();
if tx.send(event).is_err() {
break;
}
} else {
line.zeroize();
}
}
});
Ok((child, writer, rx))
}
unsafe extern "C" fn request(
_session: Object,
prompt: *const c_char,
echo: c_int,
data: *mut c_void,
) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let prompt = unsafe { CStr::from_ptr(prompt) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
}
unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Info(text));
}
unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Error(text));
}
unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let _ = tx.send(SessionEvent::Completed(success != 0));
}
unsafe fn connect(
session: Object,
name: &'static CStr,
callback: gobject_ffi::GCallback,
data: *mut c_void,
) {
unsafe {
gobject_ffi::g_signal_connect_data(
session.cast(),
name.as_ptr(),
callback,
data,
None,
gobject_ffi::G_CONNECT_DEFAULT,
);
}
}
fn run_session(
api: &Api,
uid: u32,
cookie: &CString,
context: &glib::MainContext,
writer: &mut BufWriter<ChildStdin>,
input: &mpsc::Receiver<PromptEvent>,
cancelled: &AtomicBool,
child: &mut Child,
) -> Result<Option<bool>, String> {
let identity = unsafe { (api.user_new)(uid as c_int) };
if identity.is_null() {
return Err("Could not create polkit identity".into());
}
let session = unsafe { (api.session_new)(identity, cookie.as_ptr()) };
unsafe { gobject_ffi::g_object_unref(identity.cast()) };
if session.is_null() {
return Err("Could not create polkit session".into());
}
let (tx, rx) = mpsc::channel();
let signal_data = Box::into_raw(Box::new(tx)).cast();
unsafe {
connect(
session,
c"request",
Some(std::mem::transmute(
request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void),
)),
signal_data,
);
connect(
session,
c"show-info",
Some(std::mem::transmute(
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
)),
signal_data,
);
connect(
session,
c"show-error",
Some(std::mem::transmute(
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
)),
signal_data,
);
connect(
session,
c"completed",
Some(std::mem::transmute(
completed as unsafe extern "C" fn(Object, c_int, *mut c_void),
)),
signal_data,
);
(api.initiate)(session);
}
let mut outcome = None;
let mut stopped = false;
while outcome.is_none() {
while context.pending() {
context.iteration(false);
}
while let Ok(event) = rx.try_recv() {
match event {
SessionEvent::Request(prompt, echo) => {
send(writer, &PromptEvent::Request { prompt, echo })?;
}
SessionEvent::Info(text) => send(writer, &PromptEvent::Info { text })?,
SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?,
SessionEvent::Completed(success) => outcome = Some(success),
}
}
if outcome.is_some() {
break;
}
while let Ok(event) = input.try_recv() {
match event {
PromptEvent::Response { mut value } => {
if let Ok(response) = CString::new(value.as_str()) {
unsafe { (api.response)(session, response.as_ptr()) };
}
value.zeroize();
}
PromptEvent::Cancel => cancelled.store(true, Ordering::Release),
_ => {}
}
}
if !stopped
&& (cancelled.load(Ordering::Acquire)
|| child.try_wait().map_err(|e| e.to_string())?.is_some())
{
unsafe { (api.cancel)(session) };
stopped = true;
}
std::thread::sleep(Duration::from_millis(10));
}
unsafe {
gobject_ffi::g_object_unref(session.cast());
drop(Box::from_raw(signal_data as *mut Sender<SessionEvent>));
}
Ok(outcome.filter(|_| !stopped))
}
pub fn authenticate(
uid: u32,
message: String,
cookie: String,
cancelled: Arc<AtomicBool>,
) -> Result<bool, String> {
let api = Api::load()?;
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
let context = glib::MainContext::new();
context
.with_thread_default(|| {
let (mut child, mut writer, input) = prompt_process(message, username(uid))?;
let result = loop {
match run_session(
&api,
uid,
&cookie,
&context,
&mut writer,
&input,
&cancelled,
&mut child,
) {
Ok(Some(true)) => break Ok(true),
Ok(Some(false)) if !cancelled.load(Ordering::Acquire) => {
if let Err(error) = send(
&mut writer,
&PromptEvent::Error {
text: "Password not accepted. Try again.".into(),
},
) {
break Err(error);
}
}
Ok(_) => break Ok(false),
Err(error) => break Err(error),
}
};
let _ = child.kill();
let _ = child.wait();
result
})
.map_err(|error| error.to_string())?
}
+11 -11
View File
@@ -623,6 +623,17 @@ pub fn fields(page: Page) -> Vec<Field> {
"solid",
&[("solid", "Solid"), ("translucent", "Translucent")],
),
range(
"theme.material.opacity",
"Shell opacity",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
ferese_config::DEFAULT_MATERIAL_OPACITY,
0.0,
1.0,
0.01,
"",
false,
),
range(
"theme.material.blur_radius",
"Background blur",
@@ -727,17 +738,6 @@ pub fn fields(page: Page) -> Vec<Field> {
" px",
true,
),
range(
"theme.surface.bar.opacity",
"Opacity",
"Background opacity for translucent bars, with or without blur. Text and icons stay opaque.",
0.78,
0.0,
1.0,
0.01,
"",
false,
),
range(
"theme.geometry.top_bar_window_gap",
"Window clearance",
+14 -1
View File
@@ -151,6 +151,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
} else {
(background, p.text)
};
button::Style {
background: Some(Background::Color(background)),
text_color: Some(on),
@@ -196,6 +197,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
.text_tint(cosmic_color(palette.text).color)
.accent(ferese_theme::accent_color(palette.accent, palette.card))
.build();
ferese_theme::apply(&mut theme, palette.text);
cosmic::Theme::custom(std::sync::Arc::new(theme))
}
@@ -204,16 +206,20 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let family = snapshot.string("theme.typography.font_family", "Inter");
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(&family) {
return *font;
}
if fonts.len() >= 32 {
return cosmic::font::default();
}
let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str()));
fonts.insert(family, font);
font
}
@@ -331,6 +337,7 @@ pub fn preset(index: usize) -> Vec<Edit> {
let Some(preset) = PRESETS.get(index) else {
return Vec::new();
};
vec![
set("theme.colors.accent", preset.accent),
set("theme.colors.surface_base", preset.base),
@@ -351,6 +358,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let Some(preset) = PRESETS.get(index) else {
return false;
};
[
("theme.colors.accent", preset.accent, "#3D7BE6"),
("theme.colors.surface_base", preset.base, "#111821"),
@@ -442,7 +450,11 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = snapshot.number("theme.surface.bar.opacity", 0.78);
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY).clamp(0., 1.)
} else {
1.
};
let right = 340. + gap / 2.;
let left_width = 288. - gap / 2.;
let svg = format!(
@@ -465,6 +477,7 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
bar_y + bar_height / 2.,
right + 16.
);
svg_icon::from_svg_bytes(svg.into_bytes())
.symbolic(false)
.icon()
+3
View File
@@ -6,6 +6,7 @@ use std::{path::PathBuf, time::Duration};
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let path = path.clone();
cosmic::iced::stream::channel(1, async move |mut output| {
let (send, mut events) = tokio::sync::mpsc::channel(1);
let target = path.clone();
@@ -26,6 +27,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
return;
}
};
if let Err(error) = watcher.watch(
path.parent().unwrap_or(std::path::Path::new(".")),
RecursiveMode::NonRecursive,
@@ -35,6 +37,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
.await;
return;
}
// Read once after installing the watch to cover the startup race.
loop {
let file = path.clone();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/><path d="M8 14h2m4 0h2m-8 4h2m4 0h2"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M7 12h10"/></svg>

After

Width:  |  Height:  |  Size: 226 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v9"/><path d="M7 5.5a9 9 0 1 0 10 0"/></svg>

After

Width:  |  Height:  |  Size: 219 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 12 18 6"/><circle cx="12" cy="12" r="1"/></svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 4c-8 0-14 3-14 10a6 6 0 0 0 6 6c7 0 10-6 8-16Z"/><path d="M4 21c2-5 6-8 12-11"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

+34 -40
View File
@@ -62,11 +62,11 @@ pub(crate) struct ShellTheme {
impl Default for ShellTheme {
fn default() -> Self {
Self {
bar_background: [28, 32, 46, 242],
bar_background: [28, 32, 46, 255],
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_popover: [17, 24, 33, 245],
surface_popover: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
accent: [61, 123, 230, 255],
@@ -148,6 +148,8 @@ struct AppearanceConfig {
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
material: MaterialConfig,
#[serde(default)]
surface: SurfaceConfig,
#[serde(default)]
@@ -166,16 +168,12 @@ struct ThemeConfig {
struct SurfaceConfig {
#[serde(default)]
bar: BarConfig,
#[serde(default)]
popover: PopoverConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct BarConfig {
background: String,
// If omitted, preserve the alpha supplied in `background`.
opacity: Option<f32>,
text_primary: String,
text_muted: String,
}
@@ -184,29 +182,17 @@ impl Default for BarConfig {
fn default() -> Self {
Self {
background: "#1C202EF2".to_owned(),
opacity: None,
text_primary: "#F0F3FA".to_owned(),
text_muted: "#AAB4C7".to_owned(),
}
}
}
#[derive(Debug, Deserialize)]
struct PopoverConfig {
#[serde(default = "default_popover_opacity")]
opacity: f32,
}
impl Default for PopoverConfig {
fn default() -> Self {
Self {
opacity: default_popover_opacity(),
}
}
}
const fn default_popover_opacity() -> f32 {
0.96
#[derive(Debug, Default, Deserialize)]
struct MaterialConfig {
#[serde(default)]
style: String,
opacity: Option<f32>,
}
#[derive(Debug, Default, Deserialize)]
@@ -364,17 +350,26 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
let opacity = if theme.material.style == "translucent" {
finite_or(
theme
.material
.opacity
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY as f32),
ferese_config::DEFAULT_MATERIAL_OPACITY as f32,
)
.clamp(0.0, 1.0)
} else {
1.0
};
let alpha = (opacity * 255.0).round() as u8;
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
if let Some(opacity) = theme.surface.bar.opacity {
background[3] =
(finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0)
.round() as u8;
}
background[3] = alpha;
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
@@ -385,10 +380,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity())
.clamp(0.0, 1.0)
* 255.0)
.round() as u8;
color[3] = alpha;
color
},
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
@@ -617,7 +609,7 @@ theme {
assert_eq!(theme.bar_height, 28.0);
assert_eq!(theme.bar_margin_horizontal, 0);
assert_eq!(theme.bar_radius, 14.0);
assert_eq!(theme.bar_background, ShellTheme::default().bar_background);
assert_eq!(theme.bar_background[3], 255);
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
assert_ne!(theme.for_bar().text_primary, theme.text_primary);
}
@@ -638,21 +630,17 @@ theme {
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background, [234, 236, 238, 221]);
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
}
#[test]
fn bar_transparency_and_shadow_opacity_are_independent() {
fn shell_opacity_is_shared_and_solid_remains_opaque() {
let config: FereseConfig = ferese_config::from_str(
r#"theme {
surface {
bar {
opacity 0.6
}
}
material { style "translucent"; opacity 0.6; }
shadow {
soft {
opacity 0.07
@@ -664,6 +652,12 @@ theme {
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background[3], 153);
assert_eq!(theme.surface_popover[3], 153);
assert_eq!(theme.shadow_opacity, 0.07);
let mut config = config;
config.theme.material.style = "solid".into();
let solid = shell_theme(&config.theme);
assert_eq!(solid.bar_background[3], 255);
assert_eq!(solid.surface_popover[3], 255);
}
}
+136 -3
View File
@@ -59,12 +59,15 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let family = Box::leak(family.to_owned().into_boxed_str());
let font = cosmic::font::Font::with_name(family);
fonts.insert(family.to_owned(), font);
@@ -113,14 +116,17 @@ impl From<ShellTheme> for BarMetrics {
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
let mut parts = label.split_whitespace();
let time = parts.next()?;
if let Some(period) = parts.next() {
if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") {
return None;
}
}
if parts.next().is_some() {
return None;
}
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
@@ -131,6 +137,7 @@ fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
{
return None;
}
Some([format!("{hour:0>2}"), minute.to_owned()])
}
@@ -142,6 +149,7 @@ fn main() -> cosmic::iced::Result {
// toolkit threads are started.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
@@ -154,6 +162,7 @@ fn main() -> cosmic::iced::Result {
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Cantarell-ExtraBold.otf"
)));
let mut config = config::load();
motion::configure(config.animations, config.theme.material_radius);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
@@ -207,6 +216,7 @@ struct FereseShell {
status_service: status::Service,
status: status::Snapshot,
recorder: recording::Recorder,
calendar_offset: i32,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
note_editor: Option<DesktopNoteEditor>,
@@ -284,6 +294,8 @@ enum Message {
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
CalendarMonth(i32),
CalendarToday,
}
impl cosmic::Application for FereseShell {
@@ -316,6 +328,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
calendar_offset: 0,
status_error: None,
menu: None,
note_editor: None,
@@ -348,6 +361,7 @@ impl cosmic::Application for FereseShell {
}),
None => Task::none(),
};
(app, wallpaper_task)
}
@@ -420,22 +434,28 @@ impl cosmic::Application for FereseShell {
if let Err(error) = effects.set_opacity(menu.progress()) {
eprintln!("ferese-shell: could not update popup opacity: {error}");
}
let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let regions = surface.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update notification materials: {error}");
}
}
match message {
Message::BeginNoteEdit(id) => {
let mut tasks = vec![self.finish_note_edit()];
if let Some(note) = self
.config
.desktop_widgets
@@ -459,6 +479,7 @@ impl cosmic::Application for FereseShell {
{
let edited = action.is_edit();
editor.content.perform(action);
if edited {
editor.revision = editor.revision.wrapping_add(1);
let revision = editor.revision;
@@ -525,6 +546,7 @@ impl cosmic::Application for FereseShell {
),
}
}
if let Some(menu) = &mut self.menu
&& menu.id == id
&& menu.effects.is_none()
@@ -539,6 +561,7 @@ impl cosmic::Application for FereseShell {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
@@ -566,6 +589,7 @@ impl cosmic::Application for FereseShell {
self.status_error = update.error;
}
self.notifications.tick();
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
count: self.notifications.unread(),
@@ -636,6 +660,14 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::CalendarMonth(delta) => {
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
Task::none()
}
Message::CalendarToday => {
self.calendar_offset = 0;
Task::none()
}
Message::CancelPower => {
if let Some(menu) = &mut self.menu {
menu.confirm = None;
@@ -643,6 +675,18 @@ impl cosmic::Application for FereseShell {
Task::none()
}
Message::Control(action) => {
if let status::Action::PowerProfile(profile) = action {
self.status_error = self
.status_service
.send(status::Action::PowerProfile(profile))
.err();
if self.status_error.is_none() {
if let Some(profiles) = &mut self.status.power_profiles {
profiles.active = profile.to_owned();
}
}
return Task::none();
}
if self.notifications.ready {
match action {
status::Action::Dnd(value) => {
@@ -774,9 +818,11 @@ impl FereseShell {
return Task::none();
}
};
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
config.wallpaper.path = None;
}
if self.config.font_family != config.font_family {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
@@ -784,6 +830,7 @@ impl FereseShell {
.unwrap() = configured_font(config.font_family.as_deref());
}
motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations
&& let Some(menu) = &mut self.menu
{
@@ -791,6 +838,7 @@ impl FereseShell {
}
self.status_service
.update_settings(config.status.settings_command.clone());
let old = self.config.theme;
let old_clock = &self.config.desktop_widgets.clock;
let old_notes = &self.config.desktop_widgets.notes;
@@ -826,6 +874,7 @@ impl FereseShell {
} else {
Task::none()
}];
if clock_changed
&& self
.note_drag
@@ -834,6 +883,7 @@ impl FereseShell {
{
tasks.push(self.finish_note_drag(false));
}
if notes_changed {
tasks.push(self.finish_note_drag(false));
if self.note_editor.as_ref().is_some_and(|editor| {
@@ -848,6 +898,7 @@ impl FereseShell {
}
tasks.push(self.rebuild_notes(true));
}
for entry in self.outputs.iter().filter(|_| geometry_changed) {
tasks.push(set_size(
entry.bar,
@@ -866,6 +917,7 @@ impl FereseShell {
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
));
}
Task::batch(tasks)
}
@@ -882,6 +934,7 @@ impl FereseShell {
} else {
Task::none()
};
let mut tasks = vec![destroy_layer_surface(entry.bar), menu];
if self.note_drag.as_ref().is_some_and(|drag| {
entry.clock == Some(drag.source)
@@ -889,20 +942,26 @@ impl FereseShell {
}) {
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,
@@ -913,20 +972,24 @@ impl FereseShell {
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;
@@ -1005,6 +1068,7 @@ impl FereseShell {
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() {
surfaces.push(wallpaper_action);
}
let tasks = surfaces
.into_iter()
.map(cosmic::Action::Surface)
@@ -1021,6 +1085,7 @@ impl FereseShell {
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();
@@ -1109,6 +1174,7 @@ impl FereseShell {
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,
@@ -1122,6 +1188,7 @@ impl FereseShell {
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
));
}
for note in &self.config.desktop_widgets.notes {
if Some(&note.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
obstacles.push(cosmic::iced::Rectangle::new(
@@ -1130,6 +1197,7 @@ impl FereseShell {
));
}
}
let overlay = window::Id::unique();
self.note_drag = Some(NoteDrag {
id,
@@ -1161,8 +1229,10 @@ impl FereseShell {
size_limits: Limits::NONE,
..Default::default()
},
Some(Box::new(Self::view_note_drag)),
);
Task::batch([
self.finish_note_edit(),
cosmic::task::message(cosmic::Action::Surface(action)),
@@ -1173,6 +1243,7 @@ impl FereseShell {
let Some(drag) = &self.note_drag else {
return text("").into();
};
if let Some(id) = &drag.id {
self.view_note_content(id, drag.source)
} else {
@@ -1185,6 +1256,7 @@ impl FereseShell {
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;
@@ -1202,6 +1274,7 @@ impl FereseShell {
clock.margin_y,
));
}
if let Some(note) = self
.config
.desktop_widgets
@@ -1212,6 +1285,7 @@ impl FereseShell {
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
@@ -1222,12 +1296,14 @@ impl FereseShell {
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)
@@ -1235,6 +1311,7 @@ impl FereseShell {
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(..) {
@@ -1242,12 +1319,14 @@ impl FereseShell {
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 == &note.id)
{
continue;
}
let id = window::Id::unique();
entry.notes.push((note.id.clone(), id));
let note = note.clone();
@@ -1281,11 +1360,14 @@ impl FereseShell {
..Default::default()
}
},
Some(Box::new(move |app| app.view_note(&note_id, id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
}
Task::batch(tasks)
}
@@ -1300,6 +1382,7 @@ impl FereseShell {
.height(Length::Fill)
.into();
}
self.view_note_content(id, surface)
}
@@ -1334,7 +1417,9 @@ impl FereseShell {
.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 {
@@ -1350,6 +1435,7 @@ impl FereseShell {
let mut body = cosmic::widget::column([])
.spacing(note.gap)
.align_x(alignment);
if note.interactive {
let title = cosmic::widget::text(if note.title.is_empty() {
"⋮⋮"
@@ -1369,6 +1455,7 @@ impl FereseShell {
.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" }))
@@ -1396,6 +1483,7 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
if let Some(editor) = &self.note_editor
&& editor.id == id
{
@@ -1425,6 +1513,7 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
if let Some(error) = &self.note_error {
body = body.push(
text(error.clone())
@@ -1432,7 +1521,9 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
let radius = self.config.theme.material_radius;
container(body)
.padding(note.padding)
.width(Length::Fill)
@@ -1456,18 +1547,23 @@ impl FereseShell {
.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();
@@ -1490,10 +1586,13 @@ impl FereseShell {
..Default::default()
}
},
Some(Box::new(move |app| app.view_desktop_clock(id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
Task::batch(tasks)
}
@@ -1508,6 +1607,7 @@ impl FereseShell {
.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)
@@ -1602,6 +1702,7 @@ impl FereseShell {
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(
cosmic::widget::text(digit.to_string())
@@ -1611,8 +1712,10 @@ impl FereseShell {
.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;
@@ -1629,6 +1732,7 @@ impl FereseShell {
.time_size
.min(available_width / (characters * 0.75))
.min(available_height / 1.3);
labels = labels.push(
cosmic::widget::text(self.desktop_clock.0.clone())
.font(font)
@@ -1662,6 +1766,7 @@ impl FereseShell {
Background::Color(tint)
});
let radius = self.config.theme.material_radius;
container(labels)
.padding(clock.padding)
.width(Length::Fill)
@@ -1706,9 +1811,11 @@ impl FereseShell {
}) {
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
{
@@ -1724,6 +1831,7 @@ impl FereseShell {
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,
@@ -1748,6 +1856,7 @@ impl FereseShell {
}
}
}
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => {
if self
.note_drag
@@ -1770,13 +1879,16 @@ impl FereseShell {
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,
@@ -1800,6 +1912,7 @@ impl FereseShell {
let _ = effects.set_opacity(menu.progress());
}
}
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
}
@@ -1978,6 +2091,7 @@ impl FereseShell {
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
@@ -2040,9 +2154,28 @@ impl FereseShell {
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
let clock = container(
row![
text(date)
.size(12)
.class(theme::Text::Color(color(shell_theme.text_muted))),
motion::button(
button::custom(text(date).size(12))
.padding([4, 2])
.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,
},
))
}),
color(shell_theme.text_muted),
self.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
1.0
),
text(time)
.size(bar.text_size)
.class(theme::Text::Color(foreground)),
+8
View File
@@ -48,6 +48,7 @@ impl Recorder {
pub fn busy(&self) -> bool {
self.updates.is_some()
}
pub fn elapsed(&self) -> Option<String> {
let State::Recording(start) = self.state else {
return None;
@@ -55,15 +56,18 @@ impl Recorder {
let seconds = start.elapsed().as_secs();
Some(format!("{:02}:{:02}", seconds / 60, seconds % 60))
}
pub fn start(&mut self) {
if self.busy() {
return;
}
let program = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("ferese-record")))
.filter(|p| p.is_file())
.unwrap_or_else(|| PathBuf::from("ferese-record"));
match Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -92,6 +96,7 @@ impl Recorder {
}
}
}
let _ = child.wait();
let _ = send.send(Event::Exited);
});
@@ -103,6 +108,7 @@ impl Recorder {
}
}
}
pub fn stop(&mut self) {
if self.busy() && !matches!(self.state, State::Saving) {
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
@@ -110,6 +116,7 @@ impl Recorder {
self.state = State::Saving;
}
}
pub fn poll(&mut self) {
let mut done = false;
if let Some(updates) = &self.updates {
@@ -146,6 +153,7 @@ impl Recorder {
}
}
}
if done {
match &self.state {
State::Saved(path) => notify("Recording saved", path.display().to_string()),
+94
View File
@@ -21,6 +21,7 @@ pub struct Snapshot {
pub bluetooth: Option<Bluetooth>,
pub audio: Option<Audio>,
pub battery: Option<Battery>,
pub power_profiles: Option<PowerProfiles>,
pub brightness: Option<u8>,
pub notifications: Option<Notifications>,
pub poweroff: bool,
@@ -54,6 +55,12 @@ pub struct Battery {
pub status: String,
}
#[derive(Clone, Debug)]
pub struct PowerProfiles {
pub active: String,
pub available: [bool; 3],
}
#[derive(Clone, Debug)]
pub struct Notifications {
pub count: u32,
@@ -73,6 +80,7 @@ pub enum Action {
Poweroff,
Reboot,
Suspend,
PowerProfile(&'static str),
}
impl Action {
@@ -143,11 +151,13 @@ impl Service {
Condvar::new(),
));
let polling = shared.clone();
thread::spawn(move || {
let mut system_bus = StatusBus::new(true);
let mut session_bus = StatusBus::new(false);
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager");
let mut bluetooth = dbus_cache::Cache::new("org.bluez");
loop {
// A poll overlapping a write is discarded below. Wait for the
// write to finish before retrying instead of launching commands
@@ -159,9 +169,11 @@ impl Service {
snapshot.network = network.read(before, network::read);
snapshot.bluetooth = bluetooth.read(before, bluetooth::read);
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
if state.0 == before && !state.1 {
updates.send_replace(Some(Update {
snapshot,
@@ -172,6 +184,7 @@ impl Service {
}
}
});
thread::spawn(move || {
loop {
let first = match commands.recv() {
@@ -200,6 +213,7 @@ impl Service {
shared.0.lock().unwrap().3 = true;
shared.1.notify_one();
});
Self {
settings: live_settings,
tx,
@@ -214,6 +228,7 @@ impl Service {
.try_send((generation, action))
.map_err(|_| "Controls are busy; please try again".to_owned())?;
self.generation = generation;
Ok(())
}
@@ -228,13 +243,16 @@ impl Service {
pub fn available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |p: &Path| {
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
if program.contains('/') {
return executable(Path::new(program));
}
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
@@ -243,6 +261,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
if !available(program) {
return Err(format!("{program} is not installed"));
}
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
let output = Command::new("timeout")
.args(["--kill-after=1s", "2s", program])
@@ -251,6 +270,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
.stdin(Stdio::null())
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr)
.trim()
@@ -263,6 +283,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
message
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
@@ -291,12 +312,14 @@ impl StatusBus {
} else {
zbus::blocking::connection::Builder::session()
};
self.connection = builder
.ok()?
.method_timeout(Duration::from_secs(2))
.build()
.ok();
}
match query(self.connection.as_ref()?) {
Ok(value) => Some(value),
Err(_) => {
@@ -346,6 +369,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot {
bluetooth: None,
audio: audio(),
battery: battery(),
power_profiles: power_profiles(system_bus),
brightness: brightness(),
notifications: notifications(session_bus),
poweroff: system_bus.can_power("CanPowerOff"),
@@ -361,9 +385,11 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
.next()?
.parse::<f32>()
.ok()?;
if !volume.is_finite() || volume < 0.0 {
return None;
}
Some((
(volume * 100.0).round().clamp(0.0, 100.0) as u8,
value.contains("[MUTED]"),
@@ -379,6 +405,7 @@ fn audio() -> Option<Audio> {
.find_map(|line| line.trim().strip_prefix("node.description = "))
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio {
volume,
muted,
@@ -403,6 +430,7 @@ fn battery() -> Option<Battery> {
&& read(p, "scope").as_deref() != Some("Device")
})
.collect::<Vec<_>>();
batteries.sort();
batteries.iter().find_map(|path| {
Some(Battery {
@@ -412,6 +440,46 @@ fn battery() -> Option<Battery> {
})
}
fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
bus.query(|connection| {
read_power_profiles(
connection,
"org.freedesktop.UPower.PowerProfiles",
"/org/freedesktop/UPower/PowerProfiles",
)
.or_else(|_| {
read_power_profiles(
connection,
"net.hadess.PowerProfiles",
"/net/hadess/PowerProfiles",
)
})
})
}
fn read_power_profiles(
connection: &zbus::blocking::Connection,
destination: &str,
path: &str,
) -> zbus::Result<PowerProfiles> {
let proxy = zbus::blocking::Proxy::new(connection, destination, path, destination)?;
let active = proxy.get_property::<String>("ActiveProfile")?;
let profiles = proxy
.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>(
"Profiles",
)?;
let names = ["power-saver", "balanced", "performance"];
let available = names.map(|name| {
profiles.iter().any(|profile| {
profile
.get("Profile")
.and_then(|value| String::try_from(value.clone()).ok())
.is_some_and(|value| value == name)
})
});
Ok(PowerProfiles { active, available })
}
fn brightness() -> Option<u8> {
parse_brightness(
&run(
@@ -490,6 +558,12 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
&[if *on { "--dnd-on" } else { "--dnd-off" }],
),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::PowerProfile(profile)
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
{
run("powerprofilesctl", &["set", profile])
}
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
Action::Poweroff => run("systemctl", &["poweroff"]),
Action::Reboot => run("systemctl", &["reboot"]),
Action::Suspend => run("systemctl", &["suspend"]),
@@ -523,8 +597,10 @@ mod tests {
pub(super) struct TestBus {
daemon: std::process::Child,
pub(super) address: String,
}
impl TestBus {
pub(super) fn new() -> Self {
use std::io::{BufRead, BufReader};
@@ -534,14 +610,17 @@ mod tests {
.spawn()
.unwrap();
let mut address = String::new();
BufReader::new(daemon.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
Self {
daemon,
address: address.trim().to_owned(),
}
}
pub(super) fn connect(&self) -> zbus::blocking::Connection {
zbus::blocking::connection::Builder::address(self.address.as_str())
.unwrap()
@@ -550,6 +629,7 @@ mod tests {
.unwrap()
}
}
impl Drop for TestBus {
fn drop(&mut self) {
let _ = self.daemon.kill();
@@ -557,6 +637,20 @@ mod tests {
}
}
#[test]
#[ignore = "requires a running Power Profiles D-Bus service"]
fn reads_available_power_profiles_from_system_bus() {
let mut bus = StatusBus::new(true);
let profiles = power_profiles(&mut bus).expect("Power Profiles service is available");
let names = ["power-saver", "balanced", "performance"];
assert!(
names
.iter()
.zip(profiles.available)
.any(|(name, available)| { available && profiles.active == *name })
);
}
struct MockLogin1(Arc<std::sync::atomic::AtomicU8>);
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
+255 -5
View File
@@ -16,6 +16,7 @@ pub enum Menu {
Bluetooth,
Audio,
Battery,
Calendar,
Recording,
Notifications,
System,
@@ -25,7 +26,8 @@ impl Menu {
fn width(self) -> f32 {
match self {
Self::System => 360.0,
Self::Battery => 280.0,
Self::Battery => 328.0,
Self::Calendar => 310.0,
Self::Notifications => 368.0,
_ => 300.0,
}
@@ -42,6 +44,7 @@ impl Menu {
Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound",
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::Notifications => "Notifications",
}
@@ -53,8 +56,9 @@ impl Menu {
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording => true,
Self::Notifications => status.notifications.is_some(),
Self::System | Self::Recording => true,
Self::System => true,
}
}
}
@@ -89,6 +93,9 @@ impl FereseShell {
}
return self.close_menu();
}
if kind == Menu::Calendar {
self.calendar_offset = 0;
}
let destroy = self.destroy_menu();
if !kind.available(&self.status) {
return destroy;
@@ -451,6 +458,8 @@ impl FereseShell {
]
.spacing(8),
);
} else if kind == Menu::Calendar {
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
} else {
let combined = menu.kind == Menu::System;
if combined {
@@ -717,6 +726,80 @@ impl FereseShell {
);
}
}
if menu.kind == Menu::Battery {
rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted)));
if let Some(profiles) = &self.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
(
"Battery saver",
include_bytes!("../assets/icons/status/power-saver.svg").as_slice(),
"power-saver",
),
(
"Balanced",
include_bytes!("../assets/icons/status/power-balanced.svg").as_slice(),
"balanced",
),
(
"Performance",
include_bytes!("../assets/icons/status/power-performance.svg")
.as_slice(),
"performance",
),
]
.into_iter()
.enumerate()
{
if profiles.available[index] {
modes = modes.push(power_tile(
label,
icon,
profile,
profiles.active == profile,
theme,
p,
));
}
}
rows = rows.push(modes);
} else {
rows = rows.push(
text("Power Profiles service unavailable")
.size(12)
.class(theme::Text::Color(muted)),
);
}
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
for (enabled, label, icon, action) in [
(
self.status.reboot,
"Restart",
include_bytes!("../assets/icons/status/restart.svg").as_slice(),
Action::Reboot,
),
(
self.status.poweroff,
"Power off",
include_bytes!("../assets/icons/status/power-off.svg").as_slice(),
Action::Poweroff,
),
(
self.status.suspend,
"Suspend",
include_bytes!("../assets/icons/status/suspend.svg").as_slice(),
Action::Suspend,
),
] {
if enabled {
power = power.push(power_tile_action(label, icon, action, theme, p));
}
}
if self.status.reboot || self.status.poweroff || self.status.suspend {
rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted)));
rows = rows.push(power);
}
}
if combined {
let mut actions = column::with_capacity(2).spacing(8);
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
@@ -1002,6 +1085,155 @@ fn connection_control<'a>(
.into()
}
fn power_tile<'a>(
label: &'static str,
icon: &'static [u8],
profile: &'static str,
selected: bool,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(
if selected {
theme.accent
} else {
theme.text_primary
},
opacity,
);
let tile = column![bar_icon(icon, 19, foreground), text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center);
motion::button(
button::custom(tile)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
profile,
)))),
foreground,
selected,
opacity,
)
}
fn power_tile_action<'a>(
label: &'static str,
icon: &'static [u8],
action: Action,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(theme.text_primary, opacity);
motion::button(
button::custom(
column![bar_icon(icon, 19, foreground), text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::ConfirmPower(action))),
foreground,
false,
opacity,
)
}
fn calendar_month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) {
let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
let year = month_index.div_euclid(12) as i16;
let month = (month_index.rem_euclid(12) + 1) as i8;
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0);
let days = usize::try_from(first.days_in_month()).unwrap_or(31);
(first, start, days)
}
fn calendar_grid<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let today = Zoned::now().date();
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let (first, start, days) = calendar_month(today, offset);
let year = first.year();
let month = first.month();
let primary = color_with_opacity(theme.text_primary, opacity);
let muted = color_with_opacity(theme.text_muted, opacity);
let header = row![
text(format!("{} {year}", months[(month - 1) as usize]))
.size(15)
.width(Length::Fill),
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
menu_button("Today", Message::CalendarToday, primary, opacity),
menu_button("›", Message::CalendarMonth(1), primary, opacity),
]
.align_y(Alignment::Center)
.spacing(2);
let mut grid = column::with_capacity(7).spacing(2).push(header);
let mut weekdays = row::with_capacity(7).spacing(2);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(text(label).size(11).class(theme::Text::Color(muted)))
.width(Length::FillPortion(1))
.center_x(Length::Fill),
);
}
grid = grid.push(weekdays);
for week in 0..(start + days).div_ceil(7) {
let mut dates = row::with_capacity(7).spacing(2);
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(text(label).size(12).class(theme::Text::Color(if is_today {
primary
} else {
muted
})))
.width(Length::FillPortion(1))
.height(27)
.center_x(Length::Fill)
.center_y(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: is_today.then_some(Background::Color(color_with_opacity(
theme.accent,
0.2 * opacity,
))),
border: Border {
radius: motion::radius(12.0).into(),
..Default::default()
},
..Default::default()
})),
);
}
grid = grid.push(dates);
}
grid.into()
}
fn menu_button<'a>(
label: &'a str,
message: Message,
@@ -1146,11 +1378,12 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
Some(n) => format!("Notifications: {} unread", n.count),
_ => "Notifications: unavailable".into(),
},
Menu::Calendar => "Calendar".into(),
Menu::Recording => "Screen recording".into(),
Menu::Battery => s.battery.as_ref().map_or_else(
|| "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status),
),
Menu::Recording => "Screen recording".into(),
Menu::System => "Control Center".into(),
}
}
@@ -1203,6 +1436,8 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
Menu::Audio => s.audio.as_ref().map_or((audio_icon(0, true), false), |a| {
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
}),
Menu::Calendar => (include_bytes!("../assets/icons/status/calendar.svg"), true),
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::Battery => {
let Some(b) = &s.battery else {
return (
@@ -1238,7 +1473,6 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
)
}
}
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::System => (
include_bytes!("../assets/icons/status/control-center.svg"),
true,
@@ -1248,12 +1482,28 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
#[cfg(test)]
mod tests {
#[test]
fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = calendar_month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = calendar_month(february, -2);
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = calendar_month(june, 0);
assert_eq!((start, days), (6, 30));
}
#[test]
fn smaller_popups_have_content_specific_widths() {
assert_eq!(Menu::System.width(), 360.0);
assert_eq!(Menu::Network.width(), 300.0);
assert_eq!(Menu::Bluetooth.width(), 300.0);
assert_eq!(Menu::Battery.width(), 280.0);
assert_eq!(Menu::Battery.width(), 328.0);
assert_eq!(Menu::Calendar.width(), 310.0);
assert_eq!(Menu::Audio.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 368.0);
}