Align recording dialog behavior and shell theme styling

This commit is contained in:
Samuel Onoja committed 2026-09-28 09:15:13 +01:00
1 parent 24e15f76cb
commit 8832a9bf2e
9 files changed
+263 -26

No files matched your search

+52 -4
View File
@@ -47,6 +47,32 @@ pub fn foreground(background: Color, preferred: Color) -> Color {
}
}
/// Prefer the theme's foreground for filled controls. A small shade adjustment
/// is less disruptive than flipping light text to black when selecting a row.
/// Very bright/dark accents still use a contrasting foreground rather than
/// changing the user's accent into a substantially different color.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=20 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
@@ -56,8 +82,7 @@ pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::pale
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let base = composite(component.base.into(), surface);
let on = foreground(base, preferred);
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
@@ -68,7 +93,11 @@ pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
base,
on,
));
component.selected = rgba(composite(component.selected.into(), surface));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
@@ -170,7 +199,7 @@ mod tests {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let on = foreground(fill, Color::from_rgb8(120, 130, 140));
let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
@@ -179,6 +208,25 @@ mod tests {
}
}
#[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
+3 -2
View File
@@ -146,8 +146,9 @@ these six visually distinct.
| `shadow` | `"#00000055"` | Window shadow color |
Ferese derives the text and icon color for filled accent controls from the
configured theme. It keeps the configured text color when it reaches a 4.5:1
contrast ratio; otherwise it chooses black or white. The same rule is shared by
configured theme. It keeps the configured text color and, when necessary, shades
the accent fill slightly to reach a 4.5:1 contrast ratio. If that would require a
large color change, it chooses black or white instead. The same rule is shared by
native dialogs, settings selections, and notification badges. Hover, pressed,
and disabled accent-button states use the same policy. Small filled controls
resolve accent transparency against their theme surface so wallpaper cannot
+8 -3
View File
@@ -27,10 +27,15 @@ may remain at the path shown in the error.
Use a Wayland screen-sharing or recording source in your application. Ferese
opens a display picker. Select a display and press **Share**. Nothing is selected
by default. Applications that request multiple displays can share up to four.
The picker uses your Ferese theme colors, font, and shell corner radius.
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.
A **screen sharing** window remains visible while sharing. Press **Stop sharing**
or close that window to revoke the session. Closing the requesting application,
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
windows accessible after display selection. The command-line recorder keeps the
sharing window when it is not launched by the shell. Press **Stop sharing** or
close the sharing window to revoke an ordinary application’s session. Closing the requesting application,
locking Ferese, disconnecting the selected display, or changing its capture size
also ends sharing. A new request needs fresh consent; selections are not saved.
Everything visible on a shared display, including notifications, is included.
+113 -8
View File
@@ -73,12 +73,80 @@ struct Session {
phase: Phase,
multiple: bool,
cursor: bool,
bar_controlled: bool,
cancel: Arc<Cancel>,
}
#[derive(Clone, Default)]
pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
// This private interface only transfers stop controls to the shell's own recorder.
// The ordinary portal picker and all session revocation rules still apply.
struct RecorderControl(Backend);
#[zbus::interface(name = "org.ferese.ScreenRecorder")]
impl RecorderControl {
async fn use_bar_controls(
&self,
session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
let sender = header
.sender()
.ok_or_else(|| error("Missing recorder caller"))?;
if !owns_session(sender.as_str(), session_handle.as_str()) {
return Err(zbus::fdo::Error::AccessDenied(
"Not your recording session".into(),
));
}
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let pid = dbus
.get_connection_unix_process_id(sender.clone().into())
.await?;
if !shell_recorder(pid) {
return Err(zbus::fdo::Error::AccessDenied(
"The shell must own the recording controls".into(),
));
}
let mut sessions = self.0.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.phase != Phase::Created {
return Err(error("Recording already configured"));
}
session.bar_controlled = true;
Ok(())
}
}
fn owns_session(sender: &str, path: &str) -> bool {
let Some(sender) = sender.strip_prefix(':') else {
return false;
};
path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_")))
}
fn shell_recorder(pid: u32) -> bool {
use std::os::unix::fs::MetadataExt;
let matches_binary = |pid: u32, name: &str| -> Option<bool> {
let expected = std::env::current_exe().ok()?.parent()?.join(name);
let expected = std::fs::metadata(expected).ok()?;
let actual = std::fs::metadata(format!("/proc/{pid}/exe")).ok()?;
Some(expected.dev() == actual.dev() && expected.ino() == actual.ino())
};
if matches_binary(pid, "ferese-record") != Some(true) {
return false;
}
let parent = std::fs::read_to_string(format!("/proc/{pid}/status"))
.ok()
.and_then(|status| {
status.lines().find_map(|line| {
line.strip_prefix("PPid:")
.and_then(|s| s.trim().parse::<u32>().ok())
})
});
parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true))
}
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
let sender = header
.sender()
@@ -199,6 +267,7 @@ impl Backend {
phase: Phase::Created,
multiple: false,
cursor: false,
bar_controlled: false,
cancel: Arc::default(),
},
);
@@ -257,7 +326,7 @@ impl Backend {
{
return Err(error("Invalid request path"));
}
let (multiple, cursor, cancel) = {
let (multiple, cursor, bar_controlled, cancel) = {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
@@ -266,7 +335,12 @@ impl Backend {
return Err(error("Select sources before starting a session"));
}
session.phase = Phase::Starting;
(session.multiple, session.cursor, session.cancel.clone())
(
session.multiple,
session.cursor,
session.bar_controlled,
session.cancel.clone(),
)
};
let inserted = connection
.object_server()
@@ -290,7 +364,7 @@ impl Backend {
}
let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, multiple, cursor, !bar_controlled)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
};
let _ = connection
.object_server()
@@ -414,7 +488,8 @@ async fn start_streams(
app: &str,
multiple: bool,
cursor: bool,
) -> Result<(Vec<Child>, Child, Vec<Ready>), StartError> {
show_indicator: bool,
) -> Result<(Vec<Child>, Option<Child>, Vec<Ready>), StartError> {
let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
})
@@ -469,13 +544,19 @@ async fn start_streams(
streams.push(ready);
children.push(child);
}
let indicator = picker(&Prompt {
let indicator = if show_indicator {
Some(
picker(&Prompt {
app: app.to_owned(),
sources: selected,
multiple,
indicator: true,
})
.await?;
.await?,
)
} else {
None
};
Ok((children, indicator, streams))
}
fn validate_selection(
@@ -501,10 +582,12 @@ fn validate_selection(
}
Ok(selected)
}
async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<Cancel>) {
async fn supervise(mut children: Vec<Child>, mut indicator: Option<Child>, cancel: Arc<Cancel>) {
loop {
if cancel.stopped.load(Ordering::SeqCst)
|| !matches!(indicator.try_wait(), Ok(None))
|| indicator
.as_mut()
.is_some_and(|child| !matches!(child.try_wait(), Ok(None)))
|| children
.iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None)))
@@ -517,13 +600,19 @@ async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<C
for child in &mut children {
let _ = child.kill().await;
}
if let Some(indicator) = &mut indicator {
let _ = indicator.kill().await;
}
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default();
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())?
.serve_at(
"/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()),
)?
.build()
.await?;
// Frontend death must revoke every stream, even if Session.Close never arrives.
@@ -557,6 +646,22 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
mod tests {
use super::*;
#[test]
fn bar_control_claims_are_bound_to_the_recorder_connection() {
assert!(owns_session(
":1.42",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!owns_session(
":1.4",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!owns_session(
":1.43",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!shell_recorder(std::process::id()));
}
#[test]
fn rejects_unimplemented_sources_and_cursor_modes() {
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
for bits in [0u32, 2, 3, 4] {
+61 -5
View File
@@ -27,6 +27,8 @@ struct Picker {
core: Core,
prompt: Prompt,
selected: Vec<usize>,
background: cosmic::iced::Color,
foreground: cosmic::iced::Color,
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
@@ -40,7 +42,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
return Err("No shareable displays".into());
}
let (theme, font) = appearance();
let (theme, font, background, foreground) = appearance();
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
cosmic::app::run::<Picker>(
Settings::default()
@@ -49,7 +51,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
.default_font(font)
.default_text_size(14.)
.is_daemon(false),
prompt,
(prompt, background, foreground),
)?;
Ok(())
@@ -57,7 +59,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default;
type Flags = Prompt;
type Flags = (Prompt, cosmic::iced::Color, cosmic::iced::Color);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare";
@@ -69,14 +71,20 @@ impl cosmic::Application for Picker {
&mut self.core
}
fn init(mut core: Core, prompt: Prompt) -> (Self, Task<Message>) {
fn init(
mut core: Core,
(prompt, background, foreground): Self::Flags,
) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
core.window.content_container = false;
// No preselected source: clicking Share must be a deliberate choice.
let mut app = Self {
core,
prompt,
selected: Vec::new(),
background,
foreground,
};
let title = if app.prompt.indicator {
"Ferese — screen sharing"
@@ -122,6 +130,14 @@ impl cosmic::Application for Picker {
Task::none()
}
fn style(&self) -> Option<cosmic::iced::theme::Style> {
Some(cosmic::iced::theme::Style {
background_color: self.background,
text_color: self.foreground,
icon_color: self.foreground,
})
}
fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() {
"this application"
@@ -234,7 +250,12 @@ fn glyph(source: &'static [u8], size: u16) -> icon::Icon {
.size(size)
}
fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
fn appearance() -> (
cosmic::Theme,
cosmic::font::Font,
cosmic::iced::Color,
cosmic::iced::Color,
) {
let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| ferese_config::Document::parse(&s).ok());
@@ -296,6 +317,15 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
.corner_radii(corners)
.build();
ferese_theme::apply(&mut native, foreground.into());
let mut material: cosmic::iced::Color = background.into();
material.a = material_opacity(
&string("theme.material.style", "solid"),
document
.as_ref()
.and_then(|d| d.get("theme.surface.popover.opacity"))
.and_then(|v| v.as_f64())
.unwrap_or(0.96),
);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
let family: &'static str =
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
@@ -306,5 +336,31 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
material,
cosmic::iced::Color {
a: 1.,
..foreground.into()
},
)
}
// Only the surface tint is translucent; control fills and foregrounds stay opaque.
fn material_opacity(style: &str, opacity: f64) -> f32 {
if style == "translucent" && opacity.is_finite() {
opacity.clamp(0., 1.) as f32
} else {
1.
}
}
#[cfg(test)]
mod tests {
#[test]
fn material_preserves_solid_and_bounds_translucent_opacity() {
assert_eq!(super::material_opacity("solid", 0.4), 1.);
assert_eq!(super::material_opacity("translucent", 0.4), 0.4);
assert_eq!(super::material_opacity("translucent", 2.), 1.);
assert_eq!(super::material_opacity("translucent", -1.), 0.);
assert_eq!(super::material_opacity("translucent", f64::NAN), 1.);
}
}
+15
View File
@@ -136,6 +136,21 @@ pub async fn run() -> Result<(), String> {
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
// Older/foreign backends keep their normal sharing indicator. Only Ferese
// can verify this helper's identity and that its parent owns a bar stop button.
let _ = tokio::time::timeout(Duration::from_secs(3), async {
let control = Proxy::new(
&connection,
"org.freedesktop.impl.portal.desktop.ferese",
"/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder",
)
.await?;
control
.call::<_, _, ()>("UseBarControls", &(handle.clone(),))
.await
})
.await;
let session = Proxy::new(
&connection,
DESKTOP,
+6
View File
@@ -126,6 +126,12 @@ class PortalTest(unittest.TestCase):
None, Gio.DBusCallFlags.NONE, 5000, None)
def create(token):
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
# An ordinary portal client cannot suppress its stop indicator.
with self.assertRaises(GLib.Error) as denied:
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder", "UseBarControls", GLib.Variant("(o)", (session,)),
None, Gio.DBusCallFlags.NONE, 5000, None)
self.assertIn("AccessDenied", str(denied.exception))
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
return session
def helpers():
+3 -3
View File
@@ -146,10 +146,10 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
} else {
p.card
};
let on = if selected {
ferese_theme::foreground(background, p.text)
let (background, on) = if selected {
ferese_theme::accent_pair(background, p.text)
} else {
p.text
(background, p.text)
};
button::Style {
background: Some(Background::Color(background)),
+2 -1
View File
@@ -341,7 +341,8 @@ impl FereseShell {
palette.surface_popover
}),
);
let badge_text = ferese_theme::foreground(badge_fill, color(palette.text_primary));
let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
text(count.to_string())
.size(10)