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
+185 -101

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
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**.
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
menu again to see its saved path. Videos are saved as VP8 WebM files under
The recorder finishes the video before sending a notification with its saved
path. Videos are saved as VP8 WebM files under
`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
other bar controls.
Encoding runs in a separate native `ferese-record` process using GStreamer.
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.
## 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

+93 -47
View File
@@ -2,8 +2,8 @@ use crate::capture::Source;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::Length,
widget::{button, column, container, row, text},
iced::{Alignment, Length},
widget::{button, column, container, icon, row, scrollable, text},
};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
@@ -41,9 +41,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
let (theme, font) = appearance();
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
cosmic::app::run::<Picker>(
Settings::default()
.size(cosmic::iced::Size::new(480., 440.))
.size(cosmic::iced::Size::new(400., height))
.theme(theme)
.default_font(font)
.default_text_size(14.)
@@ -68,7 +69,9 @@ impl cosmic::Application for Picker {
&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.
let mut app = Self {
core,
@@ -121,71 +124,114 @@ impl cosmic::Application for Picker {
fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() {
"An application"
"this application"
} else {
&self.prompt.app
};
let mut content = column([])
.spacing(16)
.push(
let mut content = column([]).width(Length::Fill).spacing(10).push(
row![
glyph(include_bytes!("../assets/display.svg"), 24),
column![
text(if self.prompt.indicator {
"Your screen is being shared"
"Screen sharing"
} else {
"Share your screen"
"Choose a display"
})
.size(24),
)
.push(text(if self.prompt.indicator {
format!("{app} can see the displays below.")
.size(18),
text(if self.prompt.indicator {
format!("Shared with {app}")
} else {
format!("{app} wants to see your screen. Choose what to share.")
}));
for (index, source) in self.prompt.sources.iter().enumerate() {
let label = format!(
"{}{} — {} × {}",
if self.selected.contains(&index) {
"✓ "
} else {
""
},
source.name,
source.width,
source.height
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() {
let selected = self.selected.contains(&index);
let mut entry = row![
glyph(include_bytes!("../assets/display.svg"), 24),
column![
text(&source.name).size(14),
text(format!("{} × {}", source.width, source.height)).size(12)
]
.spacing(3)
.width(Length::Fill),
]
.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 {
content = content.push(text(label));
sources = sources.push(container(entry).padding(8).width(Length::Fill));
} else {
content = content.push(
button::text(label)
sources = sources.push(
button::custom(entry)
.class(if selected {
cosmic::theme::Button::Suggested
} else {
cosmic::theme::Button::Standard
})
.padding(8)
.width(Length::Fill)
.on_press(Message::Select(index)),
);
}
}
content = content.push(text(
"Everything visible on a shared display, including notifications, may be seen.",
));
content = content.push(scrollable(sources).height(Length::Fixed(
(self.prompt.sources.len().min(4) * 56) as f32,
)));
content = content.push(
text("Everything on this display, including notifications, is visible.")
.size(12)
.width(Length::Fill),
);
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 {
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() {
share = share.on_press(Message::Share);
}
content = content.push(
row([])
.spacing(12)
.push(button::text("Cancel").on_press(Message::Cancel))
.push(share),
);
content = content
.push(row![button::text("Cancel").on_press(Message::Cancel), share].spacing(8));
}
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) {
@@ -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">
<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"/>
<rect x="22" y="18" width="16" height="16" rx="3" fill="#3d7be6"/>
<circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/>
<rect x="20" y="20" width="20" height="20" rx="4" fill="#3d7be6"/>
</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>>,
}
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 {
self.updates.is_some()
}
@@ -87,11 +96,15 @@ impl Recorder {
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) {
if self.busy() {
if self.busy() && !matches!(self.state, State::Saving) {
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
self.control.take();
self.state = State::Saving;
@@ -134,8 +147,47 @@ impl Recorder {
}
}
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.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) {
continue;
}
let (source, enabled) = if kind == Menu::Recording && self.recorder.busy() {
(
include_bytes!("../assets/icons/status/record-stop.svg").as_slice(),
true,
)
let (source, enabled) = if kind == Menu::Recording {
let source: &'static [u8] = match self.recorder.state {
recording::State::Selecting => {
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 {
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 {
theme.accent
} else if enabled {
@@ -326,7 +337,9 @@ impl FereseShell {
let recording_busy = kind == Menu::Recording && self.recorder.busy();
let control = button::custom(content)
.name(if recording_busy {
"Stop screen recording".into()
self.recorder.label().into()
} else if kind == Menu::Recording {
"Record a display".into()
} else {
status_label(kind, &self.status)
})
@@ -336,8 +349,12 @@ impl FereseShell {
])
.height(metrics.control_height)
.on_press_with_rectangle(move |offset, bounds| {
if recording_busy {
return cosmic::Action::App(Message::StopRecording);
if kind == Menu::Recording {
return cosmic::Action::App(if recording_busy {
Message::StopRecording
} else {
Message::StartRecording
});
}
cosmic::Action::App(Message::OpenMenu(
kind,
@@ -434,42 +451,6 @@ impl FereseShell {
]
.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 {
let combined = menu.kind == Menu::System;
if combined {