Open the four display modes from XF86Display or the external-monitor bar icon. Reuse output reconciliation and confirmation for manual layout changes, and notify the shell when monitor state changes. Release backdrop locks before render-element queries to prevent blurred popups from freezing the compositor. Cover popup open, update and close with regression tests. Share translucent card styles between display modes and capture sources, show focus rings during keyboard navigation, and align picker buttons, label sizing and footer spacing with system modals.
403 lines
14 KiB
Rust
403 lines
14 KiB
Rust
use std::io::{Read, Write};
|
||
|
||
use cosmic::app::{Core, Settings, Task};
|
||
use cosmic::iced::{Alignment, Length};
|
||
use cosmic::widget::{button, column, container, row, scrollable};
|
||
use cosmic::{ApplicationExt, Element};
|
||
use ferese_theme::icons::symbolic as glyph;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::capture::Source;
|
||
|
||
#[derive(Clone, Serialize, Deserialize)]
|
||
pub struct Prompt {
|
||
pub app: String,
|
||
pub sources: Vec<Source>,
|
||
pub multiple: bool,
|
||
#[serde(default)]
|
||
pub parent: String,
|
||
#[serde(default)]
|
||
pub window_capture: bool,
|
||
#[serde(default)]
|
||
pub persist_mode: u32,
|
||
#[serde(default)]
|
||
pub rememberable: Vec<String>,
|
||
}
|
||
|
||
impl Prompt {
|
||
fn source_list_height(&self) -> f32 {
|
||
let visible = &self.sources[..self.sources.len().min(4)];
|
||
let mut sections = 0;
|
||
let mut previous_kind = None;
|
||
if !self.window_capture {
|
||
for source in visible {
|
||
let is_window = source.window_id().is_some();
|
||
if previous_kind != Some(is_window) {
|
||
sections += 1;
|
||
previous_kind = Some(is_window);
|
||
}
|
||
}
|
||
}
|
||
let rows = visible.len();
|
||
(rows * 64 + rows.saturating_sub(1) * 6 + sections * 24) as f32
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub(crate) struct Selection {
|
||
pub names: Vec<String>,
|
||
pub persist_mode: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Message {
|
||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||
WindowOpened(cosmic::iced::window::Id),
|
||
Attached(crate::parent::Attachment),
|
||
Select(usize),
|
||
Remember,
|
||
Share,
|
||
Cancel,
|
||
}
|
||
|
||
struct Picker {
|
||
core: Core,
|
||
material: Option<ferese_theme::material::ModalMaterial>,
|
||
parent: Option<std::sync::Arc<crate::parent::Parent>>,
|
||
prompt: Prompt,
|
||
selected: Vec<usize>,
|
||
remember: bool,
|
||
background: cosmic::iced::Color,
|
||
palette: ferese_theme::Palette,
|
||
font: cosmic::font::Font,
|
||
}
|
||
|
||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||
let mut input = String::new();
|
||
std::io::stdin().take(256 * 1024 + 1).read_to_string(&mut input)?;
|
||
if input.len() > 256 * 1024 {
|
||
return Err("Picker request is too large".into());
|
||
}
|
||
let prompt: Prompt = serde_json::from_str(&input)?;
|
||
|
||
if prompt.sources.is_empty() || prompt.sources.len() > 144 {
|
||
return Err("No shareable displays".into());
|
||
}
|
||
|
||
let (theme, font, background, palette) = appearance();
|
||
let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 48. } else { 0. };
|
||
cosmic::app::run::<Picker>(
|
||
Settings::default()
|
||
.size(cosmic::iced::Size::new(400., height))
|
||
.client_decorations(false)
|
||
.transparent(true)
|
||
.theme(theme)
|
||
.default_font(font)
|
||
.default_text_size(14.)
|
||
.is_daemon(false),
|
||
(prompt, background, palette, font),
|
||
)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
impl cosmic::Application for Picker {
|
||
type Executor = cosmic::executor::Default;
|
||
type Flags = (Prompt, cosmic::iced::Color, ferese_theme::Palette, cosmic::font::Font);
|
||
type Message = Message;
|
||
const APP_ID: &'static str = "dev.ferese.ScreenShare";
|
||
|
||
fn core(&self) -> &Core {
|
||
&self.core
|
||
}
|
||
|
||
fn core_mut(&mut self) -> &mut Core {
|
||
&mut self.core
|
||
}
|
||
|
||
fn init(mut core: Core, (prompt, background, palette, font): Self::Flags) -> (Self, Task<Message>) {
|
||
core.window.show_headerbar = false;
|
||
core.window.border_padding = Some(0);
|
||
core.window.content_container = false;
|
||
core.window.use_template = false;
|
||
// No preselected source: clicking Share must be a deliberate choice.
|
||
let mut app = Self {
|
||
core,
|
||
material: None,
|
||
parent: None,
|
||
prompt,
|
||
selected: Vec::new(),
|
||
remember: false,
|
||
background,
|
||
palette,
|
||
font,
|
||
};
|
||
let task = app
|
||
.core
|
||
.main_window_id()
|
||
.map(|id| {
|
||
app.set_window_title(
|
||
if app.prompt.window_capture {
|
||
"Ferese — capture a window".into()
|
||
} else {
|
||
"Ferese — share your screen".into()
|
||
},
|
||
id,
|
||
)
|
||
})
|
||
.unwrap_or_else(Task::none);
|
||
|
||
(app, task)
|
||
}
|
||
|
||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||
cosmic::iced::Subscription::batch([
|
||
ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||
Some(Message::WindowOpened(id))
|
||
}
|
||
_ => None,
|
||
}),
|
||
])
|
||
}
|
||
|
||
fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::ThemeChanged(snapshot) => {
|
||
self.palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
|
||
self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||
self.background = cosmic::iced::Color {
|
||
a: ferese_theme::service::opacity(&snapshot.presented),
|
||
..self.palette.sidebar
|
||
};
|
||
return cosmic::command::set_theme(self.palette.native_theme());
|
||
}
|
||
Message::WindowOpened(id) => {
|
||
let parent = self.prompt.parent.clone();
|
||
return cosmic::iced::window::run(id, move |window| {
|
||
let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new);
|
||
Ok((parent, ferese_theme::material::ModalMaterial::attach(window)))
|
||
})
|
||
.map(|result| cosmic::Action::App(Message::Attached(result)));
|
||
}
|
||
Message::Attached(Ok((parent, material))) => {
|
||
self.parent = parent;
|
||
self.material = material.ok();
|
||
}
|
||
Message::Attached(Err(error)) => {
|
||
eprintln!("ferese portal: {error}");
|
||
return cosmic::iced::exit();
|
||
}
|
||
Message::Select(index) if index < self.prompt.sources.len() => {
|
||
if self.selected.contains(&index) {
|
||
self.selected.retain(|i| *i != index);
|
||
} else {
|
||
if self.prompt.multiple && self.selected.len() >= 8 {
|
||
return Task::none();
|
||
}
|
||
if !self.prompt.multiple {
|
||
self.selected.clear();
|
||
}
|
||
self.selected.push(index);
|
||
}
|
||
self.remember &= self.can_remember();
|
||
}
|
||
Message::Remember if self.can_remember() => self.remember = !self.remember,
|
||
Message::Share if !self.selected.is_empty() => {
|
||
let names: Vec<_> = self.selected.iter().map(|i| &self.prompt.sources[*i].name).collect();
|
||
if self.prompt.window_capture {
|
||
println!("{}", serde_json::to_string(&names).unwrap());
|
||
} else {
|
||
let selection = Selection {
|
||
names: names.into_iter().cloned().collect(),
|
||
persist_mode: if self.remember && self.can_remember() {
|
||
self.prompt.persist_mode
|
||
} else {
|
||
0
|
||
},
|
||
};
|
||
println!("{}", serde_json::to_string(&selection).unwrap());
|
||
}
|
||
let _ = std::io::stdout().flush();
|
||
return cosmic::iced::exit();
|
||
}
|
||
Message::Cancel => return cosmic::iced::exit(),
|
||
_ => (),
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
fn style(&self) -> Option<cosmic::iced::theme::Style> {
|
||
Some(self.palette.application_style(if self.material.is_some() {
|
||
cosmic::iced::Color::TRANSPARENT
|
||
} else {
|
||
self.background
|
||
}))
|
||
}
|
||
|
||
fn view(&self) -> Element<'_, Message> {
|
||
let app = if self.prompt.app.is_empty() {
|
||
"this application"
|
||
} else {
|
||
&self.prompt.app
|
||
};
|
||
let mut content = column([]).width(Length::Fill).height(Length::Fill).spacing(10).push(
|
||
row![
|
||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||
column![
|
||
self.text(if self.prompt.window_capture {
|
||
"Choose a window"
|
||
} else {
|
||
"Choose what to share"
|
||
})
|
||
.size(17),
|
||
self.text(format!(
|
||
"{} {app}",
|
||
if self.prompt.window_capture {
|
||
"Screenshot for"
|
||
} else {
|
||
"Share with"
|
||
}
|
||
))
|
||
.size(12),
|
||
]
|
||
.spacing(2)
|
||
.width(Length::Fill),
|
||
]
|
||
.spacing(12)
|
||
.align_y(Alignment::Center),
|
||
);
|
||
let mut sources = column([]).spacing(6);
|
||
let mut previous_kind = None;
|
||
for (index, source) in self.prompt.sources.iter().enumerate() {
|
||
let is_window = self.prompt.window_capture || source.window_id().is_some();
|
||
if !self.prompt.window_capture && previous_kind != Some(is_window) {
|
||
sources = sources.push(self.text(if is_window { "Windows" } else { "Displays" }).size(12));
|
||
previous_kind = Some(is_window);
|
||
}
|
||
let selected = self.selected.contains(&index);
|
||
let mut entry = row![
|
||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||
column![
|
||
self.text(if self.prompt.window_capture || source.window_id().is_some() {
|
||
&source.label
|
||
} else {
|
||
&source.name
|
||
})
|
||
.size(14),
|
||
self.text(format!("{} × {}", source.width, source.height)).size(12)
|
||
]
|
||
.spacing(3)
|
||
.width(Length::Fill),
|
||
]
|
||
.spacing(12)
|
||
.align_y(Alignment::Center);
|
||
if selected {
|
||
entry = entry.push(glyph(ferese_theme::icons::CHECK, 20));
|
||
}
|
||
sources = sources.push(
|
||
button::custom(entry)
|
||
.class(ferese_theme::controls::material_button_style(
|
||
self.palette,
|
||
selected,
|
||
self.background.a,
|
||
))
|
||
.padding(8)
|
||
.width(Length::Fill)
|
||
.on_press(Message::Select(index)),
|
||
);
|
||
}
|
||
content = content.push(scrollable(sources).height(Length::Fixed(self.prompt.source_list_height())));
|
||
content = content.push(
|
||
self.text(if self.prompt.window_capture {
|
||
"Only the selected window will be captured."
|
||
} else {
|
||
"Displays share everything visible. Windows share only their own content."
|
||
})
|
||
.size(12)
|
||
.width(Length::Fill),
|
||
);
|
||
if self.prompt.persist_mode > 0 && !self.prompt.window_capture {
|
||
let label = if !self.can_remember() {
|
||
"This selection needs approval each time"
|
||
} else if self.prompt.persist_mode == 1 {
|
||
"Allow without asking while this app is running"
|
||
} else {
|
||
"Allow this selection without asking again"
|
||
};
|
||
|
||
content = content.push(
|
||
row![
|
||
self.text(label).size(12).width(Length::Fill),
|
||
ferese_theme::controls::switch(self.remember, self.palette)
|
||
.on_press_maybe(self.can_remember().then_some(Message::Remember)),
|
||
]
|
||
.align_y(Alignment::Center),
|
||
);
|
||
}
|
||
|
||
let share = ferese_theme::controls::text_button(
|
||
if self.prompt.window_capture { "Capture" } else { "Share" },
|
||
self.font,
|
||
self.palette,
|
||
true,
|
||
)
|
||
.on_press_maybe((!self.selected.is_empty()).then_some(Message::Share));
|
||
let cancel = ferese_theme::controls::text_button("Cancel", self.font, self.palette, false)
|
||
.class(ferese_theme::controls::material_button_style(
|
||
self.palette,
|
||
false,
|
||
self.background.a,
|
||
))
|
||
.on_press(Message::Cancel);
|
||
content = content
|
||
.push(cosmic::iced::widget::Space::new().height(Length::Fill))
|
||
.push(
|
||
row![cosmic::iced::widget::Space::new().width(Length::Fill), cancel, share]
|
||
.spacing(10)
|
||
.align_y(Alignment::Center),
|
||
);
|
||
container(content).padding(14).width(Length::Fill).into()
|
||
}
|
||
}
|
||
|
||
impl Picker {
|
||
fn can_remember(&self) -> bool {
|
||
!self.selected.is_empty()
|
||
&& self.selected.len() <= 16
|
||
&& self
|
||
.selected
|
||
.iter()
|
||
.all(|index| self.prompt.rememberable.contains(&self.prompt.sources[*index].name))
|
||
}
|
||
|
||
fn text<'a>(
|
||
&self,
|
||
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||
ferese_theme::text(content, self.font)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn appearance() -> (
|
||
cosmic::Theme,
|
||
cosmic::font::Font,
|
||
cosmic::iced::Color,
|
||
ferese_theme::Palette,
|
||
) {
|
||
let snapshot = ferese_theme::service::current();
|
||
let palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
|
||
let family = &snapshot.presented.tokens.typography.font_family;
|
||
(
|
||
palette.native_theme(),
|
||
ferese_theme::font(Some(family)),
|
||
cosmic::iced::Color {
|
||
a: ferese_theme::service::opacity(&snapshot.presented),
|
||
..palette.sidebar
|
||
},
|
||
palette,
|
||
)
|
||
}
|