Streamline recording controls and compact the display picker

This commit is contained in:
Samuel Onoja committed 2026-09-28 08:45:50 +01:00
1 parent 6209d556ee
commit 299f60e671
11 files changed
+189 -105

No files matched your search

+4 -4
View File
@@ -7,19 +7,19 @@ saving files, or sending video; those tasks do not run inside the compositor.
## Record from the top bar ## Record from the top bar
1. Click the screen-recording icon in the top bar, then **Record display**. 1. Click the screen-recording icon in the top bar to open the display picker.
2. Choose a monitor in the native picker and click **Share**. 2. Choose a monitor in the native picker and click **Share**.
3. The icon changes to a stop square and shows elapsed time. Click it to stop. 3. The icon changes to a stop square and shows elapsed time. Click it to stop.
The recorder finishes the video before reporting it saved. Open the recording The recorder finishes the video before sending a notification with its saved
menu again to see its saved path. Videos are saved as VP8 WebM files under path. Videos are saved as VP8 WebM files under
`Videos/Ferese` (using your configured XDG Videos folder). Audio is off. `Videos/Ferese` (using your configured XDG Videos folder). Audio is off.
The icon uses the same theme accent, hover effect, and corner radius as the The icon uses the same theme accent, hover effect, and corner radius as the
other bar controls. other bar controls.
Encoding runs in a separate native `ferese-record` process using GStreamer. Encoding runs in a separate native `ferese-record` process using GStreamer.
The shell remains responsive while a file is recorded or finalized. If saving The shell remains responsive while a file is recorded or finalized. If saving
fails, an error appears in the recording menu; an unfinished `.webm.part` file fails, an error appears in the notification center; an unfinished `.webm.part` file
may remain at the path shown in the error. may remain at the path shown in the error.
## Start sharing ## Start sharing
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 12h14m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 200 B

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

After

Width:  |  Height:  |  Size: 276 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/><path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 271 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="3" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 136 B

+97 -51
View File
@@ -2,8 +2,8 @@ use crate::capture::Source;
use cosmic::{ use cosmic::{
ApplicationExt, Element, ApplicationExt, Element,
app::{Core, Settings, Task}, app::{Core, Settings, Task},
iced::Length, iced::{Alignment, Length},
widget::{button, column, container, row, text}, widget::{button, column, container, icon, row, scrollable, text},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{Read, Write}; use std::io::{Read, Write};
@@ -41,9 +41,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
let (theme, font) = appearance(); let (theme, font) = appearance();
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
cosmic::app::run::<Picker>( cosmic::app::run::<Picker>(
Settings::default() Settings::default()
.size(cosmic::iced::Size::new(480., 440.)) .size(cosmic::iced::Size::new(400., height))
.theme(theme) .theme(theme)
.default_font(font) .default_font(font)
.default_text_size(14.) .default_text_size(14.)
@@ -68,7 +69,9 @@ impl cosmic::Application for Picker {
&mut self.core &mut self.core
} }
fn init(core: Core, prompt: Prompt) -> (Self, Task<Message>) { fn init(mut core: Core, prompt: Prompt) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
// No preselected source: clicking Share must be a deliberate choice. // No preselected source: clicking Share must be a deliberate choice.
let mut app = Self { let mut app = Self {
core, core,
@@ -121,73 +124,116 @@ impl cosmic::Application for Picker {
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() { let app = if self.prompt.app.is_empty() {
"An application" "this application"
} else { } else {
&self.prompt.app &self.prompt.app
}; };
let mut content = column([]) let mut content = column([]).width(Length::Fill).spacing(10).push(
.spacing(16) row![
.push( glyph(include_bytes!("../assets/display.svg"), 24),
text(if self.prompt.indicator { column![
"Your screen is being shared" text(if self.prompt.indicator {
} else { "Screen sharing"
"Share your screen" } else {
}) "Choose a display"
.size(24), })
) .size(18),
.push(text(if self.prompt.indicator { text(if self.prompt.indicator {
format!("{app} can see the displays below.") format!("Shared with {app}")
} else { } else {
format!("{app} wants to see your screen. Choose what to share.") format!("Share with {app}")
})); })
.size(12),
]
.spacing(2)
.width(Length::Fill),
]
.spacing(12)
.align_y(Alignment::Center),
);
let mut sources = column([]).spacing(6);
for (index, source) in self.prompt.sources.iter().enumerate() { for (index, source) in self.prompt.sources.iter().enumerate() {
let label = format!( let selected = self.selected.contains(&index);
"{}{} — {} × {}", let mut entry = row![
if self.selected.contains(&index) { glyph(include_bytes!("../assets/display.svg"), 24),
"✓ " column![
} else { text(&source.name).size(14),
"" text(format!("{} × {}", source.width, source.height)).size(12)
}, ]
source.name, .spacing(3)
source.width, .width(Length::Fill),
source.height ]
); .spacing(12)
.align_y(Alignment::Center);
if selected || self.prompt.indicator {
entry = entry.push(glyph(include_bytes!("../assets/check.svg"), 20));
}
if self.prompt.indicator { if self.prompt.indicator {
content = content.push(text(label)); sources = sources.push(container(entry).padding(8).width(Length::Fill));
} else { } else {
content = content.push( sources = sources.push(
button::text(label) button::custom(entry)
.class(if selected {
cosmic::theme::Button::Suggested
} else {
cosmic::theme::Button::Standard
})
.padding(8)
.width(Length::Fill) .width(Length::Fill)
.on_press(Message::Select(index)), .on_press(Message::Select(index)),
); );
} }
} }
content = content.push(scrollable(sources).height(Length::Fixed(
content = content.push(text( (self.prompt.sources.len().min(4) * 56) as f32,
"Everything visible on a shared display, including notifications, may be seen.", )));
)); content = content.push(
text("Everything on this display, including notifications, is visible.")
.size(12)
.width(Length::Fill),
);
if self.prompt.indicator { if self.prompt.indicator {
content = content.push(button::destructive("Stop sharing").on_press(Message::Cancel)); content = content.push(
button::custom(
row![
glyph(include_bytes!("../assets/stop.svg"), 16),
text("Stop sharing")
]
.spacing(8)
.align_y(Alignment::Center),
)
.class(cosmic::theme::Button::Destructive)
.padding([6, 16])
.on_press(Message::Cancel),
);
} else { } else {
let mut share = button::suggested("Share"); let mut share = button::custom(
row![
text("Share"),
glyph(include_bytes!("../assets/arrow.svg"), 16)
]
.spacing(8)
.align_y(Alignment::Center),
)
.class(cosmic::theme::Button::Suggested)
.padding([6, 16]);
if !self.selected.is_empty() { if !self.selected.is_empty() {
share = share.on_press(Message::Share); share = share.on_press(Message::Share);
} }
content = content.push( content = content
row([]) .push(row![button::text("Cancel").on_press(Message::Cancel), share].spacing(8));
.spacing(12)
.push(button::text("Cancel").on_press(Message::Cancel))
.push(share),
);
} }
container(content).padding(14).width(Length::Fill).into()
container(content).padding(24).width(Length::Fill).into()
} }
} }
fn glyph(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
fn appearance() -> (cosmic::Theme, cosmic::font::Font) { fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
let document = ferese_config::config_path() let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|p| std::fs::read_to_string(p).ok())
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Cancel display selection"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><path d="m23 23 14 14m0-14L23 37" fill="none" stroke="#3d7be6" stroke-width="3" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Saving recording"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><g fill="#3d7be6"><circle cx="20" cy="30" r="3"/><circle cx="30" cy="30" r="3"/><circle cx="40" cy="30" r="3"/></g></svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -1,5 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/> <circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/> <rect x="20" y="20" width="20" height="20" rx="4" fill="#3d7be6"/>
<rect x="22" y="18" width="16" height="16" rx="3" fill="#3d7be6"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 258 B

+54 -2
View File
@@ -36,6 +36,15 @@ pub struct Recorder {
updates: Option<Receiver<Event>>, updates: Option<Receiver<Event>>,
} }
impl Recorder { impl Recorder {
pub fn label(&self) -> &'static str {
match self.state {
State::Selecting => "Cancel display selection",
State::Recording(_) => "Stop recording",
State::Saving => "Saving recording",
_ => "Record a display",
}
}
pub fn busy(&self) -> bool { pub fn busy(&self) -> bool {
self.updates.is_some() self.updates.is_some()
} }
@@ -87,11 +96,15 @@ impl Recorder {
let _ = send.send(Event::Exited); let _ = send.send(Event::Exited);
}); });
} }
Err(error) => self.state = State::Error(format!("Cannot start recorder: {error}")), Err(error) => {
let message = format!("Cannot start recorder: {error}");
notify("Recording unavailable", message.clone());
self.state = State::Error(message);
}
} }
} }
pub fn stop(&mut self) { pub fn stop(&mut self) {
if self.busy() { if self.busy() && !matches!(self.state, State::Saving) {
// EOF requests EOS/finalization. Never terminate the encoder abruptly. // EOF requests EOS/finalization. Never terminate the encoder abruptly.
self.control.take(); self.control.take();
self.state = State::Saving; self.state = State::Saving;
@@ -134,8 +147,47 @@ impl Recorder {
} }
} }
if done { if done {
match &self.state {
State::Saved(path) => notify("Recording saved", path.display().to_string()),
State::Error(message) => notify("Recording failed", message.clone()),
_ => (),
}
self.control.take(); self.control.take();
self.updates.take(); self.updates.take();
} }
} }
} }
// Use the normal notification center for results, without an extra recording menu.
fn notify(title: &'static str, body: String) {
std::thread::spawn(move || {
let result = (|| -> zbus::Result<()> {
let connection = zbus::blocking::connection::Builder::session()?
.method_timeout(std::time::Duration::from_secs(3))
.build()?;
let proxy = zbus::blocking::Proxy::new(
&connection,
"org.freedesktop.Notifications",
"/org/freedesktop/Notifications",
"org.freedesktop.Notifications",
)?;
let _: u32 = proxy.call(
"Notify",
&(
"Ferese",
0u32,
"ferese",
title,
body,
Vec::<String>::new(),
std::collections::HashMap::<String, zbus::zvariant::OwnedValue>::new(),
-1i32,
),
)?;
Ok(())
})();
if let Err(error) = result {
eprintln!("ferese-shell: recording notification: {error}");
}
});
}
+26 -45
View File
@@ -257,15 +257,26 @@ impl FereseShell {
if !kind.available(&self.status) { if !kind.available(&self.status) {
continue; continue;
} }
let (source, enabled) = if kind == Menu::Recording && self.recorder.busy() { let (source, enabled) = if kind == Menu::Recording {
( let source: &'static [u8] = match self.recorder.state {
include_bytes!("../assets/icons/status/record-stop.svg").as_slice(), recording::State::Selecting => {
true, include_bytes!("../assets/icons/status/record-cancel.svg")
) }
recording::State::Recording(_) => {
include_bytes!("../assets/icons/status/record-stop.svg")
}
recording::State::Saving => {
include_bytes!("../assets/icons/status/record-saving.svg")
}
_ => include_bytes!("../assets/icons/status/record.svg"),
};
(source, true)
} else { } else {
status_icon(kind, &self.status) status_icon(kind, &self.status)
}; };
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind); let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind)
|| (kind == Menu::Recording
&& matches!(self.recorder.state, recording::State::Recording(_)));
let foreground = color(if selected { let foreground = color(if selected {
theme.accent theme.accent
} else if enabled { } else if enabled {
@@ -326,7 +337,9 @@ impl FereseShell {
let recording_busy = kind == Menu::Recording && self.recorder.busy(); let recording_busy = kind == Menu::Recording && self.recorder.busy();
let control = button::custom(content) let control = button::custom(content)
.name(if recording_busy { .name(if recording_busy {
"Stop screen recording".into() self.recorder.label().into()
} else if kind == Menu::Recording {
"Record a display".into()
} else { } else {
status_label(kind, &self.status) status_label(kind, &self.status)
}) })
@@ -336,8 +349,12 @@ impl FereseShell {
]) ])
.height(metrics.control_height) .height(metrics.control_height)
.on_press_with_rectangle(move |offset, bounds| { .on_press_with_rectangle(move |offset, bounds| {
if recording_busy { if kind == Menu::Recording {
return cosmic::Action::App(Message::StopRecording); return cosmic::Action::App(if recording_busy {
Message::StopRecording
} else {
Message::StartRecording
});
} }
cosmic::Action::App(Message::OpenMenu( cosmic::Action::App(Message::OpenMenu(
kind, kind,
@@ -434,42 +451,6 @@ impl FereseShell {
] ]
.spacing(8), .spacing(8),
); );
} else if kind == Menu::Recording {
use recording::State;
let description = match &self.recorder.state {
State::Idle => {
"Record a display. Video is saved to Videos/Ferese as WebM. Audio is off."
.to_owned()
}
State::Selecting => "Choose a display in the sharing dialog.".to_owned(),
State::Recording(_) => format!(
"Recording {} · audio off",
self.recorder.elapsed().unwrap_or_default()
),
State::Saving => "Finishing your recording…".to_owned(),
State::Saved(path) => format!("Saved to {}", path.display()),
State::Error(message) => message.clone(),
};
rows = rows.push(text(description).size(13).class(theme::Text::Color(muted)));
if !self.recorder.busy() {
rows = rows.push(menu_button(
"Record display",
Message::StartRecording,
primary,
p,
));
} else if !matches!(self.recorder.state, State::Saving) {
rows = rows.push(menu_button(
if matches!(self.recorder.state, State::Selecting) {
"Cancel"
} else {
"Stop and save"
},
Message::StopRecording,
primary,
p,
));
}
} else { } else {
let combined = menu.kind == Menu::System; let combined = menu.kind == Menu::System;
if combined { if combined {