feat: add desktop entries for each settings page

This commit is contained in:
Michael Aaron Murphy 2024-03-22 15:44:04 +01:00 • committed by Michael Murphy
parent a40b1e9a1a
commit 1c61885428
65 changed files with 294 additions and 37 deletions

View file

@ -0,0 +1,55 @@
[package]
name = "cosmic-settings"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0"
rust-version = "1.65.0"
[dependencies]
async-channel = "2.1.1"
color-eyre = "0.6.2"
cosmic-bg-config = { workspace = true }
cosmic-randr-shell = { workspace = true }
cosmic-settings-wallpaper = { path = "../pages/wallpapers" }
cosmic-settings-page = { path = "../page" }
cosmic-settings-system = { path = "../pages/system" }
cosmic-settings-time = { path = "../pages/time" }
derivative = "2.2.0"
derive_setters = "0.1.6"
dirs = "5.0.1"
generator = "=0.7.5"
i18n-embed-fl = "0.7.0"
itertools = "0.12.0"
libcosmic = {workspace = true}
once_cell = "1.19.0"
regex = "1.10.3"
rust-embed = "8.2.0"
slotmap = "1.0.7"
tokio = "1.35.1"
downcast-rs = "1.2.0"
cosmic-comp-config = { workspace = true }
# TODO: migrate this dependency to the pages/desktop crate.
cosmic-panel-config = { workspace = true }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
url = "2.5.0"
freedesktop-desktop-entry = "0.5.0"
notify = "6.1.1"
anyhow = "1.0"
image = "0.25"
serde = { version = "1.0.196", features = ["derive"] }
ashpd = { version = "0.7", default-features = false }
ron = "0.8"
static_init = "1.0.3"
clap = {version = "4.4.18", features = ["derive"] }
itoa = "1.0.10"
futures = { package = "futures-lite", version = "2.2.0" }
[dependencies.i18n-embed]
version = "0.14.1"
features = ["fluent-system", "desktop-requester"]
[features]
default = []
wgpu = ["libcosmic/wgpu"]
test = []

View file

View file

@ -0,0 +1,4 @@
fallback_language = "en"
[fluent]
assets_dir = "../i18n"

File diff suppressed because one or more lines are too long

736
cosmic-settings/src/app.rs Normal file
View file

@ -0,0 +1,736 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
use crate::pages::desktop::{
self, appearance,
dock::{self, applets::ADD_DOCK_APPLET_DIALOGUE_ID},
panel::{
self,
applets_inner::{self, AppletsPage, APPLET_DND_ICON_ID},
inner as _panel,
},
};
use crate::pages::input::{self, keyboard};
use crate::pages::{self, display, networking, sound, system, time};
use crate::subscription::desktop_files;
use crate::widget::{page_title, search_header};
use crate::PageCommands;
use cosmic::app::DbusActivationMessage;
use cosmic::iced::Subscription;
use cosmic::widget::{button, row, text_input};
use cosmic::{
app::{Command, Core},
cosmic_config::{config_state_subscription, config_subscription},
iced::{
self,
event::{self, wayland, PlatformSpecific},
window, Length,
},
prelude::*,
widget::{column, container, icon, nav_bar, scrollable, segmented_button, settings},
Element,
};
use cosmic_panel_config::CosmicPanelConfig;
use cosmic_settings_page::{self as page, section};
use page::Entity;
use std::{borrow::Cow, str::FromStr};
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::module_name_repetitions)]
pub struct SettingsApp {
active_page: page::Entity,
config: Config,
core: Core,
nav_model: nav_bar::Model,
pages: page::Binder<crate::pages::Message>,
search_active: bool,
search_id: cosmic::widget::Id,
search_input: String,
search_selections: Vec<(page::Entity, section::Entity)>,
}
impl SettingsApp {
fn subcommand_to_page(&self, cmd: &PageCommands) -> Option<Entity> {
match cmd {
PageCommands::About => self.pages.page_id::<system::about::Page>(),
PageCommands::Appearance => self.pages.page_id::<desktop::appearance::Page>(),
PageCommands::Bluetooth => None,
PageCommands::DateTime => self.pages.page_id::<time::date::Page>(),
PageCommands::DesktopPanel => self.pages.page_id::<desktop::options::Page>(),
PageCommands::Displays => self.pages.page_id::<display::Page>(),
PageCommands::Firmware => self.pages.page_id::<system::firmware::Page>(),
PageCommands::Keyboard => self.pages.page_id::<input::keyboard::Page>(),
PageCommands::Mouse => self.pages.page_id::<input::mouse::Page>(),
PageCommands::Network => None,
PageCommands::Notifications => self.pages.page_id::<desktop::notifications::Page>(),
PageCommands::Power => None,
PageCommands::RegionLanguage => self.pages.page_id::<time::region::Page>(),
PageCommands::Sound => self.pages.page_id::<sound::Page>(),
PageCommands::Time => self.pages.page_id::<time::Page>(),
PageCommands::Touchpad => self.pages.page_id::<input::touchpad::Page>(),
PageCommands::Users => self.pages.page_id::<system::users::Page>(),
PageCommands::Wallpaper => self.pages.page_id::<desktop::wallpaper::Page>(),
PageCommands::Workspaces => self.pages.page_id::<desktop::workspaces::Page>(),
}
}
}
#[derive(Clone, Debug)]
pub enum Message {
DesktopInfo,
Error(String),
Page(page::Entity),
PageMessage(crate::pages::Message),
PanelConfig(CosmicPanelConfig),
SearchActivate,
SearchChanged(String),
SearchClear,
SearchSubmit,
SetWindowTitle,
OpenContextDrawer(Cow<'static, str>),
CloseContextDrawer,
SetTheme(cosmic::theme::Theme),
}
impl cosmic::Application for SettingsApp {
type Executor = cosmic::executor::single::Executor;
type Flags = crate::Args;
type Message = Message;
const APP_ID: &'static str = "com.system76.CosmicSettings";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
let mut app = SettingsApp {
active_page: page::Entity::default(),
config: Config::new(),
core,
nav_model: nav_bar::Model::default(),
pages: page::Binder::default(),
search_active: false,
search_id: cosmic::widget::Id::unique(),
search_input: String::new(),
search_selections: Vec::default(),
};
let desktop_id = app.insert_page::<desktop::Page>().id();
app.insert_page::<input::Page>();
app.insert_page::<display::Page>();
app.insert_page::<sound::Page>();
app.insert_page::<system::Page>();
app.insert_page::<time::Page>();
let active_id = match flags.subcommand {
Some(p) => app.subcommand_to_page(&p),
None => app
.pages
.find_page_by_id(&app.config.active_page)
.map(|(id, _info)| id),
}
.unwrap_or(desktop_id);
let command = app.activate_page(active_id);
(app, command)
}
fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav_model)
}
fn header_start(&self) -> Vec<Element<Self::Message>> {
let mut widgets = Vec::new();
widgets.push(if self.search_active {
text_input::search_input("", &self.search_input)
.width(Length::Fixed(240.0))
.id(self.search_id.clone())
.on_clear(Message::SearchClear)
.on_input(Message::SearchChanged)
.on_submit(Message::SearchSubmit)
.into()
} else {
icon::from_name("system-search-symbolic")
.apply(button::icon)
.padding([0, cosmic::theme::active().cosmic().space_s()])
.on_press(Message::SearchActivate)
.into()
});
widgets
}
fn on_close_requested(&self, id: window::Id) -> Option<Self::Message> {
let message = if id == *applets_inner::ADD_PANEL_APPLET_DIALOGUE_ID {
Message::PageMessage(crate::pages::Message::PanelApplet(
applets_inner::Message::ClosedAppletDialog,
))
} else if id == *ADD_DOCK_APPLET_DIALOGUE_ID {
Message::PageMessage(crate::pages::Message::DockApplet(dock::applets::Message(
applets_inner::Message::ClosedAppletDialog,
)))
} else {
return None;
};
Some(message)
}
fn on_escape(&mut self) -> Command<Self::Message> {
if self.search_active {
self.search_active = false;
self.search_clear();
}
Command::none()
}
fn on_nav_select(&mut self, id: nav_bar::Id) -> Command<Self::Message> {
if let Some(page) = self.nav_model.data::<page::Entity>(id).copied() {
return self.activate_page(page);
}
Command::none()
}
fn on_search(&mut self) -> Command<Self::Message> {
self.search_active = true;
cosmic::widget::text_input::focus(self.search_id.clone())
}
fn subscription(&self) -> Subscription<Message> {
let window_break = event::listen_with(|event, _| match event {
iced::Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
wayland::OutputEvent::Created(Some(info)),
o,
))) if info.name.is_some() => Some(Message::PageMessage(crate::pages::Message::Panel(
panel::Message(_panel::Message::OutputAdded(info.name.unwrap(), o)),
))),
iced::Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
wayland::OutputEvent::Removed,
o,
))) => Some(Message::PageMessage(crate::pages::Message::Panel(
panel::Message(_panel::Message::OutputRemoved(o)),
))),
_ => None,
});
Subscription::batch(vec![
window_break,
desktop_files(0).map(|_| Message::DesktopInfo),
config_subscription(0, "com.system76.CosmicPanel.Panel".into(), 1).map(|update| {
for why in update.errors {
tracing::error!(?why, "panel config load error");
}
Message::PanelConfig(update.config)
}),
config_subscription(0, "com.system76.CosmicPanel.Dock".into(), 1).map(|update| {
for why in update.errors {
tracing::error!(?why, "dock config load error");
}
Message::PanelConfig(update.config)
}),
config_state_subscription(0, cosmic_bg_config::NAME.into(), 1).map(|update| {
Message::PageMessage(pages::Message::DesktopWallpaper(
pages::desktop::wallpaper::Message::UpdateState(update.config),
))
}),
])
}
#[allow(clippy::too_many_lines)]
fn update(&mut self, message: Message) -> Command<Self::Message> {
match message {
Message::Page(page) => return self.activate_page(page),
Message::SetWindowTitle => return self.set_title(),
Message::SearchChanged(phrase) => {
self.search_changed(phrase);
}
Message::SearchActivate => {
self.search_active = true;
return cosmic::widget::text_input::focus(self.search_id.clone());
}
Message::SearchClear => {
self.search_clear();
}
Message::SearchSubmit => {
self.search_active = true;
}
Message::PageMessage(message) => match message {
crate::pages::Message::About(message) => {
page::update!(self.pages, message, system::about::Page);
}
crate::pages::Message::DateAndTime(message) => {
page::update!(self.pages, message, time::date::Page);
}
crate::pages::Message::Desktop(message) => {
page::update!(self.pages, message, desktop::Page);
}
crate::pages::Message::DesktopWallpaper(message) => {
if let Some(page) = self.pages.page_mut::<desktop::wallpaper::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::DesktopWorkspaces(message) => {
page::update!(self.pages, message, desktop::workspaces::Page);
}
crate::pages::Message::Displays(message) => {
if let Some(page) = self.pages.page_mut::<display::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::Input(message) => {
if let Some(page) = self.pages.page_mut::<input::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::External { .. } => {
todo!("external plugins not supported yet");
}
crate::pages::Message::Page(page) => {
return self.activate_page(page);
}
crate::pages::Message::Panel(message) => {
page::update!(self.pages, message, panel::Page);
}
crate::pages::Message::PanelApplet(message) => {
if let Some(page) = self.pages.page_mut::<applets_inner::Page>() {
return page
.update(message, *applets_inner::ADD_PANEL_APPLET_DIALOGUE_ID)
.map(cosmic::app::Message::App);
}
}
crate::pages::Message::Dock(message) => {
page::update!(self.pages, message, dock::Page);
}
crate::pages::Message::DockApplet(message) => {
if let Some(page) = self.pages.page_mut::<dock::applets::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::Appearance(message) => {
if let Some(page) = self.pages.page_mut::<appearance::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
},
Message::PanelConfig(config) if config.name.to_lowercase().contains("panel") => {
page::update!(
self.pages,
panel::Message(_panel::Message::PanelConfig(config.clone())),
panel::Page
);
if let Some(page) = self.pages.page_mut::<applets_inner::Page>() {
return page
.update(
applets_inner::Message::PanelConfig(config),
*applets_inner::ADD_PANEL_APPLET_DIALOGUE_ID,
)
.map(cosmic::app::Message::App);
}
}
Message::PanelConfig(config) if config.name.to_lowercase().contains("dock") => {
page::update!(
self.pages,
dock::Message::Inner(_panel::Message::PanelConfig(config.clone(),)),
dock::Page
);
page::update!(
self.pages,
dock::applets::Message(applets_inner::Message::PanelConfig(config,)),
dock::applets::Page
);
}
Message::DesktopInfo => {
let info_list: Vec<_> = freedesktop_desktop_entry::Iter::new(
freedesktop_desktop_entry::default_paths(),
)
.filter_map(|p| applets_inner::Applet::try_from(Cow::from(p)).ok())
.collect();
page::update!(
self.pages,
dock::applets::Message(applets_inner::Message::Applets(info_list.clone())),
dock::applets::Page
);
if let Some(page) = self.pages.page_mut::<applets_inner::Page>() {
return page
.update(
applets_inner::Message::Applets(info_list),
*applets_inner::ADD_PANEL_APPLET_DIALOGUE_ID,
)
.map(cosmic::app::Message::App);
}
}
Message::PanelConfig(_) => {}
Message::SetTheme(t) => return cosmic::app::command::set_theme(t),
Message::OpenContextDrawer(title) => {
self.core.window.show_context = true;
self.set_context_title(title.to_string());
}
Message::CloseContextDrawer => {
self.core.window.show_context = false;
}
Message::Error(error) => {
tracing::error!(error, "error occurred");
}
}
Command::none()
}
fn dbus_activation(&mut self, msg: DbusActivationMessage) -> Command<Self::Message> {
match msg.msg {
cosmic::app::DbusActivationDetails::Activate
| cosmic::app::DbusActivationDetails::Open { .. } => None,
cosmic::app::DbusActivationDetails::ActivateAction { action, .. } => {
PageCommands::from_str(&action)
.ok()
.and_then(|action| self.subcommand_to_page(&action))
.map(|e| self.activate_page(e))
}
}
.unwrap_or_else(Command::none)
}
fn view(&self) -> Element<Message> {
let theme = cosmic::theme::active();
let page_view = if self.search_active {
self.search_view()
} else if let Some(content) = self.pages.content(self.active_page) {
self.page_view(content)
} else if let Some(sub_pages) = self.pages.sub_pages(self.active_page) {
self.sub_page_view(sub_pages)
} else {
panic!("page without sub-pages or content");
};
let padding = if self.core.is_condensed() {
0
} else {
theme.cosmic().space_l()
};
container(page_view)
.max_width(800)
.width(Length::Fill)
.apply(container)
.center_x()
.padding([theme.cosmic().space_xxs(), padding])
.width(Length::Fill)
.apply(scrollable)
.into()
}
#[allow(clippy::too_many_lines)]
fn view_window(&self, id: window::Id) -> Element<Message> {
if let Some(Some(page)) =
(id == *APPLET_DND_ICON_ID).then(|| self.pages.page::<applets_inner::Page>())
{
return page.dnd_icon();
}
if let Some(Some(page)) = (id == *applets_inner::ADD_PANEL_APPLET_DIALOGUE_ID)
.then(|| self.pages.page::<applets_inner::Page>())
{
return page.add_applet_view(crate::pages::Message::PanelApplet);
}
if let Some(Some(page)) =
(id == *ADD_DOCK_APPLET_DIALOGUE_ID).then(|| self.pages.page::<dock::applets::Page>())
{
return page.inner().add_applet_view(|msg| {
crate::pages::Message::DockApplet(dock::applets::Message(msg))
});
}
if let Some(Some(page)) = (id == *keyboard::ADD_INPUT_SOURCE_DIALOGUE_ID)
.then(|| self.pages.page::<input::Page>())
{
return page.add_input_source_view();
}
if let Some(Some(page)) = (id == *keyboard::SPECIAL_CHARACTER_DIALOGUE_ID)
.then(|| self.pages.page::<input::Page>())
{
return page.special_character_key_view();
}
if let Some(page) = self.pages.page::<desktop::wallpaper::Page>() {
if id == page.color_dialog {
return page.show_color_dialog();
}
}
panic!("unknown window ID: {id:?}");
}
fn context_drawer(&self) -> Option<Element<Message>> {
if self.core.window.show_context {
self.pages
.context_drawer(self.active_page)
.map(|e| e.map(Message::PageMessage))
} else {
None
}
}
}
impl SettingsApp {
/// Activates a page.
fn activate_page(&mut self, page: page::Entity) -> Command<crate::Message> {
let current_page = self.active_page;
self.active_page = page;
let mut leave_command = iced::Command::none()
.map(Message::PageMessage)
.map(cosmic::app::Message::App);
if current_page != page {
leave_command = self
.pages
.on_leave(current_page)
.unwrap_or(iced::Command::none())
.map(Message::PageMessage)
.map(cosmic::app::Message::App);
self.config.active_page = Box::from(&*self.pages.info[page].id);
self.config
.set_active_page(Box::from(&*self.pages.info[page].id));
}
self.search_clear();
self.search_active = false;
self.activate_navbar(page);
let page_command = self
.pages
.page_reload(page)
.map(Message::PageMessage)
.map(cosmic::app::Message::App);
Command::batch(vec![
leave_command,
page_command,
cosmic::command::future(async { Message::SetWindowTitle })
.map(cosmic::app::Message::App),
])
}
fn set_title(&mut self) -> Command<crate::Message> {
self.set_window_title(
format!(
"{} - COSMIC Settings",
self.pages.info[self.active_page].title
),
window::Id::MAIN,
)
}
/// Activates the navbar item associated with a page.
fn activate_navbar(&mut self, mut page: page::Entity) {
if let Some(parent) = self.pages.info[page].parent {
page = parent;
}
if let Some(nav_id) = self.pages.data(page) {
self.nav_model.activate(*nav_id);
}
}
/// Adds a main page to the settings application.
fn insert_page<P: page::AutoBind<crate::pages::Message>>(
&mut self,
) -> page::Insert<crate::pages::Message> {
let id = self.pages.register::<P>().id();
self.navbar_insert(id);
page::Insert {
model: &mut self.pages,
id,
}
}
fn navbar_insert(&mut self, id: page::Entity) -> segmented_button::SingleSelectEntityMut {
let page = &self.pages.info[id];
self.nav_model
.insert()
.text(page.title.clone())
.icon(icon::from_name(&*page.icon_name).into())
.data(id)
.with_id(|nav_id| self.pages.data_set(id, nav_id))
}
/// Displays the view of a page.
fn page_view(&self, content: &[section::Entity]) -> cosmic::Element<Message> {
let page = &self.pages.page[self.active_page];
let page_info = &self.pages.info[self.active_page];
let mut column_widgets = Vec::with_capacity(1);
if let Some(parent) = page_info.parent {
let page_header = crate::widget::sub_page_header(
page_info.title.as_str(),
self.pages.info[parent].title.as_str(),
Message::Page(parent),
);
let mut page_header_content: cosmic::iced_widget::Row<'_, Message, Theme> =
row::with_capacity(2)
.align_items(iced::Alignment::End)
.push(page_header);
if let Some(element) = page.header_view() {
page_header_content = page_header_content.push(element.map(Message::from));
}
column_widgets.push(page_header_content.into());
}
column_widgets.reserve_exact(1 + content.len());
for id in content.iter().copied() {
let section = &self.pages.sections[id];
let model = &self.pages.page[self.active_page];
if section
.show_while
.as_ref()
.map_or(true, |func| func(model.as_ref()))
{
column_widgets.push(
(section.view_fn)(&self.pages, model.as_ref(), section)
.map(Message::PageMessage),
);
}
}
settings::view_column(column_widgets).into()
}
fn search_changed(&mut self, phrase: String) {
// If the text was cleared, clear the search results too.
if phrase.is_empty() {
self.search_clear();
return;
}
// Create a case-insensitive regular expression for the search function.
let search_expression = regex::RegexBuilder::new(&phrase)
.case_insensitive(true)
.unicode(true)
.ignore_whitespace(true)
.size_limit(16 * 1024)
.build();
if let Ok(expression) = search_expression {
// With the new search expression, generate new search results.
let results: Vec<_> = self.pages.search(&expression).collect();
// Use the results if results were found.
if !results.is_empty() {
self.search_selections = results;
}
}
self.search_input = phrase;
}
/// Clears the search results so that the search page will not be shown.
fn search_clear(&mut self) {
self.search_selections.clear();
self.search_input.clear();
}
/// Displays the search view.
fn search_view(&self) -> cosmic::Element<Message> {
let mut sections: Vec<cosmic::Element<Message>> = Vec::new();
let mut current_page = page::Entity::default();
for (page, section) in self.search_selections.iter().copied() {
let section = &self.pages.sections[section];
let model = &self.pages.page[page];
if page != current_page {
current_page = page;
sections.push(search_header(&self.pages, page));
}
if section
.show_while
.as_ref()
.map_or(true, |func| func(model.as_ref()))
{
let section = (section.view_fn)(&self.pages, model.as_ref(), section)
.map(Message::PageMessage)
.apply(iced::widget::container)
.padding([0, 0, 0, cosmic::theme::active().cosmic().space_xl()]);
sections.push(section.into());
}
}
settings::view_column(sections).into()
}
/// Displays the sub-pages view of a page.
fn sub_page_view(&self, sub_pages: &[page::Entity]) -> cosmic::Element<Message> {
let theme = cosmic::theme::active();
let mut page_list =
column::with_capacity(sub_pages.len()).spacing(theme.cosmic().space_s());
for entity in sub_pages.iter().copied() {
let sub_page = &self.pages.info[entity];
page_list = page_list.push(crate::widget::page_list_item(
sub_page.title.as_str(),
sub_page.description.as_str(),
&sub_page.icon_name,
entity,
));
}
column::with_capacity(2)
.push(page_title(&self.pages.info[self.active_page]))
.push(Element::from(page_list).map(Message::Page))
.spacing(theme.cosmic().space_m())
.padding(0)
.into()
}
}

View file

@ -0,0 +1,56 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::cosmic_config::{self, ConfigGet, ConfigSet};
const NAME: &str = "com.system76.CosmicSettings";
const ACTIVE_PAGE: &str = "active-page";
#[must_use]
#[derive(Debug)]
pub struct Config {
pub cosmic_config: Option<cosmic_config::Config>,
pub active_page: Box<str>,
}
impl Config {
pub fn new() -> Self {
let mut config = Self::default();
let context = match cosmic_config::Config::new(NAME, 1) {
Ok(context) => context,
Err(why) => {
tracing::warn!(?why, "failed to get config");
return Self::default();
}
};
if let Ok(page) = context.get::<Box<str>>(ACTIVE_PAGE) {
config.active_page = page;
}
config.cosmic_config = Some(context);
config
}
pub fn set_active_page(&mut self, page: Box<str>) {
if let Some(context) = self.cosmic_config.as_ref() {
if let Err(why) = context.set::<Box<str>>(ACTIVE_PAGE, page.clone()) {
tracing::error!(?why, "failed to store active page ID");
}
}
self.active_page = page;
}
}
impl Default for Config {
fn default() -> Self {
Self {
cosmic_config: None,
active_page: Box::from("desktop"),
}
}
}

View file

@ -0,0 +1,40 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DefaultLocalizer, LanguageLoader, Localizer,
};
use once_cell::sync::Lazy;
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../i18n/"]
struct Localizations;
pub static LANGUAGE_LOADER: Lazy<FluentLanguageLoader> = Lazy::new(|| {
let loader: FluentLanguageLoader = fluent_language_loader!();
loader
.load_fallback_language(&Localizations)
.expect("Error while loading fallback language");
loader
});
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id)
}};
($message_id:literal, $($args:expr),*) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *)
}};
}
// Get the `Localizer` to be used for localizing this library.
#[must_use]
pub fn localizer() -> Box<dyn Localizer> {
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
}

172
cosmic-settings/src/main.rs Normal file
View file

@ -0,0 +1,172 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_lossless)]
pub mod app;
use std::str::FromStr;
pub use app::{Message, SettingsApp};
pub mod config;
#[macro_use]
pub mod localize;
pub mod pages;
pub mod subscription;
pub mod theme;
pub mod utils;
pub mod widget;
use clap::{Parser, Subcommand};
use cosmic::{app::CosmicFlags, iced::Limits};
use i18n_embed::DesktopLanguageRequester;
use ron::error::SpannedError;
use serde::{Deserialize, Serialize};
use tracing_subscriber::prelude::*;
#[derive(Parser, Debug, Serialize, Deserialize, Clone)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Args {
#[command(subcommand)]
subcommand: Option<PageCommands>,
}
#[derive(Subcommand, Debug, Serialize, Deserialize, Clone)]
pub enum PageCommands {
/// About settings page
About,
/// Appearance settings page
Appearance,
/// Bluetooth settings page
Bluetooth,
/// DateTime settings page
DateTime,
/// Desktop and panel settings page
DesktopPanel,
/// Displays settings page
Displays,
/// Firmware settings page
Firmware,
/// Keyboard settings page
Keyboard,
/// Mouse settings page
Mouse,
/// Network settings page
Network,
/// Notifications settings page
Notifications,
/// Power settings page
Power,
/// Region & Language settings page
RegionLanguage,
/// Sound settings page
Sound,
/// Time settings page
Time,
/// Touchpad settings page
Touchpad,
/// Users settings page
Users,
/// Wallpaper settings page
Wallpaper,
/// Workspaces settings page
Workspaces,
}
impl FromStr for PageCommands {
type Err = SpannedError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
ron::de::from_str(s)
}
}
impl ToString for PageCommands {
fn to_string(&self) -> String {
ron::ser::to_string(self).unwrap()
}
}
impl CosmicFlags for Args {
type SubCommand = PageCommands;
type Args = Vec<String>;
fn action(&self) -> Option<&PageCommands> {
self.subcommand.as_ref()
}
}
/// # Errors
///
/// Returns error if iced fails to run the application.
pub fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
if std::env::var("RUST_SPANTRACE").is_err() {
std::env::set_var("RUST_SPANTRACE", "0");
}
std::env::set_var("WGPU_POWER_PREF", "low");
init_logger();
init_localizer();
let args = Args::parse();
let settings = cosmic::app::Settings::default()
.size_limits(Limits::NONE.min_width(400.0).min_height(300.0));
cosmic::app::run_single_instance::<app::SettingsApp>(settings, args)?;
Ok(())
}
fn init_localizer() {
let localizer = crate::localize::localizer();
let requested_languages = DesktopLanguageRequester::requested_languages();
if let Err(why) = localizer.select(&requested_languages) {
tracing::error!(%why, "error while loading fluent localizations");
}
}
fn init_logger() {
let log_level = std::env::var("RUST_LOG")
.ok()
.and_then(|level| level.parse::<tracing::Level>().ok())
.unwrap_or(tracing::Level::INFO);
let log_format = tracing_subscriber::fmt::format()
.pretty()
.without_time()
.with_line_number(true)
.with_file(true)
.with_target(false)
.with_thread_names(true);
let log_filter = tracing_subscriber::fmt::Layer::default()
.with_writer(std::io::stderr)
.event_format(log_format)
.with_filter(tracing_subscriber::filter::filter_fn(move |metadata| {
let target = metadata.target();
metadata.level() == &tracing::Level::ERROR
|| ((target.starts_with("cosmic_settings") || target.starts_with("cosmic_bg"))
&& metadata.level() <= &log_level)
}));
tracing_subscriber::registry().with(log_filter).init();
}
#[macro_export]
macro_rules! cache_dynamic_lazy {
( $( $visible:vis static $variable:ident: $type:ty = $expression:expr; )+ ) => {
$(
#[static_init::dynamic(lazy)]
$visible static $variable: $type = $expression;
)+
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
use cosmic::{cosmic_config::CosmicConfigEntry, iced::window, iced_runtime::Command};
use cosmic_panel_config::CosmicPanelConfig;
use cosmic_settings_page::{self as page, section, Section};
use once_cell::sync::Lazy;
use slotmap::SlotMap;
use std::borrow::Cow;
use crate::{
app,
pages::{
self,
desktop::panel::applets_inner::{self, lists, AppletsPage, ReorderWidgetState},
},
};
pub static ADD_DOCK_APPLET_DIALOGUE_ID: Lazy<window::Id> = Lazy::new(window::Id::unique);
pub(crate) struct Page {
inner: applets_inner::Page,
}
impl Default for Page {
fn default() -> Self {
let config_helper = CosmicPanelConfig::cosmic_config("Dock").ok();
let current_config = config_helper.as_ref().and_then(|config_helper| {
let panel_config = CosmicPanelConfig::get_entry(config_helper).ok()?;
// If the config is not present, it will be created with the default values and the name will not match
(panel_config.name == "Dock").then_some(panel_config)
});
Self {
inner: applets_inner::Page {
available_entries: freedesktop_desktop_entry::Iter::new(
freedesktop_desktop_entry::default_paths(),
)
.filter_map(|p| applets_inner::Applet::try_from(Cow::from(p)).ok())
.collect(),
config_helper,
current_config,
reorder_widget_state: ReorderWidgetState::default(),
search: String::new(),
has_dialog: false,
},
}
}
}
impl AppletsPage for Page {
fn inner(&self) -> &applets_inner::Page {
&self.inner
}
fn inner_mut(&mut self) -> &mut applets_inner::Page {
&mut self.inner
}
}
#[derive(Debug, Clone)]
pub struct Message(pub applets_inner::Message);
impl Page {
pub fn update(&mut self, message: Message) -> Command<app::Message> {
self.inner.update(message.0, *ADD_DOCK_APPLET_DIALOGUE_ID)
}
}
impl page::Page<crate::pages::Message> for Page {
#[allow(clippy::too_many_lines)]
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(lists::<Page, _>(|msg| {
pages::Message::DockApplet(Message(msg))
}))])
}
fn info(&self) -> page::Info {
page::Info::new("dock_applets", "preferences-dock-symbolic")
// .title(fl!("applets"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -0,0 +1,201 @@
use std::collections::HashMap;
use cosmic::Apply;
use cosmic::{
cosmic_config::{ConfigSet, CosmicConfigEntry},
widget::{settings, text, toggler},
Element,
};
use cosmic_panel_config::{CosmicPanelConfig, CosmicPanelContainerConfig};
use cosmic_settings_page::{self as page, section, Section};
use slotmap::SlotMap;
use tracing::error;
use crate::pages::desktop::panel::inner::{
add_panel, behavior_and_position, configuration, reset_button, style,
};
use super::panel::inner::{self, PageInner, PanelPage};
pub mod applets;
pub struct Page {
inner: PageInner,
}
#[derive(Clone, Debug)]
pub enum Message {
EnableDock(bool),
Inner(inner::Message),
}
impl Page {
pub fn update(&mut self, message: Message) {
match message {
Message::EnableDock(enabled) => {
let Some(container_config) = self.inner.container_config.as_mut() else {
return;
};
let Some(panel_config) = self.inner.panel_config.as_ref() else {
return;
};
let Ok(helper) = CosmicPanelContainerConfig::cosmic_config() else {
return;
};
if enabled {
container_config.config_list.push(panel_config.clone());
} else {
container_config
.config_list
.retain(|c| c.name.as_str() != "Dock");
}
let entry_names = container_config
.config_list
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>();
if let Err(err) = helper.set("entries", entry_names) {
error!("{:?}", err);
}
}
Message::Inner(inner) => {
self.inner.update(inner);
}
};
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<applets::Page>()
}
}
impl PanelPage for Page {
fn inner(&self) -> &PageInner {
&self.inner
}
fn inner_mut(&mut self) -> &mut PageInner {
&mut self.inner
}
fn autohide_label(&self) -> String {
fl!("panel-behavior-and-position", "dock-autohide")
}
fn gap_label(&self) -> String {
fl!("panel-style", "dock-anchor-gap")
}
fn extend_label(&self) -> String {
fl!("panel-style", "dock-extend")
}
fn configure_applets_label(&self) -> String {
fl!("panel-applets", "dock-desc")
}
fn applets_page_id(&self) -> &'static str {
"dock_applets"
}
}
impl Default for Page {
fn default() -> Self {
// TODO CosmicPanelConfig should return its own version
let config_helper = CosmicPanelConfig::cosmic_config("Dock").ok();
let panel_config = config_helper.as_ref().and_then(|config_helper| {
let panel_config = CosmicPanelConfig::get_entry(config_helper).ok()?;
// If the config is not present, it will be created with the default values and the name will not match
(panel_config.name == "Dock").then_some(panel_config)
});
let system_default = cosmic::cosmic_config::Config::system(
&format!("{}.Dock", cosmic_panel_config::NAME),
CosmicPanelConfig::VERSION,
)
.map(|c| match CosmicPanelConfig::get_entry(&c) {
Ok(c) => c,
Err((errs, c)) => {
for err in errs {
tracing::error!(?err, "Failed to load Dock system config.");
}
c
}
})
.ok();
let container_config = CosmicPanelContainerConfig::load().ok();
Self {
inner: PageInner {
config_helper,
panel_config,
container_config,
outputs_map: HashMap::new(),
system_default,
..Default::default()
},
}
}
}
pub(crate) fn enable() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![fl!("dock").into()])
.view::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
let Some(container_config) = page.inner.container_config.as_ref() else {
return Element::from(text(fl!("unknown")));
};
settings::view_section(&section.title)
.add(settings::item(
&*descriptions[0],
toggler(
None,
container_config
.config_list
.iter()
.any(|e| e.name.as_str() == "Dock"),
Message::EnableDock,
),
))
.apply(Element::from)
.map(crate::pages::Message::Dock)
})
}
// TODO cleanup
impl page::Page<crate::pages::Message> for Page {
#[allow(clippy::too_many_lines)]
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(if self.inner.panel_config.is_some() {
vec![
sections.insert(enable()),
sections.insert(behavior_and_position::<Page, _>(self, |m| {
crate::pages::Message::Dock(Message::Inner(m))
})),
sections.insert(style::<Page, _>(self, |m| {
crate::pages::Message::Dock(Message::Inner(m))
})),
sections.insert(configuration::<Page>(self)),
sections.insert(reset_button::<Page, _>(|m| {
crate::pages::Message::Dock(Message::Inner(m))
})),
]
} else {
vec![sections.insert(add_panel::<Page, _>(|m| {
crate::pages::Message::Dock(Message::Inner(m))
}))]
})
}
fn info(&self) -> page::Info {
page::Info::new("dock", "preferences-dock-symbolic")
.title(fl!("dock"))
.description(fl!("dock", "desc"))
}
}

View file

@ -0,0 +1,80 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod appearance;
pub mod dock;
pub mod notifications;
pub mod options;
pub mod panel;
pub mod wallpaper;
pub mod workspaces;
use cosmic::{config::CosmicTk, cosmic_config::CosmicConfigEntry};
use cosmic_settings_page as page;
#[derive(Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct Page {
pub cosmic_config: Option<cosmic::cosmic_config::Config>,
pub cosmic_tk: CosmicTk,
}
impl Default for Page {
fn default() -> Self {
let (cosmic_tk, cosmic_config) = CosmicTk::config().map_or_else(
|why| {
tracing::error!(?why, "failed to read CosmicTk config");
(CosmicTk::default(), None)
},
|config| match CosmicTk::get_entry(&config) {
Ok(tk) => (tk, Some(config)),
Err((_errors, tk)) => (tk, Some(config)),
},
);
Self {
cosmic_config,
cosmic_tk,
}
}
}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("desktop", "video-display-symbolic").title(fl!("desktop"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<options::Page>()
.sub_page::<wallpaper::Page>()
.sub_page::<appearance::Page>()
.sub_page::<workspaces::Page>()
.sub_page::<notifications::Page>()
}
}
#[derive(Copy, Clone, Debug)]
pub enum Message {
ShowMinimizeButton(bool),
ShowMaximizeButton(bool),
}
impl Page {
pub fn update(&mut self, message: Message) {
match message {
Message::ShowMaximizeButton(value) => {
if let Some(config) = self.cosmic_config.as_mut() {
let _res = self.cosmic_tk.set_show_maximize(config, value);
}
}
Message::ShowMinimizeButton(value) => {
if let Some(config) = self.cosmic_config.as_mut() {
let _res = self.cosmic_tk.set_show_minimize(config, value);
}
}
}
}
}

View file

@ -0,0 +1,26 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(Section::default())])
}
fn info(&self) -> page::Info {
page::Info::new("notifications", "preferences-system-notifications-symbolic")
.title(fl!("notifications"))
.description(fl!("notifications", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -0,0 +1,163 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use super::Message;
use cosmic::{
iced::Length,
theme,
widget::{button, container, horizontal_space, icon, row, settings, toggler},
Apply, Element,
};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
#[allow(clippy::too_many_lines)]
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(super_key_action()),
sections.insert(window_controls()),
sections.insert(panel_dock_links()),
])
}
fn info(&self) -> page::Info {
page::Info::new("desktop-panel-options", "video-display-symbolic")
.title(fl!("desktop-panel-options"))
.description(fl!("desktop-panel-options", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<super::panel::Page>()
.sub_page::<super::dock::Page>()
}
}
pub fn super_key_action() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("super-key-action"))
.descriptions(vec![
fl!("super-key-action", "launcher").into(),
fl!("super-key-action", "workspaces").into(),
fl!("super-key-action", "applications").into(),
])
.view::<Page>(|_binder, _page, section| {
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(settings::item(
&*descriptions[0],
horizontal_space(Length::Fill),
))
.add(settings::item(
&*descriptions[1],
horizontal_space(Length::Fill),
))
.add(settings::item(
&*descriptions[2],
horizontal_space(Length::Fill),
))
.into()
})
}
pub fn window_controls() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("window-controls"))
.descriptions(vec![
fl!("window-controls", "minimize").into(),
fl!("window-controls", "maximize").into(),
])
.view::<Page>(|binder, _page, section| {
let desktop = binder
.page::<super::Page>()
.expect("desktop page not found");
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(settings::item(
&*descriptions[0],
toggler(
None,
desktop.cosmic_tk.show_minimize,
Message::ShowMinimizeButton,
),
))
.add(settings::item(
&*descriptions[1],
toggler(
None,
desktop.cosmic_tk.show_maximize,
Message::ShowMaximizeButton,
),
))
.apply(Element::from)
.map(crate::pages::Message::Desktop)
})
}
pub fn panel_dock_links() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("desktop-panels-and-applets"))
.view::<Page>(|binder, _page, section| {
// TODO probably a way of getting the entity and its info
let mut settings = settings::view_section(&section.title);
settings = if let Some((panel_entity, panel_info)) =
binder.info.iter().find(|(_, v)| v.id == "panel")
{
let control = row::with_children(vec![
horizontal_space(Length::Fill).into(),
icon::from_name("go-next-symbolic").size(16).into(),
]);
settings.add(
settings::item::builder(panel_info.title.clone())
.description(panel_info.description.clone())
.control(control)
.spacing(16)
.apply(container)
.style(theme::Container::List)
.apply(button)
.style(theme::Button::Transparent)
.on_press(crate::pages::Message::Page(panel_entity)),
)
} else {
settings
};
settings = if let Some((dock_entity, dock_info)) =
binder.info.iter().find(|(_, v)| v.id == "dock")
{
let control = row::with_children(vec![
horizontal_space(Length::Fill).into(),
icon::from_name("go-next-symbolic").size(16).into(),
]);
settings.add(
settings::item::builder(dock_info.title.clone())
.description(dock_info.description.clone())
.control(control)
.spacing(16)
.apply(container)
.style(theme::Container::List)
.apply(button)
.style(theme::Button::Transparent)
.on_press(crate::pages::Message::Page(dock_entity)),
)
} else {
settings
};
Element::from(settings)
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,512 @@
use cosmic::{
cctk::sctk::reexports::client::{backend::ObjectId, protocol::wl_output::WlOutput, Proxy},
cosmic_config::{self, CosmicConfigEntry},
iced::Length,
theme,
widget::{
button, container, dropdown, horizontal_space, icon, row, settings, slider, text, toggler,
},
Element,
};
use cosmic::Apply;
use cosmic_panel_config::{
AutoHide, CosmicPanelBackground, CosmicPanelConfig, CosmicPanelContainerConfig,
CosmicPanelOuput, PanelAnchor, PanelSize,
};
use cosmic_settings_page::{self as page, Section};
use std::collections::HashMap;
pub struct PageInner {
pub(crate) config_helper: Option<cosmic_config::Config>,
pub(crate) panel_config: Option<CosmicPanelConfig>,
pub outputs: Vec<String>,
pub anchors: Vec<String>,
pub backgrounds: Vec<String>,
pub(crate) container_config: Option<CosmicPanelContainerConfig>,
// TODO move these into panel config
pub(crate) outputs_map: HashMap<ObjectId, (String, WlOutput)>,
pub(crate) system_default: Option<CosmicPanelConfig>,
pub(crate) system_container: Option<CosmicPanelContainerConfig>,
}
impl Default for PageInner {
fn default() -> Self {
Self {
config_helper: Option::default(),
panel_config: Option::default(),
outputs: vec![fl!("all")],
anchors: vec![
Anchor(PanelAnchor::Left).to_string(),
Anchor(PanelAnchor::Right).to_string(),
Anchor(PanelAnchor::Top).to_string(),
Anchor(PanelAnchor::Bottom).to_string(),
],
backgrounds: vec![
Appearance::Match.to_string(),
Appearance::Light.to_string(),
Appearance::Dark.to_string(),
],
container_config: Option::default(),
outputs_map: HashMap::default(),
system_default: None,
system_container: cosmic::cosmic_config::Config::system(
cosmic_panel_config::NAME,
CosmicPanelConfig::VERSION,
)
.map(
|c| match CosmicPanelContainerConfig::load_from_config(&c, true) {
Ok(c) => c,
Err((errs, c)) => {
for err in errs {
tracing::error!(?err, "Error when loading Panel container config.");
}
c
}
},
)
.ok(),
}
}
}
pub trait PanelPage {
fn inner(&self) -> &PageInner;
fn inner_mut(&mut self) -> &mut PageInner;
fn autohide_label(&self) -> String;
fn gap_label(&self) -> String;
fn extend_label(&self) -> String;
fn configure_applets_label(&self) -> String;
fn applets_page_id(&self) -> &'static str;
}
pub(crate) fn behavior_and_position<
P: page::Page<crate::pages::Message> + PanelPage,
T: Fn(Message) -> crate::pages::Message + Copy + 'static,
>(
p: &P,
msg_map: T,
) -> Section<crate::pages::Message> {
Section::default()
.title(fl!("panel-behavior-and-position"))
.descriptions(vec![
p.autohide_label().into(),
fl!("panel-behavior-and-position", "position").into(),
fl!("panel-behavior-and-position", "display").into(),
])
.view::<P>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let page = page.inner();
let Some(panel_config) = page.panel_config.as_ref() else {
return Element::from(text(fl!("unknown")));
};
settings::view_section(&section.title)
.add(settings::item(
&*descriptions[0],
toggler(None, panel_config.autohide.is_some(), |value| {
Message::AutoHidePanel(value)
}),
))
.add(settings::item(
&*descriptions[1],
dropdown(
page.anchors.as_slice(),
Some(panel_config.anchor as usize),
Message::PanelAnchor,
),
))
.add(settings::item(
&*descriptions[2],
dropdown(
page.outputs.as_slice(),
match &panel_config.output {
CosmicPanelOuput::All => Some(0),
CosmicPanelOuput::Active => None,
CosmicPanelOuput::Name(n) => page.outputs.iter().position(|o| o == n),
},
Message::Output,
),
))
.apply(Element::from)
.map(msg_map)
})
}
pub(crate) fn style<
P: page::Page<crate::pages::Message> + PanelPage,
T: Fn(Message) -> crate::pages::Message + Copy + 'static,
>(
p: &P,
msg_map: T,
) -> Section<crate::pages::Message> {
Section::default()
.title(fl!("panel-style"))
.descriptions(vec![
p.gap_label().into(),
p.extend_label().into(),
fl!("panel-style", "appearance").into(),
fl!("panel-style", "size").into(),
fl!("panel-style", "background-opacity").into(),
])
.view::<P>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let inner = page.inner();
let Some(panel_config) = inner.panel_config.as_ref() else {
return Element::from(text(fl!("unknown")));
};
settings::view_section(&section.title)
.add(settings::item(
&*descriptions[0],
toggler(None, panel_config.anchor_gap, |value| {
Message::AnchorGap(value)
}),
))
.add(settings::item(
&*descriptions[1],
toggler(None, panel_config.expand_to_edges, |value| {
Message::ExtendToEdge(value)
}),
))
.add(settings::item(
&*descriptions[2],
dropdown(
inner.backgrounds.as_slice(),
match panel_config.background {
CosmicPanelBackground::ThemeDefault => Some(0),
CosmicPanelBackground::Light => Some(1),
CosmicPanelBackground::Dark => Some(2),
CosmicPanelBackground::Color(_) => None,
},
Message::Appearance,
),
))
.add(settings::item(
&*descriptions[3],
// TODO custom discrete slider variant
row::with_children(vec![
text(fl!("small")).into(),
slider(
0..=4,
match panel_config.size {
PanelSize::XS => 0,
PanelSize::S => 1,
PanelSize::M => 2,
PanelSize::L => 3,
PanelSize::XL => 4,
},
|v| {
if v == 0 {
Message::PanelSize(PanelSize::XS)
} else if v == 1 {
Message::PanelSize(PanelSize::S)
} else if v == 2 {
Message::PanelSize(PanelSize::M)
} else if v == 3 {
Message::PanelSize(PanelSize::L)
} else {
Message::PanelSize(PanelSize::XL)
}
},
)
.into(),
text(fl!("large")).into(),
])
.spacing(12),
))
.add(settings::item(
&*descriptions[4],
row::with_children(vec![
text(fl!("number", HashMap::from_iter(vec![("number", 0)]))).into(),
slider(0..=100, (panel_config.opacity * 100.0) as i32, |v| {
Message::Opacity(v as f32 / 100.0)
})
.breakpoints(&[50])
.into(),
text(fl!("number", HashMap::from_iter(vec![("number", 100)]))).into(),
])
.spacing(12),
))
.apply(Element::from)
.map(msg_map)
})
}
pub(crate) fn configuration<P: page::Page<crate::pages::Message> + PanelPage>(
p: &P,
) -> Section<crate::pages::Message> {
Section::default()
.title(fl!("panel-applets"))
.descriptions(vec![p.configure_applets_label().into()])
.view::<P>(move |binder, page, section| {
let mut settings = settings::view_section(&section.title);
let descriptions = &section.descriptions;
settings = if let Some((panel_applets_entity, _panel_applets_info)) = binder
.info
.iter()
.find(|(_, v)| v.id == page.applets_page_id())
{
let control = row::with_children(vec![
horizontal_space(Length::Fill).into(),
icon::from_name("go-next-symbolic").size(16).into(),
]);
settings.add(
settings::item::builder(&*descriptions[0])
.control(control)
.spacing(16)
.apply(container)
.style(theme::Container::List)
.apply(button)
.style(theme::Button::Transparent)
.on_press(crate::pages::Message::Page(panel_applets_entity)),
)
} else {
settings
};
Element::from(settings)
})
}
#[allow(clippy::module_name_repetitions)]
pub(crate) fn add_panel<
P: page::Page<crate::pages::Message> + PanelPage,
T: Fn(Message) -> crate::pages::Message + Copy + 'static,
>(
msg_map: T,
) -> Section<crate::pages::Message> {
Section::default()
.title(fl!("panel-missing"))
.descriptions(vec![fl!("reset-to-default").into()])
.view::<P>(move |_binder, _page, section| {
let descriptions = &section.descriptions;
cosmic::iced::widget::row![button(text(&*descriptions[0])).on_press(Message::FullReset)]
.apply(Element::from)
.map(msg_map)
})
}
#[allow(clippy::too_many_lines)]
pub fn reset_button<
P: page::Page<crate::pages::Message> + PanelPage,
T: Fn(Message) -> crate::pages::Message + Copy + 'static,
>(
msg_map: T,
) -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![fl!("reset-to-default").into()])
.view::<P>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let inner = page.inner();
if inner.system_default == inner.panel_config {
horizontal_space(1).apply(Element::from)
} else {
cosmic::iced::widget::row![
button(text(&*descriptions[0])).on_press(Message::ResetPanel)
]
.apply(Element::from)
}
.map(msg_map)
})
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Anchor(PanelAnchor);
impl ToString for Anchor {
fn to_string(&self) -> String {
match self.0 {
PanelAnchor::Top => fl!("panel-top"),
PanelAnchor::Bottom => fl!("panel-bottom"),
PanelAnchor::Left => fl!("panel-left"),
PanelAnchor::Right => fl!("panel-right"),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Appearance {
Match,
Light,
Dark,
}
impl ToString for Appearance {
fn to_string(&self) -> String {
match self {
Appearance::Match => fl!("panel-appearance", "match"),
Appearance::Light => fl!("panel-appearance", "light"),
Appearance::Dark => fl!("panel-appearance", "dark"),
}
}
}
impl TryFrom<CosmicPanelBackground> for Appearance {
type Error = ();
fn try_from(value: CosmicPanelBackground) -> Result<Self, Self::Error> {
match value {
CosmicPanelBackground::ThemeDefault => Ok(Appearance::Match),
CosmicPanelBackground::Light => Ok(Appearance::Light),
CosmicPanelBackground::Dark => Ok(Appearance::Dark),
_ => Err(()),
}
}
}
impl From<Appearance> for CosmicPanelBackground {
fn from(appearance: Appearance) -> Self {
match appearance {
Appearance::Match => CosmicPanelBackground::ThemeDefault,
Appearance::Light => CosmicPanelBackground::Light,
Appearance::Dark => CosmicPanelBackground::Dark,
}
}
}
#[derive(Clone, Debug)]
pub enum Message {
// panel messages
AutoHidePanel(bool),
PanelAnchor(usize),
Output(usize),
AnchorGap(bool),
PanelSize(PanelSize),
Appearance(usize),
ExtendToEdge(bool),
Opacity(f32),
OutputAdded(String, WlOutput),
OutputRemoved(WlOutput),
PanelConfig(CosmicPanelConfig),
ResetPanel,
FullReset,
}
impl PageInner {
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) {
let Some(helper) = self.config_helper.as_ref() else {
return;
};
match &message {
Message::ResetPanel => {
if let Some((default, config)) = self
.system_default
.as_ref()
.zip(self.config_helper.as_ref())
{
self.panel_config = self.system_default.clone();
if let Err(err) = default.write_entry(config) {
tracing::error!(?err, "Error resetting panel config.");
}
} else {
tracing::error!("Panel config default is missing.");
}
}
Message::FullReset => {
if let Some(container) = self.system_container.as_ref() {
if let Err(err) = container.write_entries() {
tracing::error!(?err, "Error fully resetting the panel config.");
}
}
}
_ => {}
};
let Some(panel_config) = self.panel_config.as_mut() else {
return;
};
match message {
Message::AutoHidePanel(enabled) => {
if enabled {
panel_config.exclusive_zone = false;
panel_config.autohide = Some(AutoHide {
wait_time: 1000,
transition_time: 200,
handle_size: 4,
});
} else {
panel_config.exclusive_zone = true;
panel_config.autohide = None;
}
}
Message::PanelAnchor(i) => {
if let Some(anchor) = [
PanelAnchor::Left,
PanelAnchor::Right,
PanelAnchor::Top,
PanelAnchor::Bottom,
]
.iter()
.find(|a| a.to_string() == self.anchors[i])
{
panel_config.anchor = *anchor;
}
}
Message::Output(i) => {
if i == 0 {
panel_config.output = CosmicPanelOuput::All;
} else {
panel_config.output = CosmicPanelOuput::Name(self.outputs[i].clone());
}
}
Message::AnchorGap(enabled) => {
panel_config.anchor_gap = enabled;
if enabled {
panel_config.margin = 4;
} else {
panel_config.margin = 0;
}
}
Message::PanelSize(size) => {
panel_config.size = size;
}
Message::Appearance(a) => {
if let Some(b) = [Appearance::Match, Appearance::Light, Appearance::Dark]
.iter()
.find(|b| b.to_string() == self.backgrounds[a])
{
panel_config.background = (*b).into();
}
}
Message::ExtendToEdge(enabled) => {
panel_config.expand_to_edges = enabled;
}
Message::Opacity(opacity) => {
panel_config.opacity = opacity;
}
Message::OutputAdded(name, output) => {
self.outputs.push(name.clone());
self.outputs_map.insert(output.id(), (name, output));
return;
}
Message::OutputRemoved(output) => {
if let Some((name, _)) = self.outputs_map.remove(&output.id()) {
if let Some(pos) = self.outputs.iter().position(|o| o == &name) {
self.outputs.remove(pos);
}
}
}
Message::PanelConfig(c) => {
self.panel_config = Some(c);
return;
}
Message::ResetPanel | Message::FullReset => {}
}
if panel_config.anchor_gap || !panel_config.expand_to_edges {
panel_config.border_radius = 8;
} else {
panel_config.border_radius = 0;
}
_ = panel_config.write_entry(helper);
}
}

View file

@ -0,0 +1,134 @@
use std::collections::HashMap;
use cosmic::cosmic_config::CosmicConfigEntry;
use cosmic_panel_config::{CosmicPanelConfig, CosmicPanelContainerConfig};
use cosmic_settings_page::{self as page, section, Section};
use slotmap::SlotMap;
use crate::pages::desktop::panel::inner::{
add_panel, behavior_and_position, configuration, reset_button, style,
};
use self::inner::{PageInner, PanelPage};
pub mod applets_inner;
pub mod inner;
pub struct Page {
inner: PageInner,
}
#[derive(Clone, Debug)]
pub struct Message(pub inner::Message);
impl Page {
pub fn update(&mut self, message: Message) {
self.inner.update(message.0);
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<applets_inner::Page>()
}
}
impl PanelPage for Page {
fn inner(&self) -> &PageInner {
&self.inner
}
fn inner_mut(&mut self) -> &mut PageInner {
&mut self.inner
}
fn autohide_label(&self) -> String {
fl!("panel-behavior-and-position", "autohide")
}
fn gap_label(&self) -> String {
fl!("panel-style", "anchor-gap")
}
fn extend_label(&self) -> String {
fl!("panel-style", "extend")
}
fn configure_applets_label(&self) -> String {
fl!("panel-applets", "desc")
}
fn applets_page_id(&self) -> &'static str {
"panel_applets"
}
}
impl Default for Page {
fn default() -> Self {
let config_helper = CosmicPanelConfig::cosmic_config("Panel").ok();
let panel_config = config_helper.as_ref().and_then(|config_helper| {
let panel_config = CosmicPanelConfig::get_entry(config_helper).ok()?;
// If the config is not present, it will be created with the default values and the name will not match
(panel_config.name == "Panel").then_some(panel_config)
});
let system_default = cosmic::cosmic_config::Config::system(
&format!("{}.Panel", cosmic_panel_config::NAME),
CosmicPanelConfig::VERSION,
)
.map(|c| match CosmicPanelConfig::get_entry(&c) {
Ok(c) => c,
Err((errs, c)) => {
for err in errs {
tracing::error!(?err, "Failed to load Panel system config.");
}
c
}
})
.ok();
let container_config = CosmicPanelContainerConfig::load().ok();
Self {
inner: PageInner {
config_helper,
panel_config,
container_config,
outputs_map: HashMap::new(),
system_default,
..Default::default()
},
}
}
}
// TODO cleanup
impl page::Page<crate::pages::Message> for Page {
#[allow(clippy::too_many_lines)]
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(if self.inner.panel_config.is_some() {
vec![
sections.insert(behavior_and_position::<Page, _>(self, |m| {
crate::pages::Message::Panel(Message(m))
})),
sections.insert(style::<Page, _>(self, |m| {
crate::pages::Message::Panel(Message(m))
})),
sections.insert(configuration::<Page>(self)),
sections.insert(reset_button::<Page, _>(|m| {
crate::pages::Message::Panel(Message(m))
})),
]
} else {
vec![sections.insert(add_panel::<Page, _>(|m| {
crate::pages::Message::Panel(Message(m))
}))]
})
}
fn info(&self) -> page::Info {
page::Info::new("panel", "preferences-dock-symbolic")
.title(fl!("panel"))
.description(fl!("panel", "desc"))
}
}

View file

@ -0,0 +1,263 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::cosmic_config::{self, ConfigGet, ConfigSet};
use cosmic_bg_config::Source;
use cosmic_settings_wallpaper as wallpaper;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
const NAME: &str = "com.system76.CosmicSettings.Wallpaper";
const VERSION: u64 = 1;
const CURRENT_FOLDER: &str = "current-folder";
const CUSTOM_COLORS: &str = "custom-colors";
const CUSTOM_IMAGES: &str = "custom-images";
const RECENT_FOLDERS: &str = "recent-folders";
#[derive(Debug, Default)]
pub struct Config {
context: Option<cosmic_config::Config>,
state: Option<cosmic_config::Config>,
pub(super) current_folder: Option<PathBuf>,
custom_colors: Vec<wallpaper::Color>,
custom_images: Vec<PathBuf>,
recent_folders: VecDeque<PathBuf>,
}
impl Config {
pub fn new() -> Self {
let mut config = Self::default();
let context = match cosmic_config::Config::new(NAME, VERSION) {
Ok(context) => context,
Err(why) => {
tracing::warn!(?why, "failed to get config");
return Self::default();
}
};
let state = match cosmic_config::Config::new_state("com.system76.CosmicBackground", VERSION)
{
Ok(state) => state,
Err(why) => {
tracing::warn!(?why, "failed to get state");
return Self::default();
}
};
// Get the active background folder from cosmic-config.
if let Ok(path) = context.get::<Option<PathBuf>>(CURRENT_FOLDER) {
// Set current folder if it exists.
config.current_folder = path.filter(|path| path.exists());
}
// Get custom colors stored in cosmic-config.
if let Ok(colors) = context.get::<Vec<wallpaper::Color>>(CUSTOM_COLORS) {
config.custom_colors = colors;
}
// Get custom background images stored in cosmic-config.
if let Ok(images) = context.get::<Vec<PathBuf>>(CUSTOM_IMAGES) {
// Update config if images are missing.
let mut update_config = false;
for image in images {
if image.exists() {
config.custom_images.push(image);
} else {
update_config = true;
}
}
if update_config {
let _res = config.update_custom_images();
}
}
// Get recently-added background folders from cosmic-config.
if let Ok(folders) = context.get::<VecDeque<PathBuf>>(RECENT_FOLDERS) {
// Update config if folders are missing
let mut update_config = false;
for folder in folders {
if folder.exists() {
config.recent_folders.push_back(folder);
} else {
update_config = true;
}
}
if update_config {
let _res = config.update_recent_folders();
}
}
config.context = Some(context);
config.state = Some(state);
config
}
#[must_use]
pub fn current_folder(&self) -> &Path {
self.current_folder
.as_deref()
.unwrap_or(Self::default_folder())
}
#[must_use]
pub fn default_folder() -> &'static Path {
Path::new("/usr/share/backgrounds/")
}
/// Sets the current background folder
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn set_current_folder(
&mut self,
folder: Option<PathBuf>,
) -> Result<(), cosmic_config::Error> {
let result = self.update(CURRENT_FOLDER, &folder);
self.current_folder = folder;
result
}
#[must_use]
pub fn custom_colors(&self) -> &[wallpaper::Color] {
&self.custom_colors
}
/// Adds a custom color
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn add_custom_color(
&mut self,
color: wallpaper::Color,
) -> Result<(), cosmic_config::Error> {
if !self.custom_colors.contains(&color) {
self.custom_colors.push(color);
return self.update_custom_colors();
}
Ok(())
}
/// Removes custom background colors.
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn remove_custom_color(
&mut self,
color: &wallpaper::Color,
) -> Result<(), cosmic_config::Error> {
if let Some(position) = self.custom_colors.iter().position(|c| c == color) {
self.custom_colors.remove(position);
return self.update_custom_colors();
}
Ok(())
}
#[must_use]
pub fn current_image(&self, output: &str) -> Option<Source> {
let mut wallpapers = self
.state
.as_ref()?
.get::<Vec<(String, Source)>>("wallpapers")
.ok()?
.into_iter();
let wallpaper = if output == "all" {
wallpapers.next()
} else {
wallpapers.find(|(name, _path)| name == output)
};
wallpaper.map(|(_name, path)| path)
}
#[must_use]
pub fn custom_images(&self) -> &[PathBuf] {
&self.custom_images
}
/// Adds a custom background image
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn add_custom_image(&mut self, image: PathBuf) -> Result<(), cosmic_config::Error> {
if !self.custom_images.contains(&image) {
self.custom_images.push(image);
return self.update_custom_images();
}
Ok(())
}
/// Removes custom background images.
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn remove_custom_image(&mut self, image: &Path) -> Result<(), cosmic_config::Error> {
if let Some(position) = self.custom_images.iter().position(|p| p == image) {
self.custom_images.remove(position);
return self.update_custom_images();
}
Ok(())
}
#[must_use]
pub fn recent_folders(&self) -> &VecDeque<PathBuf> {
&self.recent_folders
}
/// Adds a folder to the recent folders list
///
/// # Errors
///
/// Returns an error if the on-disk configuration could not be updated.
pub fn add_recent_folder(&mut self, folder: PathBuf) -> Result<(), cosmic_config::Error> {
while self.recent_folders.len() > 4 {
self.recent_folders.pop_front();
}
if !self.recent_folders.contains(&folder) {
self.recent_folders.push_back(folder);
return self.update_recent_folders();
}
Ok(())
}
fn update<V: serde::Serialize>(
&self,
key: &str,
value: &V,
) -> Result<(), cosmic_config::Error> {
if let Some(context) = self.context.as_ref() {
context.set(key, value)?;
}
Ok(())
}
fn update_custom_colors(&self) -> Result<(), cosmic_config::Error> {
self.update(CUSTOM_COLORS, &self.custom_colors)
}
fn update_custom_images(&self) -> Result<(), cosmic_config::Error> {
self.update(CUSTOM_IMAGES, &self.custom_images)
}
fn update_recent_folders(&self) -> Result<(), cosmic_config::Error> {
self.update(RECENT_FOLDERS, &self.recent_folders)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use super::Message;
use cosmic::iced::Radius;
use cosmic::iced_core::Border;
use cosmic::iced_core::{self, gradient::Linear, Background, Color, Degrees, Length};
use cosmic::iced_runtime::core::image::Handle as ImageHandle;
use cosmic::prelude::*;
use cosmic::widget::{button, container, space};
use cosmic::{iced, Element};
use cosmic_settings_wallpaper as wallpaper;
use slotmap::DefaultKey;
const COLOR_WIDTH: u16 = 70;
const COLUMN_SPACING: u16 = 12;
const ROW_SPACING: u16 = 16;
/// A button for selecting a color or gradient.
#[must_use]
pub fn color_button(
color: wallpaper::Color,
removable: bool,
selected: bool,
) -> Element<'static, Message> {
let content = color_image(color.clone(), COLOR_WIDTH, COLOR_WIDTH, None);
let on_remove = if removable {
Some(Message::ColorRemove(color.clone()))
} else {
None
};
button::custom_image_button(content, on_remove)
.padding(0)
.selected(selected)
.style(button::Style::Image)
.on_press(Message::ColorSelect(color))
.into()
}
/// A sized container that's filled with a color or gradient.
#[must_use]
pub fn color_image<'a, M: 'a>(
color: wallpaper::Color,
width: u16,
height: u16,
border_radius: Option<f32>,
) -> Element<'a, M> {
container(space::Space::new(width, height))
.style(cosmic::theme::Container::custom(move |theme| {
container::Appearance {
icon_color: None,
text_color: None,
background: Some(match &color {
wallpaper::Color::Single([r, g, b]) => {
Background::Color(Color::from_rgb(*r, *g, *b))
}
wallpaper::Color::Gradient(wallpaper::Gradient { colors, radius }) => {
let stop_increment = 1.0 / (colors.len() - 1) as f32;
let mut stop = 0.0;
let mut linear = Linear::new(Degrees(*radius));
for &[r, g, b] in &**colors {
linear = linear.add_stop(stop, iced::Color::from_rgb(r, g, b));
stop += stop_increment;
}
Background::Gradient(iced_core::Gradient::Linear(linear))
}
}),
border: Border {
radius: border_radius.map_or_else(
|| Radius::from(theme.cosmic().corner_radii.radius_s),
|br| br.into(),
),
..Default::default()
},
shadow: Default::default(),
}
}))
.padding(0)
.into()
}
/// Color selection list
#[must_use]
pub fn color_select_options(
context: &super::Context,
selected: Option<&wallpaper::Color>,
) -> Element<'static, Message> {
let mut vec = Vec::with_capacity(wallpaper::DEFAULT_COLORS.len());
// Place removable custom colors first
for color in context.custom_colors.iter().rev() {
vec.push(color_button(
color.clone(),
true,
selected.map_or(false, |selection| selection == color),
));
}
// Then non-removable default colors
for color in wallpaper::DEFAULT_COLORS {
vec.push(color_button(
color.clone(),
false,
selected.map_or(false, |selection| selection == color),
));
}
flex_select_row(vec)
}
/// Background selection list
#[must_use]
pub fn wallpaper_select_options(
page: &super::Page,
selected: Option<DefaultKey>,
show_custom_images: bool,
) -> Element<Message> {
let mut vec = Vec::with_capacity(page.selection.selection_handles.len());
if show_custom_images {
// Place removable custom images first
for id in page.selection.custom_images.iter().rev() {
let handle = &page.selection.selection_handles[*id];
vec.push(wallpaper_button(
handle,
*id,
true,
selected.map_or(false, |selection| id == &selection),
));
}
}
// Then place non-removable images from the current folder
for (id, handle) in &page.selection.selection_handles {
if page.selection.is_custom.contains_key(id) {
continue;
}
vec.push(wallpaper_button(
handle,
id,
false,
selected.map_or(false, |selection| id == selection),
));
}
flex_select_row(vec)
}
fn flex_select_row(elements: Vec<Element<Message>>) -> Element<Message> {
cosmic::widget::flex_row(elements)
.column_spacing(COLUMN_SPACING)
.row_spacing(ROW_SPACING)
.apply(container)
.width(Length::Fill)
.center_x()
.into()
}
fn wallpaper_button(
handle: &ImageHandle,
id: DefaultKey,
removable: bool,
selected: bool,
) -> Element<Message> {
cosmic::widget::button::image(handle.clone())
.selected(selected)
.on_press(Message::Select(id))
.on_remove_maybe(if removable {
Some(Message::ImageRemove(id))
} else {
None
})
.into()
}

View file

@ -0,0 +1,196 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
// TODO make settings work
use cosmic::widget::settings;
use cosmic::{
cosmic_config::{self, ConfigGet, ConfigSet},
widget::radio,
Apply, Element,
};
use cosmic_comp_config::workspace::{WorkspaceConfig, WorkspaceLayout, WorkspaceMode};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
use tracing::error;
#[derive(Clone, Debug)]
pub enum Message {
SetWorkspaceMode(WorkspaceMode),
OrientationButtonSelected(cosmic::widget::segmented_button::Entity),
SetShowName(bool),
SetShowNumber(bool),
}
pub struct Page {
config: cosmic_config::Config,
comp_config: cosmic_config::Config,
comp_workspace_config: WorkspaceConfig,
show_workspace_name: bool,
show_workspace_number: bool,
orientation_model: cosmic::widget::segmented_button::SingleSelectModel,
}
impl Default for Page {
fn default() -> Self {
let comp_config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap();
let comp_workspace_config = comp_config.get("workspaces").unwrap_or_else(|err| {
error!(?err, "Failed to read config 'workspaces'");
WorkspaceConfig::default()
});
let mut orientation_model = cosmic::widget::segmented_button::SingleSelectModel::builder()
.insert(|b| b.text(fl!("workspaces-orientation", "vertical")))
.insert(|b| b.text(fl!("workspaces-orientation", "horizontal")))
.build();
orientation_model.activate_position(0);
let config = cosmic_config::Config::new("com.system76.CosmicWorkspaces", 1).unwrap();
let show_workspace_name = config.get("show_workspace_name").unwrap_or_else(|err| {
error!(?err, "Failed to read config 'show_workspace_name'");
false
});
let show_workspace_number = config.get("show_workspace_number").unwrap_or_else(|err| {
error!(?err, "Failed to read config 'show_workspace_number'");
false
});
Self {
config,
comp_config,
comp_workspace_config,
show_workspace_name,
show_workspace_number,
orientation_model,
}
}
}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(multi_behavior()),
sections.insert(overview_thumbnails()),
sections.insert(workspace_orientation()),
])
}
fn info(&self) -> page::Info {
page::Info::new("workspaces", "preferences-workspaces-symbolic")
.title(fl!("workspaces"))
.description(fl!("workspaces", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl Page {
fn save_comp_config(&self) {
if let Err(err) = self
.comp_config
.set("workspaces", &self.comp_workspace_config)
{
error!(?err, "Failed to set config 'workspaces'");
}
}
pub fn update(&mut self, message: Message) {
match message {
Message::SetWorkspaceMode(value) => {
self.comp_workspace_config.workspace_mode = value;
self.save_comp_config();
}
Message::OrientationButtonSelected(entity) => {
self.orientation_model.activate(entity);
let horizontal_entity = self.orientation_model.entity_at(1).unwrap();
let layout = if self.orientation_model.active() == horizontal_entity {
WorkspaceLayout::Horizontal
} else {
WorkspaceLayout::Vertical
};
self.comp_workspace_config.workspace_layout = layout;
self.save_comp_config();
}
Message::SetShowName(value) => {
self.show_workspace_name = value;
if let Err(err) = self.config.set("show_workspace_name", value) {
error!(?err, "Failed to set config 'show_workspace_name'");
}
}
Message::SetShowNumber(value) => {
self.show_workspace_number = value;
if let Err(err) = self.config.set("show_workspace_number", value) {
error!(?err, "Failed to set config 'show_workspace_number'");
}
}
}
}
}
fn multi_behavior() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("workspaces-multi-behavior"))
.descriptions(vec![
fl!("workspaces-multi-behavior", "span").into(),
fl!("workspaces-multi-behavior", "separate").into(),
])
.view::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(settings::item_row(vec![radio(
&*descriptions[0],
WorkspaceMode::Global,
Some(page.comp_workspace_config.workspace_mode),
Message::SetWorkspaceMode,
)
.into()]))
.add(settings::item_row(vec![radio(
&*descriptions[1],
WorkspaceMode::OutputBound,
Some(page.comp_workspace_config.workspace_mode),
Message::SetWorkspaceMode,
)
.into()]))
.apply(Element::from)
.map(crate::pages::Message::DesktopWorkspaces)
})
}
fn overview_thumbnails() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("workspaces-overview-thumbnails"))
.descriptions(vec![
fl!("workspaces-overview-thumbnails", "show-number").into(),
fl!("workspaces-overview-thumbnails", "show-name").into(),
])
.view::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(
settings::item::builder(&*descriptions[0])
.toggler(page.show_workspace_number, Message::SetShowNumber),
)
.add(
settings::item::builder(&*descriptions[1])
.toggler(page.show_workspace_name, Message::SetShowName),
)
.apply(Element::from)
.map(crate::pages::Message::DesktopWorkspaces)
})
}
fn workspace_orientation() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("workspaces-orientation"))
.descriptions(vec![])
.view::<Page>(|_binder, page, section| {
settings::view_section(&section.title)
.add(
cosmic::widget::segmented_control::horizontal(&page.orientation_model)
.on_activate(Message::OrientationButtonSelected),
)
.apply(Element::from)
.map(crate::pages::Message::DesktopWorkspaces)
})
}

View file

@ -0,0 +1,598 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use cosmic::iced_core::renderer::Quad;
use cosmic::iced_core::widget::{tree, Tree};
use cosmic::iced_core::{
self as core, Border, Clipboard, Element, Layout, Length, Rectangle, Renderer as IcedRenderer,
Shell, Size, Widget,
};
use cosmic::iced_core::{alignment, event, text};
use cosmic::iced_core::{layout, mouse, renderer, touch, Point};
use cosmic::widget::segmented_button::{self, SingleSelectModel};
use cosmic::Renderer;
use cosmic_randr_shell::{self as randr, OutputKey};
use randr::Transform;
const UNIT_PIXELS: f32 = 12.0;
pub type OnPlacementFunc<Message> = Box<dyn Fn(OutputKey, i32, i32) -> Message>;
pub type OnSelectFunc<Message> = Box<dyn Fn(segmented_button::Entity) -> Message>;
#[must_use]
#[derive(derive_setters::Setters)]
pub struct Arrangement<'a, Message> {
#[setters(skip)]
list: &'a randr::List,
#[setters(skip)]
tab_model: &'a SingleSelectModel,
#[setters(skip)]
on_placement: Option<OnPlacementFunc<Message>>,
#[setters(skip)]
on_select: Option<OnSelectFunc<Message>>,
width: Length,
height: Length,
}
impl<'a, Message> Arrangement<'a, Message> {
pub fn new(list: &'a randr::List, tab_model: &'a SingleSelectModel) -> Self {
Self {
list,
tab_model,
on_placement: None,
on_select: None,
width: Length::Shrink,
height: Length::Shrink,
}
}
pub fn on_placement(
mut self,
on_placement: impl Fn(OutputKey, i32, i32) -> Message + 'static,
) -> Self {
self.on_placement = Some(Box::new(on_placement));
self
}
pub fn on_select(
mut self,
on_select: impl Fn(segmented_button::Entity) -> Message + 'static,
) -> Self {
self.on_select = Some(Box::new(on_select));
self
}
}
impl<'a, Message: Clone> Widget<Message, cosmic::Theme, Renderer> for Arrangement<'a, Message> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&self,
tree: &mut Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
// Determine the max display dimensions, and the total display area utilized.
let mut max_dimensions = (0, 0);
let mut display_area = (0, 0);
for output in self.list.outputs.values() {
if !output.enabled {
continue;
}
let Some(mode_key) = output.current else {
continue;
};
let Some(mode) = self.list.modes.get(mode_key) else {
continue;
};
let (mut width, mut height) = if output.transform.map_or(true, is_landscape) {
(mode.size.0, mode.size.1)
} else {
(mode.size.1, mode.size.0)
};
// Scale dimensions of the display with the output scale.
width = (width as f64 / output.scale) as u32;
height = (height as f64 / output.scale) as u32;
max_dimensions.0 = max_dimensions.0.max(width);
max_dimensions.1 = max_dimensions.1.max(height);
display_area.0 = display_area.0.max(width as i32 + output.position.0);
display_area.1 = display_area.1.max(height as i32 + output.position.1);
}
let width = (max_dimensions.0 as i32 * 2 + display_area.0) as f32 / UNIT_PIXELS;
let height = (max_dimensions.1 as i32 * 2 + display_area.1) as f32 / UNIT_PIXELS;
let state = tree.state.downcast_mut::<State>();
state.max_dimensions = (
max_dimensions.0 as f32 * 1.25 / UNIT_PIXELS,
max_dimensions.1 as f32 * 1.25 / UNIT_PIXELS,
);
let limits = limits
.width(Length::Fixed(width))
.height(Length::Fixed(height));
let size = limits.resolve(width, height, Size::ZERO);
layout::Node::new(size)
}
fn on_event(
&mut self,
tree: &mut Tree,
event: cosmic::iced_core::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) -> event::Status {
let bounds = layout.bounds();
match event {
core::Event::Mouse(mouse::Event::CursorMoved { .. })
| core::Event::Touch(touch::Event::FingerMoved { .. }) => {
if let Some(position) = cursor.position() {
let state = tree.state.downcast_mut::<State>();
if let Some((output_key, region)) = state.dragging.as_mut() {
update_dragged_region(
self.tab_model,
self.list,
&bounds,
*output_key,
region,
state.max_dimensions,
(position.x - state.offset.0, position.y - state.offset.1),
);
return event::Status::Captured;
}
}
}
core::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| core::Event::Touch(touch::Event::FingerPressed { .. }) => {
if let Some(position) = cursor.position() {
let state = tree.state.downcast_mut::<State>();
if let Some((output_key, output_region)) = display_region_hovers(
self.tab_model,
self.list,
&bounds,
state.max_dimensions,
position,
) {
state.drag_from = position;
state.offset = (position.x - output_region.x, position.y - output_region.y);
state.dragging = Some((output_key, output_region));
return event::Status::Captured;
}
}
}
core::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| core::Event::Touch(touch::Event::FingerLifted { .. }) => {
let state = tree.state.downcast_mut::<State>();
if let Some((output_key, region)) = state.dragging.take() {
if let Some(position) = cursor.position() {
if position.distance(state.drag_from) < 4.0 {
if let Some(ref on_select) = self.on_select {
for id in self.tab_model.iter() {
if let Some(&key) = self.tab_model.data::<OutputKey>(id) {
if key == output_key {
shell.publish(on_select(id));
}
}
}
}
return event::Status::Captured;
}
}
if let Some(ref on_placement) = self.on_placement {
shell.publish(on_placement(
output_key,
((region.x - state.max_dimensions.0 - bounds.x) * UNIT_PIXELS) as i32,
((region.y - state.max_dimensions.1 - bounds.y) * UNIT_PIXELS) as i32,
));
}
return event::Status::Captured;
}
}
_ => (),
}
event::Status::Ignored
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
for (_output_key, region) in
display_regions(self.tab_model, self.list, &bounds, state.max_dimensions)
{
if cursor.is_over(region) {
return mouse::Interaction::Grab;
}
}
mouse::Interaction::Idle
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
_theme: &cosmic::Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
let theme = cosmic::theme::active();
let cosmic_theme = theme.cosmic();
let border_color = cosmic_theme.palette.neutral_7;
let active_key = self.tab_model.active_data::<OutputKey>();
for (id, (output_key, mut region)) in
display_regions(self.tab_model, self.list, &bounds, state.max_dimensions).enumerate()
{
// If the output is being dragged, show its dragged position instead.
if let Some((dragged_key, dragged_region)) = state.dragging {
if dragged_key == output_key {
region = dragged_region;
}
}
let (background, border_color) = if Some(&output_key) == active_key {
let mut border_color = border_color;
border_color.alpha = 0.4;
(cosmic_theme.accent_color(), border_color)
} else {
(cosmic_theme.palette.neutral_4, border_color)
};
renderer.fill_quad(
Quad {
bounds: region,
border: Border {
color: border_color.into(),
radius: 4.0.into(),
width: 3.0,
},
shadow: Default::default(),
},
core::Background::Color(background.into()),
);
let id_bounds = Rectangle {
x: region.x + (region.width / 2.0 - 36.0),
y: region.y + (region.height / 2.0 - 23.0),
width: 72.0,
height: 46.0,
};
renderer.fill_quad(
Quad {
bounds: id_bounds,
border: Border {
radius: 30.0.into(),
..Default::default()
},
shadow: Default::default(),
},
core::Background::Color(cosmic_theme.palette.neutral_1.into()),
);
core::text::Renderer::fill_text(
renderer,
core::Text {
content: itoa::Buffer::new().format(id),
size: core::Pixels(24.0),
line_height: core::text::LineHeight::Relative(1.2),
font: cosmic::font::FONT_BOLD,
bounds: id_bounds.size(),
horizontal_alignment: alignment::Horizontal::Center,
vertical_alignment: alignment::Vertical::Center,
shaping: text::Shaping::Basic,
},
core::Point {
x: id_bounds.center_x(),
y: id_bounds.center_y(),
},
cosmic_theme.palette.neutral_10.into(),
*viewport,
);
}
}
}
impl<'a, Message: 'static + Clone> From<Arrangement<'a, Message>> for cosmic::Element<'a, Message> {
fn from(display_positioner: Arrangement<'a, Message>) -> Self {
Element::new(display_positioner)
}
}
#[derive(Default)]
struct State {
drag_from: Point,
dragging: Option<(OutputKey, Rectangle)>,
offset: (f32, f32),
max_dimensions: (f32, f32),
}
/// Iteratively calculate display regions for each display output in the list.
fn display_regions<'a>(
model: &'a SingleSelectModel,
list: &'a randr::List,
bounds: &'a Rectangle,
max_dimensions: (f32, f32),
) -> impl Iterator<Item = (OutputKey, Rectangle)> + 'a {
model
.iter()
.filter_map(move |id| model.data::<OutputKey>(id))
.filter_map(move |&key| {
let Some(output) = list.outputs.get(key) else {
return None;
};
if !output.enabled {
return None;
}
let Some(mode_key) = output.current else {
return None;
};
let Some(mode) = list.modes.get(mode_key) else {
return None;
};
let (mut width, mut height) = (
(mode.size.0 as f32 / output.scale as f32) / UNIT_PIXELS,
(mode.size.1 as f32 / output.scale as f32) / UNIT_PIXELS,
);
(width, height) = if output.transform.map_or(true, is_landscape) {
(width, height)
} else {
(height, width)
};
Some((
key,
Rectangle {
width,
height,
x: max_dimensions.0 + bounds.x + (output.position.0 as f32) / UNIT_PIXELS,
y: max_dimensions.1 + bounds.y + (output.position.1 as f32) / UNIT_PIXELS,
},
))
})
}
fn display_region_hovers(
model: &SingleSelectModel,
list: &randr::List,
bounds: &Rectangle,
max_dimensions: (f32, f32),
point: Point,
) -> Option<(OutputKey, Rectangle)> {
for (output_key, region) in display_regions(model, list, bounds, max_dimensions) {
if region.contains(point) {
return Some((output_key, region));
}
}
None
}
/// Updates a display's region, preventing coordinates from overlapping with existing displays.
fn update_dragged_region(
model: &SingleSelectModel,
list: &randr::List,
bounds: &Rectangle,
output: OutputKey,
region: &mut Rectangle,
max_dimensions: (f32, f32),
(x, y): (f32, f32),
) {
let mut dragged_region = Rectangle { x, y, ..*region };
let mut nearest = f32::MAX;
let mut nearest_region = Rectangle::default();
let mut nearest_side = NearestSide::East;
// Find the nearest adjacent display to the dragged display.
for (other_output, other_region) in display_regions(model, list, bounds, max_dimensions) {
if other_output == output {
continue;
}
let center = dragged_region.center();
let eastward = distance(east_point(&other_region), center) * 1.25;
let westward = distance(west_point(&other_region), center) * 1.25;
let northward = distance(north_point(&other_region), center);
let southward = distance(south_point(&other_region), center);
let mut nearer = false;
if nearest > eastward {
(nearest, nearest_side, nearer) = (eastward, NearestSide::East, true);
}
if nearest > westward {
(nearest, nearest_side, nearer) = (westward, NearestSide::West, true);
}
if nearest > northward {
(nearest, nearest_side, nearer) = (northward, NearestSide::North, true);
}
if nearest > southward {
(nearest, nearest_side, nearer) = (southward, NearestSide::South, true);
}
if nearer {
nearest_region = other_region;
}
}
// Attach dragged display to nearest adjacent display.
match nearest_side {
NearestSide::East => {
dragged_region.x = nearest_region.x - dragged_region.width;
dragged_region.y = dragged_region
.y
.max(nearest_region.y - dragged_region.height + 8.0)
.min(nearest_region.y + nearest_region.height - 8.0);
}
NearestSide::North => {
dragged_region.y = nearest_region.y - dragged_region.height;
dragged_region.x = dragged_region
.x
.max(nearest_region.x - dragged_region.width + 8.0)
.min(nearest_region.x + nearest_region.width - 8.0);
}
NearestSide::West => {
dragged_region.x = nearest_region.x + nearest_region.width;
dragged_region.y = dragged_region
.y
.max(nearest_region.y - dragged_region.height + 8.0)
.min(nearest_region.y + nearest_region.height - 8.0);
}
NearestSide::South => {
dragged_region.y = nearest_region.y + nearest_region.height;
dragged_region.x = dragged_region
.x
.max(nearest_region.x - dragged_region.width + 8.0)
.min(nearest_region.x + nearest_region.width - 8.0);
}
}
// Snap-align on x-axis when alignment is near.
if (dragged_region.x - nearest_region.x).abs() <= 8.0 {
dragged_region.x = nearest_region.x;
}
// Snap-align on x-axis when alignment is near bottom edge.
if ((dragged_region.x + dragged_region.width) - (nearest_region.x + nearest_region.width)).abs()
<= 8.0
{
dragged_region.x = nearest_region.x + nearest_region.width - dragged_region.width;
}
// Snap-align on y-axis when alignment is near.
if (dragged_region.y - nearest_region.y).abs() <= 8.0 {
dragged_region.y = nearest_region.y;
}
// Snap-align on y-axis when alignment is near bottom edge.
if ((dragged_region.y + dragged_region.height) - (nearest_region.y + nearest_region.height))
.abs()
<= 8.0
{
dragged_region.y = nearest_region.y + nearest_region.height - dragged_region.height;
}
// Prevent display from overlapping with other displays.
for (other_output, other_region) in display_regions(model, list, bounds, max_dimensions) {
if other_output == output {
continue;
}
if other_region.intersects(&dragged_region) {
return;
}
}
*region = dragged_region;
}
fn is_landscape(transform: Transform) -> bool {
matches!(
transform,
Transform::Normal | Transform::Rotate180 | Transform::Flipped | Transform::Flipped180
)
}
#[derive(Debug)]
enum NearestSide {
East,
North,
South,
West,
}
fn distance(a: Point, b: Point) -> f32 {
((b.x - a.x).powf(2.0) + (b.y - a.y).powf(2.0)).sqrt()
}
fn east_point(r: &Rectangle) -> Point {
Point {
x: r.x,
y: r.center_y(),
}
}
fn north_point(r: &Rectangle) -> Point {
Point {
x: r.center_x(),
y: r.y,
}
}
fn west_point(r: &Rectangle) -> Point {
Point {
x: r.x + r.width,
y: r.center_y(),
}
}
fn south_point(r: &Rectangle) -> Point {
Point {
x: r.center_x(),
y: r.y + r.height,
}
}

View file

@ -0,0 +1,245 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use super::{Message, NightLight};
use crate::pages;
use cosmic::iced_core::{Alignment, Length, Padding};
use cosmic::prelude::CollectionWidget;
use cosmic::widget::{button, column, icon, list_column, row, toggler};
use cosmic::{Apply, Command, Element};
use std::sync::Arc;
pub const INTEGRATED: &str = "integrated";
pub const NVIDIA: &str = "nvidia";
pub const HYBRID: &str = "hybrid";
pub const COMPUTE: &str = "compute";
pub async fn fetch() -> Option<Arc<std::io::Result<Mode>>> {
let switchable = tokio::process::Command::new("system76-power")
.args(["graphics", "switchable"])
.output()
.await
.map(|output| {
std::str::from_utf8(&output.stdout).map_or(false, |text| text.trim() == "switchable")
});
match switchable {
Ok(false) => None,
Ok(true) => Some(Arc::new(
tokio::process::Command::new("system76-power")
.arg("graphics")
.output()
.await
.and_then(|output| {
if let Ok(mut mode) = std::str::from_utf8(&output.stdout) {
mode = mode.trim();
if mode == COMPUTE {
Ok(Mode::Compute)
} else if mode == HYBRID {
Ok(Mode::Hybrid)
} else if mode == INTEGRATED {
Ok(Mode::Integrated)
} else if mode == NVIDIA {
Ok(Mode::Nvidia)
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unknown graphics mode",
))
}
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"system76-power output was not UTF-8",
))
}
}),
)),
Err(why) => Some(Arc::new(Err(why))),
}
}
pub fn view(
mode: &'static str,
description: &'static str,
button: Option<(&'static str, Message)>,
) -> Element<'static, Message> {
let theme = cosmic::theme::active();
let theme = theme.cosmic();
let has_checkmark = button.is_none();
let content = column::with_capacity(3)
.padding(Padding::from([theme.space_xxs(), theme.space_l()]))
.push(cosmic::widget::text::body(mode))
.push(cosmic::widget::text::caption(description))
.push(cosmic::widget::Space::new(Length::Fill, 12))
.push_maybe(button.map(|(text, message)| {
button::text(text)
.style(cosmic::theme::Button::Link)
.trailing_icon(icon::from_name("go-next-symbolic").size(16))
.padding(0)
.on_press(message)
}));
if has_checkmark {
row::with_capacity(2)
.align_items(Alignment::Center)
.push(content)
.push(icon::from_name("object-select-symbolic").size(24))
.apply(Element::from)
.apply(cosmic::widget::list::container)
.into()
} else {
cosmic::widget::list::container(content).into()
}
}
/// Switchable graphics mode
#[derive(Clone, Copy, Debug)]
pub enum Mode {
Compute,
Hybrid,
Integrated,
Nvidia,
}
impl Mode {
#[must_use]
pub fn argument_str(self) -> &'static str {
match self {
Self::Compute => COMPUTE,
Self::Hybrid => HYBRID,
Self::Integrated => INTEGRATED,
Self::Nvidia => NVIDIA,
}
}
#[must_use]
pub fn localized_str(self) -> &'static str {
match self {
Self::Compute => &super::text::GRAPHICS_MODE_COMPUTE,
Self::Hybrid => &super::text::GRAPHICS_MODE_HYBRID,
Self::Integrated => &super::text::GRAPHICS_MODE_INTEGRATED,
Self::Nvidia => &super::text::GRAPHICS_MODE_NVIDIA,
}
}
}
impl super::Page {
pub fn graphics_mode_view(&self) -> Element<pages::Message> {
let mut container = list_column();
if let Some(graphics_mode) = self.config.graphics_mode {
// Displays the active graphics mode, and a button for configuring it.
container = container.add(cosmic::widget::settings::item(
&*super::text::GRAPHICS_MODE,
row()
.align_items(Alignment::Center)
.push(cosmic::widget::text::body(graphics_mode.localized_str()))
.push(
button::icon(icon::from_name("go-next-symbolic"))
.extra_small()
.on_press(Message::GraphicsModeContext),
),
));
}
// Displays the night light status, and a button for configuring it.
container = container.add(
cosmic::widget::settings::item::builder(&*super::text::NIGHT_LIGHT)
.description(&*super::text::NIGHT_LIGHT_DESCRIPTION)
.control(
row()
.align_items(Alignment::Center)
.push(toggler(None, self.config.night_light_enabled, |enable| {
Message::NightLight(NightLight::Toggle(enable))
}))
.push(
button::icon(icon::from_name("go-next-symbolic"))
.extra_small()
.on_press(Message::NightLightContext),
),
),
);
container
.apply(Element::from)
.map(crate::pages::Message::Displays)
}
pub fn graphics_mode_context_view(&self) -> Element<pages::Message> {
let theme = cosmic::theme::active();
let theme = theme.cosmic();
column::with_capacity(4)
.spacing(theme.space_xs())
.push(view(
&super::text::GRAPHICS_MODE_INTEGRATED,
&super::text::GRAPHICS_MODE_INTEGRATED_DESC,
if let Some(Mode::Integrated) = self.config.graphics_mode {
None
} else {
Some((
&super::text::GRAPHICS_MODE_INTEGRATED_ENABLE,
Message::GraphicsMode(Mode::Integrated),
))
},
))
.push(view(
&super::text::GRAPHICS_MODE_NVIDIA,
&super::text::GRAPHICS_MODE_NVIDIA_DESC,
if let Some(Mode::Nvidia) = self.config.graphics_mode {
None
} else {
Some((
&super::text::GRAPHICS_MODE_NVIDIA_ENABLE,
Message::GraphicsMode(Mode::Nvidia),
))
},
))
.push(view(
&super::text::GRAPHICS_MODE_HYBRID,
&super::text::GRAPHICS_MODE_HYBRID_DESC,
if let Some(Mode::Hybrid) = self.config.graphics_mode {
None
} else {
Some((
&super::text::GRAPHICS_MODE_HYBRID_ENABLE,
Message::GraphicsMode(Mode::Hybrid),
))
},
))
.push(view(
&super::text::GRAPHICS_MODE_COMPUTE,
&super::text::GRAPHICS_MODE_COMPUTE_DESC,
if let Some(Mode::Compute) = self.config.graphics_mode {
None
} else {
Some((
&super::text::GRAPHICS_MODE_COMPUTE_ENABLE,
Message::GraphicsMode(Mode::Compute),
))
},
))
.apply(Element::from)
.map(pages::Message::Displays)
}
/// Change the graphics mode.
pub fn set_graphics_mode(&mut self, mode: Mode) -> Command<crate::app::Message> {
self.config.graphics_mode = Some(mode);
// Runs `system76-power graphics {{mode}}`
cosmic::command::future(async move {
let result = tokio::process::Command::new("system76-power")
.args(["graphics", mode.argument_str()])
.status()
.await;
let page_message =
crate::pages::Message::Displays(Message::GraphicsModeResult(Arc::new(result)));
crate::app::Message::PageMessage(page_message)
})
}
}

View file

@ -0,0 +1,805 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod arrangement;
pub mod graphics;
pub mod text;
use crate::{app, pages};
use arrangement::Arrangement;
use cosmic::iced::{Alignment, Length};
use cosmic::iced_widget::scrollable::{Direction, Properties, RelativeOffset};
use cosmic::widget::{
column, container, dropdown, list_column, segmented_button, tab_bar, toggler,
};
use cosmic::{command, Apply, Command, Element};
use cosmic_randr_shell::{List, Output, OutputKey, Transform};
use cosmic_settings_page::{self as page, section, Section};
use slotmap::{Key, SlotMap};
use std::collections::BTreeMap;
use std::{process::ExitStatus, sync::Arc};
/// Display color depth options
#[derive(Clone, Copy, Debug)]
pub struct ColorDepth(usize);
/// Identifies the content to display in the context drawer
pub enum ContextDrawer {
GraphicsMode,
NightLight,
}
/// Display mirroring options
#[derive(Clone, Copy, Debug)]
pub enum Mirroring {
Disable,
ProjectToAll,
Project(OutputKey),
Mirror(OutputKey),
}
/// Night light preferences
#[derive(Clone, Copy, Debug)]
pub enum NightLight {
/// Toggles night light's automatic scheduling.
AutoSchedule(bool),
/// Sets the night light schedule.
ManualSchedule,
/// Changes the preferred night light temperature.
Temperature(f32),
/// Toggles night light mode
Toggle(bool),
}
#[derive(Clone, Debug)]
pub enum Message {
/// Change placement of display
Position(OutputKey, i32, i32),
/// Changes the active display being configured.
Display(segmented_button::Entity),
/// Set the color depth of a display.
ColorDepth(ColorDepth),
/// Set the color profile of a display.
ColorProfile(usize),
/// Toggles display on or off.
DisplayToggle(bool),
/// Changes the hybrid graphics mode.
GraphicsMode(graphics::Mode),
/// Shows the graphics mode context drawer.
GraphicsModeContext,
/// Status of an applied graphics mode change
GraphicsModeResult(Arc<std::io::Result<ExitStatus>>),
/// Configures mirroring status of a display.
Mirroring(Mirroring),
/// Handle night light preferences.
NightLight(NightLight),
/// Show the night light mode context drawer.
NightLightContext,
/// Set the orientation of a display.
Orientation(Transform),
/// Status of an applied display change.
RandrResult(Arc<std::io::Result<ExitStatus>>),
/// Set the refresh rate of a display.
RefreshRate(usize),
/// Set the resolution of a display.
Resolution(usize),
/// Set the preferred scale for a display.
Scale(usize),
/// Refreshes display outputs.
Update {
/// The current graphics mode
graphics: Option<Arc<std::io::Result<graphics::Mode>>>,
/// Available outputs from cosmic-randr.
randr: Arc<Result<List, cosmic_randr_shell::Error>>,
},
}
impl From<Message> for app::Message {
fn from(message: Message) -> Self {
let page_message = crate::pages::Message::Displays(message);
app::Message::PageMessage(page_message)
}
}
#[derive(Clone, Copy)]
enum Randr {
Position(i32, i32),
RefreshRate(u32),
Resolution(u32, u32),
Scale(u32),
Transform(Transform),
Toggle(bool),
}
/// The page struct for the display settings page.
pub struct Page {
list: List,
display_tabs: segmented_button::SingleSelectModel,
active_display: OutputKey,
config: Config,
cache: ViewCache,
context: Option<ContextDrawer>,
display_arrangement_scrollable: cosmic::widget::Id,
}
impl Default for Page {
fn default() -> Self {
Self {
list: List::default(),
display_tabs: segmented_button::SingleSelectModel::default(),
active_display: OutputKey::default(),
config: Config::default(),
cache: ViewCache::default(),
context: None,
display_arrangement_scrollable: cosmic::widget::Id::unique(),
}
}
}
#[derive(Default)]
struct Config {
/// Whether night light is enabled.
night_light_enabled: bool,
graphics_mode: Option<graphics::Mode>,
refresh_rate: Option<u32>,
resolution: Option<(u32, u32)>,
scale: u32,
}
/// Cached view content for widgets.
#[derive(Default)]
struct ViewCache {
modes: BTreeMap<(u32, u32), Vec<u32>>,
orientations: [&'static str; 4],
refresh_rates: Vec<String>,
resolutions: Vec<String>,
orientation_selected: Option<usize>,
refresh_rate_selected: Option<usize>,
resolution_selected: Option<usize>,
scale_selected: Option<usize>,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
// Graphics switching and light mode
sections.insert(
Section::default()
.descriptions(vec![
text::GRAPHICS_MODE.as_str().into(),
text::GRAPHICS_MODE_COMPUTE_DESC.as_str().into(),
text::GRAPHICS_MODE_HYBRID_DESC.as_str().into(),
text::GRAPHICS_MODE_INTEGRATED_DESC.as_str().into(),
text::GRAPHICS_MODE_NVIDIA_DESC.as_str().into(),
text::NIGHT_LIGHT.as_str().into(),
text::NIGHT_LIGHT_AUTO.as_str().into(),
text::NIGHT_LIGHT_DESCRIPTION.as_str().into(),
])
.view::<Page>(|_binder, page, _section| page.graphics_mode_view()),
),
// Display arrangement
sections.insert(
Section::default()
.title(&*text::DISPLAY_ARRANGEMENT)
.descriptions(vec![
text::DISPLAY_ARRANGEMENT.as_str().into(),
text::DISPLAY_ARRANGEMENT_DESC.as_str().into(),
])
// Show section when there is more than 1 display
.show_while::<Page>(|page| page.list.outputs.len() > 1)
.view::<Page>(|_binder, page, _section| page.display_arrangement_view()),
),
// Display configuration
sections.insert(
Section::default()
.descriptions([
text::DISPLAY.as_str().into(),
text::DISPLAY_REFRESH_RATE.as_str().into(),
text::DISPLAY_SCALE.as_str().into(),
text::ORIENTATION.as_str().into(),
text::ORIENTATION_STANDARD.as_str().into(),
text::ORIENTATION_ROTATE_90.as_str().into(),
])
.view::<Page>(|_binder, page, _section| page.display_view()),
),
])
}
fn info(&self) -> page::Info {
page::Info::new("display", "preferences-desktop-display-symbolic")
.title(fl!("display"))
.description(fl!("display", "desc"))
}
#[cfg(not(feature = "test"))]
fn reload(&mut self, _page: page::Entity) -> Command<crate::pages::Message> {
command::future(reload())
}
#[cfg(feature = "test")]
fn reload(&mut self, _page: page::Entity) -> Command<crate::pages::Message> {
command::future(async move {
let mut randr = List::default();
let test_mode = randr.modes.insert(cosmic_randr_shell::Mode {
size: (1920, 1080),
refresh_rate: 144_000,
preferred: true,
});
randr.outputs.insert(cosmic_randr_shell::Output {
name: "Dummy-1".into(),
enabled: true,
make: None,
model: "Test 1".into(),
physical: (1, 1),
position: (0, 0),
scale: 1.0,
transform: Some(Transform::Normal),
modes: vec![test_mode],
current: Some(test_mode),
});
randr.outputs.insert(cosmic_randr_shell::Output {
name: "Dummy-2".into(),
enabled: true,
make: None,
model: "Test 1".into(),
physical: (1, 1),
position: (1920, 0),
scale: 1.0,
transform: Some(Transform::Normal),
modes: vec![test_mode],
current: Some(test_mode),
});
crate::pages::Message::Displays(Message::Update {
graphics: graphics::fetch().await,
randr: Arc::new(Ok(randr)),
})
})
}
fn context_drawer(&self) -> Option<Element<pages::Message>> {
Some(match self.context {
Some(ContextDrawer::GraphicsMode) => self.graphics_mode_context_view(),
Some(ContextDrawer::NightLight) => self.night_light_context_view(),
None => return None,
})
}
}
impl Page {
pub fn update(&mut self, message: Message) -> Command<app::Message> {
match message {
Message::RandrResult(result) => {
if let Some(Err(why)) = Arc::into_inner(result) {
tracing::error!(?why, "cosmic-randr error");
}
return cosmic::command::future(async {
crate::Message::PageMessage(reload().await)
});
}
Message::Display(display) => self.set_display(display),
Message::ColorDepth(color_depth) => return self.set_color_depth(color_depth),
Message::ColorProfile(profile) => return self.set_color_profile(profile),
Message::DisplayToggle(enable) => return self.toggle_display(enable),
Message::GraphicsMode(mode) => return self.set_graphics_mode(mode),
Message::GraphicsModeContext => {
self.context = Some(ContextDrawer::GraphicsMode);
return cosmic::command::message(app::Message::OpenContextDrawer(
text::GRAPHICS_MODE.clone().into(),
));
}
Message::GraphicsModeResult(result) => {
if let Some(Err(why)) = Arc::into_inner(result) {
tracing::error!(?why, "system76-power error");
}
}
Message::Mirroring(mirroring) => match mirroring {
Mirroring::Disable => (),
Mirroring::Mirror(target_display) => (),
Mirroring::Project(target_display) => (),
Mirroring::ProjectToAll => (),
},
Message::NightLight(night_light) => {}
Message::NightLightContext => {
self.context = Some(ContextDrawer::NightLight);
return cosmic::command::message(app::Message::OpenContextDrawer(
text::NIGHT_LIGHT.clone().into(),
));
}
Message::Orientation(orientation) => return self.set_orientation(orientation),
Message::Position(display, x, y) => return self.set_position(display, x, y),
Message::RefreshRate(rate) => return self.set_refresh_rate(rate),
Message::Resolution(option) => return self.set_resolution(option),
Message::Scale(scale) => return self.set_scale(scale),
Message::Update { graphics, randr } => {
match graphics.and_then(Arc::into_inner) {
Some(Ok(mode)) => {
self.config.graphics_mode = Some(mode);
}
Some(Err(why)) => {
tracing::error!(?why, "error fetching graphics switching mode");
}
None => (),
}
match Arc::into_inner(randr) {
Some(Ok(outputs)) => self.update_displays(outputs),
Some(Err(why)) => {
tracing::error!(?why, "error fetching displays");
}
None => (),
}
self.cache.orientations = [
text::ORIENTATION_STANDARD.as_str(),
text::ORIENTATION_ROTATE_90.as_str(),
text::ORIENTATION_ROTATE_180.as_str(),
text::ORIENTATION_ROTATE_270.as_str(),
];
}
}
cosmic::iced::widget::scrollable::snap_to(
self.display_arrangement_scrollable.clone(),
RelativeOffset { x: 0.5, y: 0.5 },
)
}
/// View for the display arrangement section.
pub fn display_arrangement_view(&self) -> Element<pages::Message> {
let theme = cosmic::theme::active();
column()
.padding(cosmic::iced::Padding::from([
theme.cosmic().space_s(),
theme.cosmic().space_m(),
]))
.spacing(theme.cosmic().space_xs())
.push(cosmic::widget::text::body(&*text::DISPLAY_ARRANGEMENT_DESC))
.push({
Arrangement::new(&self.list, &self.display_tabs)
.on_select(|id| pages::Message::Displays(Message::Display(id)))
.on_placement(|id, x, y| pages::Message::Displays(Message::Position(id, x, y)))
.apply(cosmic::widget::scrollable)
.id(self.display_arrangement_scrollable.clone())
.width(Length::Shrink)
.direction(Direction::Horizontal(Properties::new()))
.apply(container)
.center_x()
.width(Length::Fill)
})
.apply(cosmic::widget::list::container)
.into()
}
/// View for the display configuration section.
pub fn display_view(&self) -> Element<pages::Message> {
let theme = cosmic::theme::active();
let Some(&active_id) = self.display_tabs.active_data::<OutputKey>() else {
return column().into();
};
let active_output = &self.list.outputs[active_id];
let display_options = list_column()
.add(cosmic::widget::settings::item(
&*text::DISPLAY_RESOLUTION,
dropdown(
&self.cache.resolutions,
self.cache.resolution_selected,
Message::Resolution,
),
))
.add(cosmic::widget::settings::item(
&*text::DISPLAY_REFRESH_RATE,
dropdown(
&self.cache.refresh_rates,
self.cache.refresh_rate_selected,
Message::RefreshRate,
),
))
.add(cosmic::widget::settings::item(
&*text::DISPLAY_SCALE,
dropdown(
&["50%", "75%", "100%", "125%", "150%", "175%", "200%"],
self.cache.scale_selected,
Message::Scale,
),
))
.add(cosmic::widget::settings::item(
&*text::ORIENTATION,
dropdown(
&self.cache.orientations,
self.cache.orientation_selected,
|id| {
Message::Orientation(match id {
0 => Transform::Normal,
1 => Transform::Rotate90,
2 => Transform::Rotate180,
_ => Transform::Rotate270,
})
},
),
));
let mut content = column().spacing(theme.cosmic().space_m());
if self.list.outputs.len() > 1 {
let display_switcher = tab_bar::horizontal(&self.display_tabs)
.button_alignment(Alignment::Center)
.on_activate(Message::Display);
let display_enable = list_column().add(cosmic::widget::settings::item(
&*text::DISPLAY_ENABLE,
toggler(None, active_output.enabled, Message::DisplayToggle),
));
content = content.push(display_switcher).push(display_enable);
}
content
.push(cosmic::widget::text::heading(&*text::DISPLAY_OPTIONS))
.push(display_options)
.apply(Element::from)
.map(pages::Message::Displays)
}
/// Displays the night light context drawer.
pub fn night_light_context_view(&self) -> Element<pages::Message> {
column().into()
}
/// Reloads the display list, and all information relevant to the active display.
pub fn update_displays(&mut self, list: List) {
self.active_display = OutputKey::null();
self.display_tabs.clear();
self.list = list;
let sorted_outputs = self
.list
.outputs
.iter()
.map(|(key, output)| (&*output.name, key))
.collect::<BTreeMap<_, _>>();
for (name, id) in sorted_outputs {
let Some(output) = self.list.outputs.get(id) else {
continue;
};
self.display_tabs
.insert()
.text(crate::utils::display_name(&output.name, output.physical))
.data::<OutputKey>(id);
}
self.display_tabs.activate_position(0);
// Retrieve data for the first, activated display.
self.set_display(self.display_tabs.active());
}
/// Changes the color depth of the active display.
pub fn set_color_depth(&mut self, depth: ColorDepth) -> Command<app::Message> {
unimplemented!()
}
/// Changes the color profile of the active display.
pub fn set_color_profile(&mut self, profile: usize) -> Command<app::Message> {
unimplemented!()
}
/// Changes the active display, and regenerates available options for it.
pub fn set_display(&mut self, display: segmented_button::Entity) {
let Some(&output_id) = self.display_tabs.data::<OutputKey>(display) else {
return;
};
let Some(output) = self.list.outputs.get_mut(output_id) else {
return;
};
self.display_tabs.activate(display);
self.active_display = output_id;
self.config.refresh_rate = None;
self.config.resolution = None;
self.config.scale = (output.scale * 100.0) as u32;
self.cache.modes.clear();
self.cache.refresh_rates.clear();
self.cache.resolutions.clear();
self.cache.orientation_selected = match output.transform {
Some(Transform::Normal) => Some(0),
Some(Transform::Rotate90) => Some(1),
Some(Transform::Rotate180) => Some(2),
Some(Transform::Rotate270) => Some(3),
_ => None,
};
self.cache.resolution_selected = None;
self.cache.refresh_rate_selected = None;
self.cache.scale_selected = Some(if self.config.scale < 75 {
0
} else if self.config.scale < 100 {
1
} else if self.config.scale < 125 {
2
} else if self.config.scale < 150 {
3
} else if self.config.scale < 175 {
4
} else if self.config.scale < 200 {
5
} else {
6
});
if let Some(current_mode_id) = output.current {
for (mode_id, mode) in output
.modes
.iter()
.filter_map(|&id| self.list.modes.get(id).map(|m| (id, m)))
{
let refresh_rates = self.cache.modes.entry(mode.size).or_default();
refresh_rates.push(mode.refresh_rate);
if current_mode_id == mode_id {
self.cache.refresh_rate_selected = Some(refresh_rates.len() - 1);
self.cache.resolution_selected = Some(self.cache.modes.len() - 1);
self.config.resolution = Some(mode.size);
self.config.refresh_rate = Some(mode.refresh_rate);
}
}
}
for (&resolution, rates) in self.cache.modes.iter().rev() {
self.cache
.resolutions
.push(format!("{}x{}", resolution.0, resolution.1));
if Some(resolution) == self.config.resolution {
cache_rates(&mut self.cache.refresh_rates, rates);
}
}
}
/// Change display orientation.
pub fn set_orientation(&mut self, transform: Transform) -> Command<app::Message> {
let Some(output) = self.list.outputs.get(self.active_display) else {
return Command::none();
};
self.cache.orientation_selected = match transform {
Transform::Normal => Some(0),
Transform::Rotate90 => Some(1),
Transform::Rotate180 => Some(2),
_ => Some(3),
};
self.exec_randr(output, Randr::Transform(transform))
}
/// Changes the position of the display.
pub fn set_position(&mut self, display: OutputKey, x: i32, y: i32) -> Command<app::Message> {
let Some(output) = self.list.outputs.get_mut(display) else {
return Command::none();
};
output.position = (x, y);
if cfg!(feature = "test") {
tracing::debug!("set position {x},{y}");
return Command::none();
}
let output = &self.list.outputs[display];
self.exec_randr(output, Randr::Position(x, y))
}
/// Changes the refresh rate of the active display.
pub fn set_refresh_rate(&mut self, option: usize) -> Command<app::Message> {
let Some(output) = self.list.outputs.get(self.active_display) else {
return Command::none();
};
if let Some(ref resolution) = self.config.resolution {
if let Some(rates) = self.cache.modes.get(resolution) {
if let Some(&rate) = rates.get(option) {
self.cache.refresh_rate_selected = Some(option);
self.config.refresh_rate = Some(rate);
return self.exec_randr(output, Randr::RefreshRate(rate));
}
}
}
Command::none()
}
/// Change the resolution of the active display.
pub fn set_resolution(&mut self, option: usize) -> Command<app::Message> {
let Some(output) = self.list.outputs.get(self.active_display) else {
return Command::none();
};
let Some((&resolution, rates)) = self.cache.modes.iter().rev().nth(option) else {
return Command::none();
};
self.cache.refresh_rates.clear();
cache_rates(&mut self.cache.refresh_rates, rates);
let Some(&rate) = rates.first() else {
return Command::none();
};
self.config.refresh_rate = Some(rate);
self.config.resolution = Some(resolution);
self.cache.refresh_rate_selected = Some(0);
self.cache.resolution_selected = Some(option);
self.exec_randr(output, Randr::Resolution(resolution.0, resolution.1))
}
/// Set the scale of the active display.
pub fn set_scale(&mut self, option: usize) -> Command<app::Message> {
let Some(output) = self.list.outputs.get(self.active_display) else {
return Command::none();
};
let scale = (option * 25 + 50) as u32;
self.cache.scale_selected = Some(option);
self.config.scale = scale;
self.exec_randr(output, Randr::Scale(scale))
}
/// Enables or disables the active display.
pub fn toggle_display(&mut self, enable: bool) -> Command<app::Message> {
let Some(output) = self.list.outputs.get_mut(self.active_display) else {
return Command::none();
};
output.enabled = enable;
let output = &self.list.outputs[self.active_display];
self.exec_randr(output, Randr::Toggle(output.enabled))
}
/// Applies a display configuration via `cosmic-randr`.
fn exec_randr(&self, output: &Output, request: Randr) -> Command<app::Message> {
let Some(current) = output.current.and_then(|id| self.list.modes.get(id)) else {
return Command::none();
};
let name = &*output.name;
let mut command = tokio::process::Command::new("cosmic-randr");
match request {
Randr::Position(x, y) => {
command
.arg("mode")
.arg("--pos-x")
.arg(itoa::Buffer::new().format(x))
.arg("--pos-y")
.arg(itoa::Buffer::new().format(y))
.arg(name)
.arg(itoa::Buffer::new().format(current.size.0))
.arg(itoa::Buffer::new().format(current.size.1));
}
Randr::RefreshRate(rate) => {
command
.arg("mode")
.arg("--refresh")
.arg(
&[
itoa::Buffer::new().format(rate / 1000),
".",
itoa::Buffer::new().format(rate % 1000),
]
.concat(),
)
.arg(name)
.arg(itoa::Buffer::new().format(current.size.0))
.arg(itoa::Buffer::new().format(current.size.1));
}
Randr::Resolution(width, height) => {
command
.arg("mode")
.arg(name)
.arg(itoa::Buffer::new().format(width))
.arg(itoa::Buffer::new().format(height));
}
Randr::Scale(scale) => {
command
.arg("mode")
.arg("--scale")
.arg(
&[
itoa::Buffer::new().format(scale / 100),
".",
itoa::Buffer::new().format(scale % 100),
]
.concat(),
)
.arg(name)
.arg(itoa::Buffer::new().format(current.size.0))
.arg(itoa::Buffer::new().format(current.size.1));
}
Randr::Toggle(enable) => {
command
.arg(if enable { "enable" } else { "disable" })
.arg(name);
}
Randr::Transform(transform) => {
command
.arg("mode")
.arg("--transform")
.arg(&*format!("{transform}"))
.arg(name)
.arg(itoa::Buffer::new().format(current.size.0))
.arg(itoa::Buffer::new().format(current.size.1));
}
}
cosmic::command::future(async move {
tracing::debug!(?command, "executing");
app::Message::from(Message::RandrResult(Arc::new(command.status().await)))
})
}
}
fn cache_rates(cached_rates: &mut Vec<String>, rates: &[u32]) {
*cached_rates = rates
.iter()
.map(|&rate| format!("{:>3}.{:02} Hz", rate / 1000, rate % 1000))
.collect();
}
pub async fn reload() -> crate::pages::Message {
let graphics_fut = graphics::fetch();
let randr_fut = cosmic_randr_shell::list();
let (graphics, randr) = futures::future::zip(graphics_fut, randr_fut).await;
crate::pages::Message::Displays(Message::Update {
graphics,
randr: Arc::new(randr),
})
}

View file

@ -0,0 +1,65 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
crate::cache_dynamic_lazy! {
pub static COLOR: String = fl!("color");
pub static COLOR_DEPTH: String = fl!("color", "depth");
pub static COLOR_PROFILE: String = fl!("color", "profile");
pub static COLOR_PROFILES: String = fl!("color", "sidebar");
pub static COLOR_TEMPERATURE: String = fl!("color", "temperature");
pub static DISPLAY: String = fl!("display");
pub static DISPLAY_ARRANGEMENT: String = fl!("display", "arrangement");
pub static DISPLAY_ARRANGEMENT_DESC: String = fl!("display", "arrangement-desc");
pub static DISPLAY_ENABLE: String = fl!("display", "enable");
pub static DISPLAY_EXTERNAL: String = fl!("display", "external");
pub static DISPLAY_LAPTOP: String = fl!("display", "laptop");
pub static DISPLAY_OPTIONS: String = fl!("display", "options");
pub static DISPLAY_REFRESH_RATE: String = fl!("display", "refresh-rate");
pub static DISPLAY_RESOLUTION: String = fl!("display", "resolution");
pub static DISPLAY_SCALE: String = fl!("display", "scale");
pub static GRAPHICS_MODE: String = fl!("graphics-mode");
pub static GRAPHICS_MODE_COMPUTE: String =
fl!("graphics-mode", "mode", mode = super::graphics::COMPUTE);
pub static GRAPHICS_MODE_COMPUTE_ENABLE: String =
fl!("graphics-mode", "enable", mode = super::graphics::COMPUTE);
pub static GRAPHICS_MODE_COMPUTE_DESC: String =
fl!("graphics-mode", "desc", mode = super::graphics::COMPUTE);
pub static GRAPHICS_MODE_HYBRID: String =
fl!("graphics-mode", "mode", mode = super::graphics::HYBRID);
pub static GRAPHICS_MODE_HYBRID_ENABLE: String =
fl!("graphics-mode", "enable", mode = super::graphics::HYBRID);
pub static GRAPHICS_MODE_HYBRID_DESC: String =
fl!("graphics-mode", "desc", mode = super::graphics::HYBRID);
pub static GRAPHICS_MODE_INTEGRATED: String =
fl!("graphics-mode", "mode", mode = super::graphics::INTEGRATED);
pub static GRAPHICS_MODE_INTEGRATED_ENABLE: String = fl!(
"graphics-mode",
"enable",
mode = super::graphics::INTEGRATED
);
pub static GRAPHICS_MODE_INTEGRATED_DESC: String =
fl!("graphics-mode", "desc", mode = super::graphics::INTEGRATED);
pub static GRAPHICS_MODE_NVIDIA: String =
fl!("graphics-mode", "mode", mode = super::graphics::NVIDIA);
pub static GRAPHICS_MODE_NVIDIA_ENABLE: String =
fl!("graphics-mode", "enable", mode = super::graphics::NVIDIA);
pub static GRAPHICS_MODE_NVIDIA_DESC: String =
fl!("graphics-mode", "desc", mode = super::graphics::NVIDIA);
pub static MIRRORING: String = fl!("mirroring");
pub static NIGHT_LIGHT: String = fl!("night-light");
pub static NIGHT_LIGHT_AUTO: String = fl!("night-light", "auto");
pub static NIGHT_LIGHT_DESCRIPTION: String = fl!("night-light", "desc");
pub static ORIENTATION: String = fl!("orientation");
pub static ORIENTATION_STANDARD: String = fl!("orientation", "standard");
pub static ORIENTATION_ROTATE_90: String = fl!("orientation", "rotate-90");
pub static ORIENTATION_ROTATE_180: String = fl!("orientation", "rotate-180");
pub static ORIENTATION_ROTATE_270: String = fl!("orientation", "rotate-270");
pub static SCHEDULING: String = fl!("scheduling");
pub static SCHEDULING_MANUAL: String = fl!("scheduling", "manual");
}

View file

@ -0,0 +1,327 @@
use cosmic::{
iced::{
self,
widget::{self, horizontal_space},
window, Length,
},
iced_core::Border,
iced_style, theme,
widget::{button, container, icon, radio, settings},
Apply,
};
use cosmic_settings_page::{self as page, section, Section};
use once_cell::sync::Lazy;
use slotmap::SlotMap;
use super::Message;
pub static ADD_INPUT_SOURCE_DIALOGUE_ID: Lazy<window::Id> = Lazy::new(window::Id::unique);
pub static SPECIAL_CHARACTER_DIALOGUE_ID: Lazy<window::Id> = Lazy::new(window::Id::unique);
static COMPOSE_OPTIONS: &[(&str, &str)] = &[
// ("Left Alt", "compose:lalt"), XXX?
("Right Alt", "compose:ralt"),
("Left Super", "compose:lwin"),
("Right Super", "compose:rwin"),
("Menu key", "compose:menu"),
("Right Ctrl", "compose:rctrl"),
("Caps Lock", "compose:caps"),
("Scroll Lock", "compose:sclk"),
("Print Screen", "compose:prsc"),
];
static ALTERNATE_CHARACTER_OPTIONS: &[(&str, &str)] = &[
("Left Alt", "lv3:lalt_switch"),
("Right Alt", "lv3:alt_switch"),
("Left Super", "lv3:lwin_switch"),
("Right Super", "lv3:win_switch"),
("Menu key", "lv3:menu_switch"),
// ("Right Ctrl", "lv3:"), XXX
("Caps Lock", "lv3:caps_switch"),
// ("Scroll Lock", "lv3:"), XXX
// ("Print Screen", "lv3"), XXX
];
#[derive(Copy, Clone, Debug)]
pub enum SpecialKey {
AlternateCharacters,
Compose,
}
impl SpecialKey {
pub fn title(self) -> String {
match self {
Self::Compose => "Compose".to_string(),
Self::AlternateCharacters => "Alternate Characters".to_string(),
}
}
pub fn prefix(self) -> &'static str {
match self {
Self::Compose => "compose:",
Self::AlternateCharacters => "lv3:",
}
}
}
fn popover_menu_row(label: String) -> cosmic::Element<'static, Message> {
widget::text(label)
.apply(widget::container)
.style(cosmic::theme::Container::custom(|theme| {
iced_style::container::Appearance {
background: None,
..container::StyleSheet::appearance(theme, &cosmic::theme::Container::List)
}
}))
.apply(button)
.style(theme::Button::Transparent)
.into()
}
// TODO for on press, would need to clone ID for each row?
fn popover_menu() -> cosmic::Element<'static, Message> {
// XXX translate
widget::column![
popover_menu_row(fl!("keyboard-sources", "move-up")),
popover_menu_row(fl!("keyboard-sources", "move-down")),
//cosmic::widget::divider::horizontal::light(),
cosmic::widget::divider::horizontal::light(),
popover_menu_row(fl!("keyboard-sources", "settings")),
popover_menu_row(fl!("keyboard-sources", "view-layout")),
popover_menu_row(fl!("keyboard-sources", "remove")),
]
.width(Length::Shrink)
.height(Length::Shrink)
.apply(cosmic::widget::container)
.style(cosmic::theme::Container::custom(|theme| {
let cosmic = theme.cosmic();
container::Appearance {
icon_color: Some(theme.cosmic().background.on.into()),
text_color: Some(theme.cosmic().background.on.into()),
background: Some(iced::Color::from(theme.cosmic().background.base).into()),
border: Border {
radius: cosmic.corner_radii.radius_m.into(),
..Default::default()
},
shadow: Default::default(),
}
}))
.into()
}
fn popover_button(input_source: &InputSource, expanded: bool) -> cosmic::Element<'static, Message> {
let on_press = Message::ExpandInputSourcePopover(if expanded {
None
} else {
Some(input_source.id.clone())
});
let button = button::icon(icon::from_name("open-menu-symbolic"))
.extra_small()
.padding(0)
.on_press(on_press);
if expanded {
cosmic::widget::popover(button).popup(popover_menu()).into()
} else {
button.into()
}
}
fn input_source<'a>(
input_source: &'a InputSource,
expanded_source_popover: Option<&'a str>,
) -> cosmic::Element<'a, Message> {
let expanded = expanded_source_popover == Some(input_source.id.as_str());
settings::item(&input_source.label, popover_button(input_source, expanded)).into()
}
pub mod shortcuts;
pub struct InputSource {
id: String,
// TODO Translate?
label: String,
}
impl super::Page {
pub fn add_input_source_view(&self) -> cosmic::Element<'static, crate::app::Message> {
widget::column![].into()
}
pub fn special_character_key_view(&self) -> cosmic::Element<'_, crate::app::Message> {
let Some(special_key) = self.special_character_dialog else {
return widget::text("").into();
};
let options = match special_key {
SpecialKey::Compose => COMPOSE_OPTIONS,
SpecialKey::AlternateCharacters => ALTERNATE_CHARACTER_OPTIONS,
};
let prefix = special_key.prefix();
let current = self
.xkb
.options
.iter()
.flat_map(|x| x.split(','))
.find(|x| x.starts_with(prefix));
// TODO description, layout default
let mut list = cosmic::widget::list_column();
list = list.add(special_char_radio_row("None", None, current));
for (desc, id) in options {
list = list.add(special_char_radio_row(desc, Some(id), current));
}
widget::column![
cosmic::widget::header_bar()
.title(special_key.title())
.on_close(Message::CloseSpecialCharacterDialog),
cosmic::widget::container(
cosmic::widget::scrollable(cosmic::widget::container(list).padding(24))
.width(Length::Fill)
.height(Length::Fill)
)
.style(theme::Container::Background)
.width(Length::Fill)
.height(Length::Fill)
]
.apply(cosmic::Element::from)
.map(crate::pages::Message::Input)
.map(crate::app::Message::PageMessage)
}
}
fn special_char_radio_row<'a>(
desc: &'a str,
value: Option<&'static str>,
current_value: Option<&'a str>,
) -> cosmic::Element<'a, Message> {
settings::item_row(vec![radio(desc, value, Some(current_value), |_| {
Message::SpecialCharacterSelect(value)
})
.into()])
.into()
}
#[derive(Default)]
pub struct Page;
// XXX
pub fn default_input_sources() -> Vec<InputSource> {
vec![InputSource {
id: "us".to_string(),
label: "English (US)".to_string(),
}]
}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(input_sources()),
sections.insert(special_character_entry()),
sections.insert(keyboard_shortcuts()),
])
}
fn info(&self) -> page::Info {
page::Info::new("keyboard", "input-keyboard-symbolic")
.title(fl!("keyboard"))
.description(fl!("keyboard", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<shortcuts::Page>()
}
}
fn input_sources() -> Section<crate::pages::Message> {
// TODO desc
Section::default()
.title(fl!("keyboard-sources"))
.view::<Page>(|binder, _page, section| {
let input = binder.page::<super::Page>().expect("input page not found");
// TODO Need something more custom, with drag and drop
let mut section = settings::view_section(&section.title);
let expanded_source = input.expanded_source_popover.as_deref();
for source in &input.sources {
section = section.add(input_source(source, expanded_source));
}
section
.apply(cosmic::Element::from)
.map(crate::pages::Message::Input)
})
}
fn special_character_entry() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("keyboard-special-char"))
.descriptions(vec![
fl!("keyboard-special-char", "alternate").into(),
fl!("keyboard-special-char", "compose").into(),
])
.view::<Page>(|_binder, _page, section| {
let descriptions = &section.descriptions;
// TODO dialogs
settings::view_section(&section.title)
.add(go_next_item(
&*descriptions[0],
Message::OpenSpecialCharacterDialog(SpecialKey::AlternateCharacters),
))
.add(go_next_item(
&*descriptions[1],
Message::OpenSpecialCharacterDialog(SpecialKey::Compose),
))
.apply(cosmic::Element::from)
.map(crate::pages::Message::Input)
})
}
fn keyboard_shortcuts() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("keyboard-shortcuts"))
.descriptions(vec![fl!("keyboard-shortcuts", "desc").into()])
.view::<Page>(|binder, _page, section| {
let descriptions = &section.descriptions;
let mut section = settings::view_section(&section.title);
if let Some((shortcuts_entity, _)) = binder
.info
.iter()
.find(|(_, v)| v.id == "keyboard-shortcuts")
{
section = section.add(go_next_item(
&*descriptions[0],
crate::pages::Message::Page(shortcuts_entity),
));
}
section.apply(cosmic::Element::from)
})
}
fn go_next_control<Msg: Clone + 'static>() -> cosmic::Element<'static, Msg> {
widget::row!(
horizontal_space(Length::Fill),
icon::from_name("go-next-symbolic").size(16).icon(),
)
.into()
}
fn go_next_item<Msg: Clone + 'static>(description: &str, msg: Msg) -> cosmic::Element<'_, Msg> {
settings::item(description, go_next_control())
.apply(widget::container)
.style(cosmic::theme::Container::List)
.apply(button)
.style(theme::Button::Transparent)
.on_press(msg)
.into()
}

View file

@ -0,0 +1,44 @@
use cosmic::widget::{column, settings};
use cosmic::{Apply, Element};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
//crate::app::Message::Page
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(shortcuts())])
}
fn info(&self) -> page::Info {
page::Info::new("keyboard-shortcuts", "input-keyboard-symbolic")
.title(fl!("keyboard-shortcuts"))
.description(fl!("keyboard-shortcuts", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
fn shortcuts() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![])
.view::<Page>(|_binder, _page, section| {
// TODO need something more custom
/*
settings::view_section(&section.title)
.apply(Element::from)
.map(crate::pages::Message::Input)
*/
column()
.push(settings::view_section(&section.title))
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}

View file

@ -0,0 +1,276 @@
use crate::app;
use cosmic::{
cosmic_config::{self, ConfigGet, ConfigSet},
iced::{self, wayland::actions::window::SctkWindowSettings, window},
iced_sctk::commands,
iced_widget::core::layout,
};
use cosmic_comp_config::{
input::{
AccelConfig, AccelProfile, ClickMethod, InputConfig, ScrollConfig, ScrollMethod,
TapButtonMap, TapConfig,
},
XkbConfig,
};
use cosmic_settings_page as page;
use itertools::Itertools;
use tracing::error;
pub mod keyboard;
pub mod mouse;
pub mod touchpad;
crate::cache_dynamic_lazy! {
static ACCELERATION_DESC: String = fl!("acceleration-desc");
static DISABLE_WHILE_TYPING: String = fl!("disable-while-typing");
static PRIMARY_BUTTON: String = fl!("primary-button");
static SCROLLING_EDGE: String = fl!("scrolling", "edge");
static SCROLLING_NATURAL_DESC: String = fl!("scrolling", "natural-desc");
static SCROLLING_NATURAL: String = fl!("scrolling", "natural");
static SCROLLING_SPEED: String = fl!("scrolling", "speed");
static SCROLLING_TWO_FINGER: String = fl!("scrolling", "two-finger");
}
#[derive(Clone, Debug)]
pub enum Message {
CloseSpecialCharacterDialog,
// seperate close message, to make sure another isn't closed?
DisableWhileTyping(bool, bool),
ExpandInputSourcePopover(Option<String>),
OpenSpecialCharacterDialog(keyboard::SpecialKey),
PrimaryButtonSelected(cosmic::widget::segmented_button::Entity, bool),
SetAcceleration(bool, bool),
SetMouseSpeed(f64, bool),
SetNaturalScroll(bool, bool),
SetSecondaryClickBehavior(Option<ClickMethod>, bool),
SetScrollFactor(f64, bool),
SetScrollMethod(Option<ScrollMethod>, bool),
SpecialCharacterSelect(Option<&'static str>),
TapToClick(bool),
}
pub struct Page {
config: cosmic_config::Config,
input_default: InputConfig,
#[allow(dead_code)]
input_touchpad: InputConfig,
// Mouse
primary_button: cosmic::widget::segmented_button::SingleSelectModel,
// Touchpad
touchpad_primary_button: cosmic::widget::segmented_button::SingleSelectModel,
// Keyboard
expanded_source_popover: Option<String>,
sources: Vec<keyboard::InputSource>,
special_character_dialog: Option<keyboard::SpecialKey>,
xkb: XkbConfig,
}
fn get_config<T: Default + serde::de::DeserializeOwned>(
config: &cosmic_config::Config,
key: &str,
) -> T {
config.get(key).unwrap_or_else(|why| {
error!(?why, "Failed to read config '{}'", key);
T::default()
})
}
impl Default for Page {
fn default() -> Self {
let config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap();
let input_default: InputConfig = get_config(&config, "input_default");
let input_touchpad: InputConfig = get_config(&config, "input_touchpad");
let xkb = get_config(&config, "xkb_config");
let mut primary_button = mouse::default_primary_button();
let idx = input_default.left_handed.unwrap_or(false) as u16;
primary_button.activate_position(idx);
let mut touchpad_primary_button = mouse::default_primary_button();
let idx = input_touchpad.left_handed.unwrap_or(false) as u16;
touchpad_primary_button.activate_position(idx);
Self {
config,
input_default,
input_touchpad,
// Mouse
primary_button,
// Touchpad
touchpad_primary_button,
// Keyboard
expanded_source_popover: None,
sources: keyboard::default_input_sources(),
special_character_dialog: None,
xkb,
}
}
}
impl Page {
fn update_input<F: Fn(&mut InputConfig)>(&mut self, touchpad: bool, f: F) {
let (name, input_config) = if touchpad {
("input_touchpad", &mut self.input_touchpad)
} else {
("input_default", &mut self.input_default)
};
f(input_config);
if let Err(err) = self.config.set(name, input_config) {
error!(?err, "Failed to set config '{}'", name);
}
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> iced::Command<app::Message> {
match message {
Message::SetAcceleration(value, touchpad) => {
let profile = if value {
AccelProfile::Adaptive
} else {
AccelProfile::Flat
};
self.update_input(touchpad, |x| {
x.acceleration.get_or_insert(AccelConfig::default()).profile = Some(profile);
});
}
Message::SetMouseSpeed(value, touchpad) => self.update_input(touchpad, |x| {
x.acceleration.get_or_insert(AccelConfig::default()).speed = value;
}),
Message::DisableWhileTyping(disabled, touchpad) => {
self.update_input(touchpad, |conf| {
conf.disable_while_typing = Some(disabled);
});
}
Message::SetNaturalScroll(enabled, touchpad) => self.update_input(touchpad, |x| {
x.scroll_config
.get_or_insert(ScrollConfig::default())
.natural_scroll = Some(enabled);
}),
Message::SetSecondaryClickBehavior(click_method, touchpad) => {
self.update_input(touchpad, |x| {
x.click_method = click_method;
})
}
Message::SetScrollFactor(value, touchpad) => self.update_input(touchpad, |x| {
x.scroll_config
.get_or_insert(ScrollConfig::default())
.scroll_factor = Some(value);
}),
Message::SetScrollMethod(method, touchpad) => {
self.update_input(touchpad, |conf| {
conf.scroll_config
.get_or_insert(ScrollConfig::default())
.method = method;
});
}
Message::PrimaryButtonSelected(entity, touchpad) => {
let select_model = if touchpad {
&mut self.touchpad_primary_button
} else {
&mut self.primary_button
};
select_model.activate(entity);
let Some(left_entity) = select_model.entity_at(1) else {
return cosmic::Command::none();
};
let left_handed = select_model.active() == left_entity;
self.update_input(touchpad, |x| x.left_handed = Some(left_handed));
}
Message::ExpandInputSourcePopover(value) => {
self.expanded_source_popover = value;
}
Message::OpenSpecialCharacterDialog(special_key) => {
self.special_character_dialog = Some(special_key);
let window_settings = SctkWindowSettings {
window_id: *keyboard::SPECIAL_CHARACTER_DIALOGUE_ID,
app_id: Some("com.system76.CosmicSettings".to_string()),
title: Some(special_key.title()),
parent: Some(window::Id::MAIN),
autosize: false,
size_limits: layout::Limits::NONE
.min_width(300.0)
.max_width(800.0)
.min_height(200.0)
.max_height(1080.0),
size: (512, 420),
resizable: None,
client_decorations: true,
transparent: true,
..Default::default()
};
return commands::window::get_window(window_settings);
}
Message::CloseSpecialCharacterDialog => {
self.special_character_dialog = None;
return commands::window::close_window(*keyboard::SPECIAL_CHARACTER_DIALOGUE_ID);
}
Message::SpecialCharacterSelect(id) => {
if let Some(special_key) = self.special_character_dialog {
let options = self.xkb.options.as_deref().unwrap_or("");
let prefix = special_key.prefix();
let new_options = options
.split(',')
.filter(|x| !x.starts_with(prefix))
.chain(id)
.join(",");
self.xkb.options = Some(new_options).filter(|x| !x.is_empty());
if let Err(err) = self.config.set("xkb_config", &self.xkb) {
error!(?err, "Failed to set config 'xkb_config'");
}
}
}
Message::TapToClick(enabled) => {
self.update_input(true, |conf| {
conf.tap_config
.get_or_insert(TapConfig {
enabled: true,
button_map: Some(TapButtonMap::LeftRightMiddle),
drag: true,
drag_lock: false,
})
.enabled = enabled;
});
}
}
cosmic::Command::none()
}
}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
// XXX icon?
page::Info::new("input-devices", "input-keyboard-symbolic")
.title(fl!("input-devices"))
.description(fl!("input-devices", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<keyboard::Page>()
.sub_page::<mouse::Page>()
.sub_page::<touchpad::Page>()
}
}

View file

@ -0,0 +1,154 @@
use cosmic::iced::Alignment;
use cosmic::widget::{self, row, settings, text};
use cosmic::{Apply, Element};
use cosmic_comp_config::input::AccelProfile;
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
use super::Message;
crate::cache_dynamic_lazy! {
static MOUSE_ACCELERATION: String = fl!("mouse", "acceleration");
static MOUSE_SPEED: String = fl!("mouse", "speed");
}
pub fn default_primary_button() -> cosmic::widget::segmented_button::SingleSelectModel {
let mut model = cosmic::widget::segmented_button::SingleSelectModel::builder()
.insert(|b| b.text(fl!("primary-button", "left")))
.insert(|b| b.text(fl!("primary-button", "right")))
.build();
model.activate_position(0);
model
}
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(mouse()), sections.insert(scrolling())])
}
fn info(&self) -> page::Info {
page::Info::new("mouse", "input-mouse-symbolic")
.title(fl!("mouse"))
.description(fl!("mouse", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
fn mouse() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![
super::PRIMARY_BUTTON.as_str().into(),
MOUSE_SPEED.as_str().into(),
MOUSE_ACCELERATION.as_str().into(),
super::ACCELERATION_DESC.as_str().into(),
])
.view::<Page>(|binder, _page, section| {
let input = binder.page::<super::Page>().expect("input page not found");
let theme = cosmic::theme::active();
settings::view_section(&section.title)
.add(settings::item(
&*super::PRIMARY_BUTTON,
cosmic::widget::segmented_control::horizontal(&input.primary_button)
.minimum_button_width(0)
.on_activate(|x| Message::PrimaryButtonSelected(x, false)),
))
.add(settings::item::builder(&*MOUSE_SPEED).control({
let value = (input
.input_default
.acceleration
.as_ref()
.map_or(0.0, |x| x.speed)
+ 1.0)
* 50.0;
let slider = widget::slider(10.0..=80.0, value, |value| {
Message::SetMouseSpeed((value / 50.0) - 1.0, false)
})
.width(250.0)
.breakpoints(&[45.0]);
row::with_capacity(2)
.align_items(Alignment::Center)
.spacing(theme.cosmic().space_s())
.push(text(format!("{:.0}", value.round())))
.push(slider)
}))
.add(
settings::item::builder(&*MOUSE_ACCELERATION)
.description(&*super::ACCELERATION_DESC)
.toggler(
input
.input_default
.acceleration
.as_ref()
.map_or(true, |x| x.profile == Some(AccelProfile::Adaptive)),
|x| Message::SetAcceleration(x, false),
),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}
fn scrolling() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("scrolling"))
.descriptions(vec![
super::SCROLLING_SPEED.as_str().into(),
super::SCROLLING_NATURAL.as_str().into(),
super::SCROLLING_NATURAL_DESC.as_str().into(),
])
.view::<Page>(|binder, _page, section| {
let input = binder.page::<super::Page>().expect("input page not found");
let theme = cosmic::theme::active();
settings::view_section(&section.title)
.add(settings::item(&*super::SCROLLING_SPEED, {
let value = input
.input_default
.scroll_config
.as_ref()
.and_then(|x| x.scroll_factor)
.unwrap_or(1.)
.log(2.)
* 10.0
+ 50.0;
let slider = widget::slider(1.0..=100.0, value, |value| {
Message::SetScrollFactor(2f64.powf((value - 50.0) / 10.0), false)
})
.width(250.0)
.breakpoints(&[50.0]);
row::with_capacity(2)
.align_items(Alignment::Center)
.spacing(theme.cosmic().space_s())
.push(text(format!("{:.0}", value.round())))
.push(slider)
}))
.add(
settings::item::builder(&*super::SCROLLING_NATURAL)
.description(&*super::SCROLLING_NATURAL_DESC)
.toggler(
input
.input_default
.scroll_config
.as_ref()
.and_then(|x| x.natural_scroll)
.unwrap_or(false),
|x| Message::SetNaturalScroll(x, false),
),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}

View file

@ -0,0 +1,301 @@
use cosmic::iced::Alignment;
use cosmic::widget::{self, row, settings, text};
use cosmic::{Apply, Element};
use cosmic_comp_config::input::{AccelProfile, ClickMethod, ScrollMethod};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
use super::Message;
crate::cache_dynamic_lazy! {
static CLICK_BEHAVIOR_CLICK_FINGER: String = fl!("click-behavior", "click-finger");
static CLICK_BEHAVIOR_BUTTON_AREAS: String = fl!("click-behavior", "button-areas");
static TAP_TO_CLICK: String = fl!("tap-to-click");
static TAP_TO_CLICK_DESC: String = fl!("tap-to-click", "desc");
static TOUCHPAD_ACCELERAION: String = fl!("touchpad", "acceleration");
static TOUCHPAD_SPEED: String = fl!("touchpad", "speed");
static OPEN_APPLICATION_LIBRARY: String = fl!("open-application-library");
static OPEN_WORKSPACES_VIEW: String = fl!("open-workspaces-view");
static SWIPING_FOUR_FINGER_DOWN: String = fl!("swiping", "four-finger-down");
static SWIPING_FOUR_FINGER_LEFT: String = fl!("swiping", "four-finger-left");
static SWIPING_FOUR_FINGER_RIGHT: String = fl!("swiping", "four-finger-right");
static SWIPING_FOUR_FINGER_UP: String = fl!("swiping", "four-finger-up");
static SWIPING_THREE_FINGER_ANY: String = fl!("swiping", "three-finger-any");
static SWITCH_BETWEEN_WINDOWS: String = fl!("switch-between-windows");
static SWITCH_TO_NEXT_WORKSPACE: String = fl!("switch-to-next-workspace");
static SWITCH_TO_PREV_WORKSPACE: String = fl!("switch-to-prev-workspace");
}
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(touchpad()),
sections.insert(click_behavior()),
sections.insert(scrolling()),
sections.insert(swiping()),
])
}
fn info(&self) -> page::Info {
page::Info::new("touchpad", "input-touchpad-symbolic")
.title(fl!("touchpad"))
.description(fl!("touchpad", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
fn touchpad() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![
super::PRIMARY_BUTTON.as_str().into(),
TOUCHPAD_SPEED.as_str().into(),
TOUCHPAD_ACCELERAION.as_str().into(),
super::ACCELERATION_DESC.as_str().into(),
super::DISABLE_WHILE_TYPING.as_str().into(),
])
.view::<Page>(|binder, _page, section| {
let input = binder.page::<super::Page>().expect("input page not found");
let theme = cosmic::theme::active();
settings::view_section(&section.title)
.add(settings::item(
&*super::PRIMARY_BUTTON,
cosmic::widget::segmented_control::horizontal(&input.touchpad_primary_button)
.minimum_button_width(0)
.on_activate(|x| Message::PrimaryButtonSelected(x, true)),
))
.add(settings::item::builder(&*TOUCHPAD_SPEED).control({
let value = (input
.input_touchpad
.acceleration
.as_ref()
.map_or(0.0, |x| x.speed)
+ 1.0)
* 50.0;
let slider = widget::slider(10.0..=80.0, value, |value| {
Message::SetMouseSpeed((value / 50.0) - 1.0, true)
})
.width(250.0)
.breakpoints(&[45.0]);
row::with_capacity(2)
.align_items(Alignment::Center)
.spacing(theme.cosmic().space_s())
.push(text(format!("{:.0}", value.round())))
.push(slider)
}))
.add(
settings::item::builder(&*TOUCHPAD_ACCELERAION)
.description(&*super::ACCELERATION_DESC)
.toggler(
input
.input_touchpad
.acceleration
.as_ref()
.map_or(true, |x| x.profile == Some(AccelProfile::Adaptive)),
|x| Message::SetAcceleration(x, true),
),
)
.add(
settings::item::builder(&*super::DISABLE_WHILE_TYPING).toggler(
input.input_touchpad.disable_while_typing.unwrap_or(false),
|enabled| Message::DisableWhileTyping(enabled, true),
),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}
fn click_behavior() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("click-behavior"))
.descriptions(vec![
CLICK_BEHAVIOR_CLICK_FINGER.as_str().into(),
CLICK_BEHAVIOR_BUTTON_AREAS.as_str().into(),
TAP_TO_CLICK.as_str().into(),
TAP_TO_CLICK_DESC.as_str().into(),
])
.view::<Page>(|binder, _page, section| {
let page = binder
.page::<super::Page>()
.expect("input devices page not found");
settings::view_section(&*section.title)
// Secondary click via two fingers, and middle-click via three fingers
.add(
settings::item::builder(&*CLICK_BEHAVIOR_CLICK_FINGER).toggler(
page.input_touchpad
.click_method
.as_ref()
.map_or(false, |x| matches!(x, ClickMethod::Clickfinger)),
|enabled| {
Message::SetSecondaryClickBehavior(
enabled.then_some(ClickMethod::Clickfinger),
true,
)
},
),
)
// Secondary and middle-click via button areas.
.add(
settings::item::builder(&*CLICK_BEHAVIOR_BUTTON_AREAS).toggler(
page.input_touchpad
.click_method
.as_ref()
.map_or(false, |x| matches!(x, ClickMethod::ButtonAreas)),
|enabled| {
Message::SetSecondaryClickBehavior(
enabled.then_some(ClickMethod::ButtonAreas),
true,
)
},
),
)
.add(
settings::item::builder(&*TAP_TO_CLICK).toggler(
page.input_touchpad
.tap_config
.as_ref()
.map_or(false, |x| x.enabled),
Message::TapToClick,
),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}
fn scrolling() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("scrolling"))
.descriptions(vec![
super::SCROLLING_TWO_FINGER.as_str().into(),
super::SCROLLING_EDGE.as_str().into(),
super::SCROLLING_SPEED.as_str().into(),
super::SCROLLING_NATURAL.as_str().into(),
super::SCROLLING_NATURAL_DESC.as_str().into(),
])
.view::<Page>(|binder, _page, section| {
let page = binder
.page::<super::Page>()
.expect("input devices page not found");
let theme = cosmic::theme::active();
settings::view_section(&section.title)
// Two-finger scrolling toggle
.add(
settings::item::builder(&*super::SCROLLING_TWO_FINGER).toggler(
page.input_touchpad
.scroll_config
.as_ref()
.map_or(false, |x| matches!(x.method, Some(ScrollMethod::TwoFinger))),
|enabled| {
Message::SetScrollMethod(
enabled.then_some(ScrollMethod::TwoFinger),
true,
)
},
),
)
// Edge scrolling toggle
.add(
settings::item::builder(&*super::SCROLLING_EDGE).toggler(
page.input_touchpad
.scroll_config
.as_ref()
.map_or(false, |x| matches!(x.method, Some(ScrollMethod::Edge))),
|enabled| {
Message::SetScrollMethod(enabled.then_some(ScrollMethod::Edge), true)
},
),
)
// Scroll speed slider
.add(settings::item(&*super::SCROLLING_SPEED, {
let value = page
.input_touchpad
.scroll_config
.as_ref()
.and_then(|x| x.scroll_factor)
.unwrap_or(1.)
.log(2.)
* 10.0
+ 50.0;
let slider = widget::slider(1.0..=100.0, value, |value| {
Message::SetScrollFactor(2f64.powf((value - 50.0) / 10.0), true)
})
.width(250.0)
.breakpoints(&[50.0]);
row::with_capacity(2)
.align_items(Alignment::Center)
.spacing(theme.cosmic().space_s())
.push(text(format!("{:.0}", value.round())))
.push(slider)
}))
// Natural scrolling toggle
.add(
settings::item::builder(&*super::SCROLLING_NATURAL)
.description(&*super::SCROLLING_NATURAL_DESC)
.toggler(
page.input_touchpad
.scroll_config
.as_ref()
.map_or(false, |conf| conf.natural_scroll.unwrap_or(false)),
|enabled| Message::SetNaturalScroll(enabled, true),
),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}
fn swiping() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("swiping"))
.descriptions(vec![
SWIPING_FOUR_FINGER_DOWN.as_str().into(),
SWIPING_FOUR_FINGER_LEFT.as_str().into(),
SWIPING_FOUR_FINGER_RIGHT.as_str().into(),
SWIPING_FOUR_FINGER_UP.as_str().into(),
SWIPING_THREE_FINGER_ANY.as_str().into(),
])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&*section.title)
.add(
settings::item::builder(&*SWIPING_THREE_FINGER_ANY)
.control(text(&*SWITCH_BETWEEN_WINDOWS)),
)
.add(
settings::item::builder(&*SWIPING_FOUR_FINGER_UP)
.control(text(&*SWITCH_TO_PREV_WORKSPACE)),
)
.add(
settings::item::builder(&*SWIPING_FOUR_FINGER_DOWN)
.control(text(&*SWITCH_TO_NEXT_WORKSPACE)),
)
.add(
settings::item::builder(&*SWIPING_FOUR_FINGER_LEFT)
.control(text(&*OPEN_WORKSPACES_VIEW)),
)
.add(
settings::item::builder(&*SWIPING_FOUR_FINGER_RIGHT)
.control(text(&*OPEN_APPLICATION_LIBRARY)),
)
.apply(Element::from)
.map(crate::pages::Message::Input)
})
}

View file

@ -0,0 +1,36 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::Entity;
pub mod desktop;
pub mod display;
pub mod input;
pub mod networking;
pub mod sound;
pub mod system;
pub mod time;
#[derive(Clone, Debug)]
pub enum Message {
About(system::about::Message),
Appearance(desktop::appearance::Message),
DateAndTime(time::date::Message),
Desktop(desktop::Message),
DesktopWallpaper(desktop::wallpaper::Message),
DesktopWorkspaces(desktop::workspaces::Message),
Displays(display::Message),
Dock(desktop::dock::Message),
DockApplet(desktop::dock::applets::Message),
External { id: String, message: Vec<u8> },
Input(input::Message),
Page(Entity),
Panel(desktop::panel::Message),
PanelApplet(desktop::panel::applets_inner::Message),
}
impl From<Message> for crate::Message {
fn from(message: Message) -> Self {
crate::Message::PageMessage(message)
}
}

View file

@ -0,0 +1,10 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page as page;
pub fn info() -> page::Info {
page::Info::new("online-accounts", "goa-panel-symbolic")
.title(fl!("online-accounts"))
.description(fl!("online-accounts", "desc"))
}

View file

@ -0,0 +1,5 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod accounts;
pub mod wired;

View file

@ -0,0 +1,10 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page as page;
pub fn info() -> page::Info {
page::Info::new("wired", "network-workgroup-symbolic")
.title(fl!("wired"))
.description(fl!("wired", "desc"))
}

View file

@ -0,0 +1,111 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::widget::{settings, text};
use cosmic_settings_page::{self as page, section, Section};
use slotmap::SlotMap;
// crate::cache_dynamic_lazy! {
// pub static SOUND_ALERTS_VOLUME: String = fl!("sound-alerts", "volume");
// pub static SOUND_ALERTS_SOUND: String = fl!("sound-alerts", "sound");
// pub static SOUND_APPLICATIONS_DESC: String = fl!("sound-applications", "desc");
// pub static SOUND_INPUT_VOLUME: String = fl!("sound-input", "volume");
// pub static SOUND_INPUT_DEVICE: String = fl!("sound-input", "device");
// pub static SOUND_INPUT_LEVEL: String = fl!("sound-input", "level");
// pub static SOUND_OUTPUT_VOLUME: String = fl!("sound-output", "volume");
// pub static SOUND_OUTPUT_DEVICE: String = fl!("sound-output", "device");
// pub static SOUND_OUTPUT_LEVEL: String = fl!("sound-output", "level");
// pub static SOUND_OUTPUT_CONFIG: String = fl!("sound-output", "config");
// pub static SOUND_OUTPUT_BALANCE: String = fl!("sound-output", "balance");
// }
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(output()),
sections.insert(input()),
sections.insert(alerts()),
sections.insert(applications()),
])
}
fn info(&self) -> page::Info {
page::Info::new("sound", "multimedia-volume-control-symbolic")
.title(fl!("sound"))
.description(fl!("sound", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
fn alerts() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("sound-alerts"))
.descriptions(vec![
fl!("sound-alerts", "volume").into(),
fl!("sound-alerts", "sound").into(),
])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&section.title)
.add(settings::item(&*section.descriptions[0], text("TODO")))
.add(settings::item(&*section.descriptions[1], text("TODO")))
.into()
})
}
fn applications() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("sound-applications"))
.descriptions(vec![fl!("sound-applications", "desc").into()])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&section.title)
.add(settings::item(&*section.descriptions[0], text("TODO")))
.into()
})
}
fn input() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("sound-input"))
.descriptions(vec![
fl!("sound-input", "volume").into(),
fl!("sound-input", "device").into(),
fl!("sound-input", "level").into(),
])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&section.title)
.add(settings::item(&*section.descriptions[0], text("TODO")))
.add(settings::item(&*section.descriptions[1], text("TODO")))
.add(settings::item(&*section.descriptions[2], text("TODO")))
.into()
})
}
fn output() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("sound-output"))
.descriptions(vec![
fl!("sound-output", "volume").into(),
fl!("sound-output", "device").into(),
fl!("sound-output", "level").into(),
fl!("sound-output", "config").into(),
fl!("sound-output", "balance").into(),
])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&section.title)
.add(settings::item(&*section.descriptions[0], text("TODO")))
.add(settings::item(&*section.descriptions[1], text("TODO")))
.add(settings::item(&*section.descriptions[2], text("TODO")))
.add(settings::item(&*section.descriptions[3], text("TODO")))
.into()
})
}

View file

@ -0,0 +1,140 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::{self as page, section, Section};
use cosmic::widget::{list_column, settings, text};
use cosmic::{command, Command};
use cosmic_settings_system::about::Info;
use slotmap::SlotMap;
#[derive(Clone, Debug)]
pub enum Message {
Info(Box<Info>),
}
#[derive(Clone, Debug, Default)]
pub struct Page {
info: Info,
// support_page: page::Entity,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(device()),
sections.insert(hardware()),
sections.insert(os()),
sections.insert(related()),
])
}
fn info(&self) -> page::Info {
page::Info::new("about", "help-about-symbolic")
.title(fl!("about"))
.description(fl!("about", "desc"))
}
fn reload(&mut self, _page: page::Entity) -> Command<crate::pages::Message> {
command::future(async move {
crate::pages::Message::About(Message::Info(Box::new(Info::load())))
})
}
}
impl Page {
pub fn update(&mut self, message: Message) {
match message {
Message::Info(info) => self.info = *info,
}
}
}
fn device() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![
fl!("about-device").into(),
fl!("about-device", "desc").into(),
])
.view::<Page>(|_binder, page, section| {
let desc = &section.descriptions;
let device_name = settings::item::builder(&*desc[0])
.description(&*desc[1])
.control(text(&page.info.device_name));
list_column().add(device_name).into()
})
}
fn hardware() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("about-hardware"))
.descriptions(vec![
fl!("about-hardware", "model").into(),
fl!("about-hardware", "memory").into(),
fl!("about-hardware", "processor").into(),
fl!("about-hardware", "graphics").into(),
fl!("about-hardware", "disk-capacity").into(),
])
.view::<Page>(|_binder, page, section| {
let desc = &section.descriptions;
let mut sections = settings::view_section(&section.title)
.add(settings::item(&*desc[0], text(&page.info.hardware_model)))
.add(settings::item(&*desc[1], text(&page.info.memory)))
.add(settings::item(&*desc[2], text(&page.info.processor)));
for card in &page.info.graphics {
sections = sections.add(settings::item(&*desc[3], text(card.as_str())));
}
sections
.add(settings::item(&*desc[4], text(&page.info.disk_capacity)))
.into()
})
}
fn os() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("about-os"))
.descriptions(vec![
fl!("about-os", "os").into(),
fl!("about-os", "os-architecture").into(),
fl!("about-os", "desktop-environment").into(),
fl!("about-os", "windowing-system").into(),
])
.view::<Page>(|_binder, page, section| {
let desc = &section.descriptions;
settings::view_section(&section.title)
.add(settings::item(&*desc[0], text(&page.info.operating_system)))
.add(settings::item(&*desc[1], text(&page.info.os_architecture)))
.add(settings::item(
&*desc[2],
text(&page.info.desktop_environment),
))
.add(settings::item(&*desc[3], text(&page.info.windowing_system)))
.into()
})
}
fn related() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("about-related"))
.descriptions(vec![fl!("about-related", "support").into()])
.view::<Page>(|_binder, _page, section| {
settings::view_section(&section.title)
.add(settings::item(&*section.descriptions[0], text("TODO")))
.into()
})
}
// fn page(app: &crate::SettingsApp) -> &Page {
// app.pages
// .resource::<Page>()
// .expect("missing system->about page")
// }

View file

@ -0,0 +1,26 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(Section::default())])
}
fn info(&self) -> page::Info {
page::Info::new("firmware", "firmware-manager-symbolic")
.title(fl!("firmware"))
.description(fl!("firmware", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -0,0 +1,25 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod about;
pub mod firmware;
pub mod users;
use cosmic_settings_page as page;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("system", "system-users-symbolic").title(fl!("system"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<users::Page>()
.sub_page::<about::Page>()
.sub_page::<firmware::Page>()
}
}

View file

@ -0,0 +1,26 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(Section::default())])
}
fn info(&self) -> page::Info {
page::Info::new("users", "system-users-symbolic")
.title(fl!("users"))
.description(fl!("users", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -0,0 +1,131 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
iced::{widget::horizontal_space, Length},
widget::settings,
Apply,
};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
// use icu::calendar::{DateTime, Gregorian};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page {
auto: bool,
auto_timezone: bool,
military_time: bool,
// info: Option<cosmic_settings_time::Info>,
}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(date()),
sections.insert(timezone()),
sections.insert(format()),
])
}
fn info(&self) -> page::Info {
page::Info::new("time-date", "preferences-system-time-symbolic")
.title(fl!("time-date"))
.description(fl!("time-date", "desc"))
}
}
impl Page {
pub fn update(&mut self, message: Message) {
match message {
Message::Automatic(enable) => self.auto = enable,
Message::AutomaticTimezone(enable) => self.auto_timezone = enable,
Message::MilitaryTime(enable) => self.military_time = enable,
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum Message {
Automatic(bool),
AutomaticTimezone(bool),
MilitaryTime(bool),
}
impl page::AutoBind<crate::pages::Message> for Page {}
fn date() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("time-date"))
.descriptions(vec![
fl!("time-date", "auto").into(),
fl!("time-date").into(),
])
.view::<Page>(|_binder, page, section| {
settings::view_section(&section.title)
.add(
settings::item::builder(&*section.descriptions[0])
.toggler(page.auto, Message::Automatic),
)
.add(settings::item(
&*section.descriptions[1],
horizontal_space(Length::Fill),
))
.apply(cosmic::Element::from)
.map(crate::pages::Message::DateAndTime)
})
}
fn format() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("time-format"))
.descriptions(vec![
fl!("time-format", "twenty-four").into(),
fl!("time-format", "first").into(),
])
.view::<Page>(|_binder, page, section| {
settings::view_section(&section.title)
// 24-hour toggle
.add(
settings::item::builder(&*section.descriptions[0])
.toggler(page.military_time, Message::MilitaryTime),
)
// First day of week
.add(settings::item(
&*section.descriptions[1],
horizontal_space(Length::Fill),
))
.apply(cosmic::Element::from)
.map(crate::pages::Message::DateAndTime)
})
}
fn timezone() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("time-zone"))
.descriptions(vec![
fl!("time-zone", "auto").into(),
fl!("time-zone", "auto-info").into(),
fl!("time-zone").into(),
])
.view::<Page>(|_binder, page, section| {
settings::view_section(&section.title)
// Automatic timezone toggle
.add(
settings::item::builder(&*section.descriptions[0])
.description(&*section.descriptions[1])
.toggler(page.auto_timezone, Message::AutomaticTimezone),
)
// Time zone select
.add(
settings::item::builder(&*section.descriptions[2])
.control(horizontal_space(Length::Fill)),
)
.apply(cosmic::Element::from)
.map(crate::pages::Message::DateAndTime)
})
}

View file

@ -0,0 +1,24 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page as page;
pub mod date;
pub mod region;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("time", "preferences-system-time-symbolic")
.title(fl!("time"))
.description(fl!("time", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
page.sub_page::<date::Page>().sub_page::<region::Page>()
}
}

View file

@ -0,0 +1,26 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use slotmap::SlotMap;
#[derive(Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(Section::default())])
}
fn info(&self) -> page::Info {
page::Info::new("time-region", "preferences-desktop-locale-symbolic")
.title(fl!("time-region"))
.description(fl!("time-region", "desc"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -0,0 +1,96 @@
use cosmic::{
iced::subscription,
iced_futures::futures::{self, SinkExt},
};
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::fmt::Debug;
use std::hash::Hash;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
#[derive(Debug)]
pub enum State {
Ready,
Waiting {
watcher: RecommendedWatcher,
rx: UnboundedReceiver<notify::Result<Event>>,
},
Finished,
}
#[derive(Debug, Clone, Copy)]
pub enum DesktopFileEvent {
Changed,
}
pub fn desktop_files<I: 'static + Hash + Copy + Send + Sync + Debug>(
id: I,
) -> cosmic::iced::Subscription<DesktopFileEvent> {
subscription::channel(id, 50, move |mut output| async move {
let mut state = State::Ready;
loop {
state = start_watching(state, &mut output).await;
}
})
}
async fn start_watching(
state: State,
output: &mut futures::channel::mpsc::Sender<DesktopFileEvent>,
) -> State {
match state {
State::Ready => {
let paths = freedesktop_desktop_entry::default_paths();
// TODO log errors
if let Ok((mut watcher, rx)) = async_watcher() {
for path in paths {
let _ = watcher.watch(path.as_ref(), RecursiveMode::Recursive);
}
_ = output.send(DesktopFileEvent::Changed).await;
State::Waiting { watcher, rx }
} else {
State::Finished
}
}
State::Waiting { watcher, rx } => {
if let Some(rx) = async_watch(rx).await {
_ = output.send(DesktopFileEvent::Changed).await;
State::Waiting { watcher, rx }
} else {
State::Finished
}
}
State::Finished => cosmic::iced::futures::future::pending().await,
}
}
fn async_watcher() -> notify::Result<(RecommendedWatcher, UnboundedReceiver<notify::Result<Event>>)>
{
let (tx, rx) = unbounded_channel();
// Automatically select the best implementation for your platform.
// You can also access each implementation directly e.g. INotifyWatcher.
let watcher = RecommendedWatcher::new(
move |res| {
_ = tx.send(res);
},
Config::default(),
)?;
Ok((watcher, rx))
}
async fn async_watch(
mut rx: UnboundedReceiver<notify::Result<Event>>,
) -> Option<UnboundedReceiver<notify::Result<Event>>> {
// TODO log errors
if let Some(res) = rx.recv().await {
match res {
Ok(_) => return Some(rx),
Err(_) => return None,
}
}
None
}

View file

@ -0,0 +1,2 @@
mod desktop_files;
pub use desktop_files::*;

View file

@ -0,0 +1,40 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{iced_core::Border, theme};
#[must_use]
pub fn display_container_frame() -> cosmic::theme::Container {
theme::Container::custom(|theme| {
let cosmic = theme.cosmic();
cosmic::widget::container::Appearance {
icon_color: None,
text_color: None,
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color::WHITE)),
border: Border {
color: cosmic::iced::Color::WHITE,
radius: cosmic.corner_radii.radius_xs.into(),
width: 3.0,
},
shadow: Default::default(),
}
})
}
#[must_use]
pub fn display_container_screen() -> cosmic::theme::Container {
theme::Container::custom(|theme| {
let cosmic = theme.cosmic();
cosmic::widget::container::Appearance {
icon_color: None,
text_color: None,
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color::BLACK)),
border: Border {
color: cosmic::iced::Color::BLACK,
radius: cosmic.corner_radii.radius_0.into(),
width: 0.0,
},
shadow: Default::default(),
}
})
}

View file

@ -0,0 +1,17 @@
/// Normalize the labeling of displays across settings pages.
pub fn display_name(name: &str, physical: (u32, u32)) -> String {
let inches = ((physical.0.pow(2) + physical.1.pow(2)) as f32).sqrt() * 0.039_370_1;
let inches_string = format!("{inches:.1}\"");
match name {
"eDP-1" | "LVDS1" => {
fl!("display", "laptop", size = inches_string.as_str())
}
output => fl!(
"display",
"external",
size = inches_string.as_str(),
output = output
),
}
}

View file

@ -0,0 +1,123 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::iced::Length;
use cosmic::widget::{
button, column, container, divider, horizontal_space, icon, list, row, settings, text,
vertical_space,
};
use cosmic::{theme, Apply, Element};
use cosmic_settings_page as page;
#[must_use]
pub fn search_header<Message>(
pages: &page::Binder<Message>,
page: page::Entity,
) -> cosmic::Element<crate::Message> {
let page_meta = &pages.info[page];
let mut column_children = Vec::with_capacity(4);
if let Some(parent) = page_meta.parent {
let parent_meta = &pages.info[parent];
column_children.push(
text(parent_meta.title.as_str())
.size(14)
.apply(container)
.padding([0, 0, 0, 6])
.into(),
);
}
column_children.push(
crate::widget::search_page_link(&page_meta.title)
.on_press(crate::Message::Page(page))
.into(),
);
column_children.push(vertical_space(Length::Fixed(8.)).into());
column_children.push(divider::horizontal::heavy().into());
column::with_children(column_children).into()
}
pub fn search_page_link<Message: 'static>(title: &str) -> button::TextButton<Message> {
button::text(title).style(button::Style::Link)
}
#[must_use]
pub fn page_title<Message: 'static>(page: &page::Info) -> Element<Message> {
row::with_capacity(2)
.push(text::title3(page.title.as_str()))
.push(horizontal_space(Length::Fill))
.into()
}
#[must_use]
pub fn unimplemented_page<Message: 'static>() -> Element<'static, Message> {
settings::view_section("")
.add(text("We haven't created that panel yet, and/or it is using a similar idea as current Pop! designs."))
.into()
}
#[must_use]
pub fn display_container<'a, Message: 'a>(widget: Element<'a, Message>) -> Element<'a, Message> {
let display = container(widget)
.style(crate::theme::display_container_screen())
.apply(container)
.padding(4)
.style(crate::theme::display_container_frame());
row::with_capacity(3)
.push(horizontal_space(Length::Fill))
.push(display)
.push(horizontal_space(Length::Fill))
.padding([0, 0, 8, 0])
.into()
}
#[must_use]
pub fn page_list_item<'a, Message: 'static + Clone>(
title: &'a str,
description: &'a str,
icon: &'a str,
message: Message,
) -> Element<'a, Message> {
cosmic::widget::settings::item::builder(title)
.description(description)
.icon(icon::from_name(icon).size(16))
.control(icon::from_name("go-next-symbolic").size(16))
.spacing(16)
.apply(container)
.padding([20, 24])
.style(theme::Container::List)
.apply(button)
.style(theme::Button::Transparent)
.on_press(message)
.into()
}
#[must_use]
pub fn sub_page_header<'a, Message: 'static + Clone>(
sub_page: &'a str,
parent_page: &'a str,
on_press: Message,
) -> Element<'a, Message> {
let previous_button = button::icon(icon::from_name("go-previous-symbolic"))
.extra_small()
.padding(0)
.label(parent_page)
.spacing(4)
.style(button::Style::Link)
.on_press(on_press);
let sub_page_header = row::with_capacity(2).push(text::title3(sub_page));
column::with_capacity(2)
.push(previous_button)
.push(sub_page_header)
.spacing(6)
.width(Length::Shrink)
.into()
}