feat: refactor the settings page architecture
This commit is contained in:
parent
efdd934e62
commit
c015ad9948
55 changed files with 2212 additions and 1635 deletions
199
page/src/binder.rs
Normal file
199
page/src/binder.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use crate::section::{self, Section};
|
||||
use crate::{Content, Info, Page};
|
||||
use cosmic::iced_native::command::{Action, Command};
|
||||
use regex::Regex;
|
||||
use slotmap::{SecondaryMap, SlotMap, SparseSecondaryMap};
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
collections::HashMap,
|
||||
};
|
||||
|
||||
/// All settings pages are registered and managed by the [`Binder`].
|
||||
pub struct Binder<Message> {
|
||||
pub info: SlotMap<crate::Entity, Info>,
|
||||
pub page: SecondaryMap<crate::Entity, Box<dyn Page<Message>>>,
|
||||
pub typed_page_ids: HashMap<TypeId, crate::Entity>,
|
||||
pub resource: HashMap<TypeId, Box<dyn Any>>,
|
||||
pub storage: HashMap<TypeId, SecondaryMap<crate::Entity, Box<dyn Any>>>,
|
||||
pub sub_pages: SparseSecondaryMap<crate::Entity, Vec<crate::Entity>>,
|
||||
pub sections: SlotMap<section::Entity, Section<Message>>,
|
||||
pub content: SparseSecondaryMap<crate::Entity, Content>,
|
||||
}
|
||||
|
||||
impl<Message> Default for Binder<Message> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content: SparseSecondaryMap::new(),
|
||||
info: SlotMap::with_key(),
|
||||
page: SecondaryMap::new(),
|
||||
typed_page_ids: HashMap::new(),
|
||||
resource: HashMap::new(),
|
||||
sections: SlotMap::with_key(),
|
||||
storage: HashMap::new(),
|
||||
sub_pages: SparseSecondaryMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: 'static> Binder<Message> {
|
||||
/// Check if a page exists in the model.
|
||||
#[must_use]
|
||||
pub fn contains_item(&self, id: crate::Entity) -> bool {
|
||||
self.info.contains_key(id)
|
||||
}
|
||||
|
||||
/// Returns the content of a page, if it has any.
|
||||
#[must_use]
|
||||
pub fn content(&self, page: crate::Entity) -> Option<&[section::Entity]> {
|
||||
self.content.get(page).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Get an immutable reference to data associated with a page.
|
||||
#[must_use]
|
||||
pub fn data<Data: 'static>(&self, id: crate::Entity) -> Option<&Data> {
|
||||
self.storage
|
||||
.get(&TypeId::of::<Data>())
|
||||
.and_then(|storage| storage.get(id))
|
||||
.and_then(|data| data.downcast_ref())
|
||||
}
|
||||
|
||||
/// Get a mutable reference to data associated with a page.
|
||||
pub fn data_mut<Data: 'static>(&mut self, id: crate::Entity) -> Option<&mut Data> {
|
||||
self.storage
|
||||
.get_mut(&TypeId::of::<Data>())
|
||||
.and_then(|storage| storage.get_mut(id))
|
||||
.and_then(|data| data.downcast_mut())
|
||||
}
|
||||
|
||||
/// Associates data with the item.
|
||||
pub fn data_set<Data: 'static>(&mut self, id: crate::Entity, data: Data) {
|
||||
if self.contains_item(id) {
|
||||
self.storage
|
||||
.entry(TypeId::of::<Data>())
|
||||
.or_insert_with(SecondaryMap::new)
|
||||
.insert(id, Box::new(data));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a specific data type from the item.
|
||||
pub fn data_remove<Data: 'static>(&mut self, id: crate::Entity) {
|
||||
self.storage
|
||||
.get_mut(&TypeId::of::<Data>())
|
||||
.and_then(|storage| storage.remove(id));
|
||||
}
|
||||
|
||||
/// Registers a new page in the settings panel.
|
||||
pub fn register<P: AutoBind<Message>>(&mut self) -> crate::Insert<Message> {
|
||||
let page = P::default();
|
||||
|
||||
let id = self.register_page(page);
|
||||
|
||||
self.typed_page_ids.insert(TypeId::of::<P>(), id);
|
||||
|
||||
P::sub_pages(crate::Insert { id, model: self })
|
||||
}
|
||||
|
||||
pub fn register_page<P: Page<Message>>(&mut self, page: P) -> crate::Entity {
|
||||
let id = self.info.insert(page.info());
|
||||
|
||||
if let Some(content) = page.content(&mut self.sections) {
|
||||
self.content.insert(id, content);
|
||||
}
|
||||
|
||||
self.page.insert(id, Box::new(page));
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self, id: crate::Entity) -> Option<&dyn Page<Message>> {
|
||||
self.page.get(id).map(AsRef::as_ref)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model_mut(&mut self, id: crate::Entity) -> Option<&mut dyn Page<Message>> {
|
||||
self.page.get_mut(id).map(AsMut::as_mut)
|
||||
}
|
||||
|
||||
/// Obtain a reference to a page by its type ID.
|
||||
#[must_use]
|
||||
pub fn page<P: Page<Message>>(&self) -> Option<&P> {
|
||||
let id = self.typed_page_ids.get(&TypeId::of::<P>())?;
|
||||
let page = self.page.get(*id)?;
|
||||
page.downcast_ref::<P>()
|
||||
}
|
||||
|
||||
/// Obtain a reference to a page by its type ID.
|
||||
#[must_use]
|
||||
pub fn page_mut<P: Page<Message>>(&mut self) -> Option<&mut P> {
|
||||
let id = self.typed_page_ids.get(&TypeId::of::<P>())?;
|
||||
let page = self.page.get_mut(*id)?;
|
||||
page.downcast_mut::<P>()
|
||||
}
|
||||
|
||||
/// Calls a page's load function to refresh its data.
|
||||
pub fn page_reload(&mut self, id: crate::Entity) -> Option<Command<Message>> {
|
||||
if let Some(page) = self.page.get(id) {
|
||||
if let Some(future) = page.load(id) {
|
||||
return Some(Command::single(Action::Future(future)));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resource<Resource: 'static>(&self) -> Option<&Resource> {
|
||||
self.resource
|
||||
.get(&TypeId::of::<Resource>())
|
||||
.and_then(|resource| resource.downcast_ref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resource_mut<Resource: 'static>(&mut self) -> Option<&mut Resource> {
|
||||
self.resource
|
||||
.get_mut(&TypeId::of::<Resource>())
|
||||
.and_then(|resource| resource.downcast_mut())
|
||||
}
|
||||
|
||||
#[allow(unused_must_use)]
|
||||
pub fn resource_register<Resource: Default + 'static>(&mut self) {
|
||||
self.resource
|
||||
.entry(TypeId::of::<Resource>())
|
||||
.or_insert_with(|| Box::<Resource>::default());
|
||||
}
|
||||
|
||||
/// Finds content of panels that match the search.
|
||||
pub fn search<'a>(
|
||||
&'a self,
|
||||
rule: &'a Regex,
|
||||
) -> impl Iterator<Item = (crate::Entity, section::Entity)> + 'a {
|
||||
generator::Gn::new_scoped_local(|mut s| {
|
||||
for (page, sections) in self.content.iter() {
|
||||
for id in sections {
|
||||
if self.sections[*id].search_matches(rule) {
|
||||
s.yield_((page, *id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generator::done!();
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the sub-pages of a page, if it has any.
|
||||
pub fn sub_pages(&self, page: crate::Entity) -> Option<&[crate::Entity]> {
|
||||
self.sub_pages.get(page).map(AsRef::as_ref)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AutoBind<Message: 'static>: Page<Message> + Default + 'static {
|
||||
/// Attaches sub-pages to the page.
|
||||
#[allow(clippy::must_use_candidate)]
|
||||
fn sub_pages(page: crate::Insert<Message>) -> crate::Insert<Message> {
|
||||
page
|
||||
}
|
||||
}
|
||||
50
page/src/insert.rs
Normal file
50
page/src/insert.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use super::{AutoBind, Binder, Content, Entity, Info};
|
||||
|
||||
/// An inserted page which may have additional properties assigned to it.
|
||||
pub struct Insert<'a, Message> {
|
||||
pub model: &'a mut Binder<Message>,
|
||||
pub id: Entity,
|
||||
}
|
||||
|
||||
impl<'a, Message: 'static> Insert<'a, Message> {
|
||||
#[must_use]
|
||||
pub fn id(self) -> Entity {
|
||||
self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn content(self, content: Content) -> Self {
|
||||
self.model.content.insert(self.id, content);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a page and associates it with its parent page.
|
||||
#[allow(clippy::return_self_not_must_use)]
|
||||
#[allow(clippy::must_use_candidate)]
|
||||
pub fn sub_page<P: AutoBind<Message>>(self) -> Self {
|
||||
let sub_page = P::default();
|
||||
|
||||
let page = self.model.info.insert(Info {
|
||||
parent: Some(self.id),
|
||||
..sub_page.info()
|
||||
});
|
||||
|
||||
if let Some(content) = sub_page.content(&mut self.model.sections) {
|
||||
self.model.content.insert(page, content);
|
||||
}
|
||||
|
||||
self.model.page.insert(page, Box::new(sub_page));
|
||||
|
||||
self.model
|
||||
.sub_pages
|
||||
.entry(self.id)
|
||||
.expect("parent page missing")
|
||||
.and_modify(|v| v.push(page))
|
||||
.or_insert_with(|| vec![page]);
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
85
page/src/lib.rs
Normal file
85
page/src/lib.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
mod binder;
|
||||
pub use binder::{AutoBind, Binder};
|
||||
|
||||
mod insert;
|
||||
use downcast_rs::{impl_downcast, Downcast};
|
||||
pub use insert::Insert;
|
||||
|
||||
pub mod section;
|
||||
pub use section::Section;
|
||||
|
||||
use derive_setters::Setters;
|
||||
use slotmap::SlotMap;
|
||||
use std::{borrow::Cow, future::Future, pin::Pin};
|
||||
|
||||
slotmap::new_key_type! {
|
||||
/// The unique ID of a page.
|
||||
pub struct Entity;
|
||||
}
|
||||
|
||||
/// A collection of sections which a page may be comprised of.
|
||||
pub type Content = Vec<section::Entity>;
|
||||
|
||||
/// A request by a page to run a command in the background.
|
||||
pub type Task<Message> = Pin<Box<dyn Future<Output = Message> + Send>>;
|
||||
|
||||
pub trait Page<Message: 'static>: Downcast {
|
||||
/// Information about the page
|
||||
fn info(&self) -> Info;
|
||||
|
||||
#[must_use]
|
||||
fn content(
|
||||
&self,
|
||||
_sections: &mut SlotMap<section::Entity, Section<Message>>,
|
||||
) -> Option<Content> {
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(unused)]
|
||||
fn load(&self, page: crate::Entity) -> Option<crate::Task<Message>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl_downcast!(Page<Message>);
|
||||
|
||||
/// Information about a page; including its title, icon, and description.
|
||||
#[derive(Setters)]
|
||||
#[must_use]
|
||||
pub struct Info {
|
||||
/// An identifier that is the same between application runs.
|
||||
#[setters(skip)]
|
||||
pub id: Cow<'static, str>,
|
||||
|
||||
/// The icon associated with the page.
|
||||
#[setters(skip)]
|
||||
pub icon_name: Cow<'static, str>,
|
||||
|
||||
/// The title of the page.
|
||||
#[setters(into)]
|
||||
pub title: String,
|
||||
|
||||
/// A description of the page.
|
||||
#[setters(into)]
|
||||
pub description: String,
|
||||
|
||||
/// The parent of the page.
|
||||
#[setters(strip_option)]
|
||||
pub parent: Option<Entity>,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
pub fn new(id: impl Into<Cow<'static, str>>, icon_name: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
icon_name: icon_name.into(),
|
||||
id: id.into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
102
page/src/section.rs
Normal file
102
page/src/section.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use derive_setters::Setters;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{Binder, Page};
|
||||
|
||||
slotmap::new_key_type! {
|
||||
/// The unique ID of a section of page.
|
||||
pub struct Entity;
|
||||
}
|
||||
|
||||
pub type ViewFn<Message> = Box<
|
||||
dyn for<'a> Fn(
|
||||
&'a Binder<Message>,
|
||||
&'a dyn Page<Message>,
|
||||
&'a Section<Message>,
|
||||
) -> cosmic::Element<'a, Message>,
|
||||
>;
|
||||
|
||||
/// A searchable sub-component of a page.
|
||||
///
|
||||
/// Searches can group multiple sections together.
|
||||
#[derive(Setters)]
|
||||
#[must_use]
|
||||
pub struct Section<Message> {
|
||||
#[setters(into)]
|
||||
pub title: String,
|
||||
pub descriptions: Vec<String>,
|
||||
#[setters(skip)]
|
||||
pub view_fn: ViewFn<Message>,
|
||||
#[setters(bool)]
|
||||
pub search_ignore: bool,
|
||||
}
|
||||
|
||||
impl<Message: 'static> Default for Section<Message> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
descriptions: Vec::new(),
|
||||
view_fn: Box::new(unimplemented),
|
||||
search_ignore: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: 'static> Section<Message> {
|
||||
#[must_use]
|
||||
pub fn search_matches(&self, rule: &Regex) -> bool {
|
||||
if self.search_ignore {
|
||||
return false;
|
||||
}
|
||||
|
||||
if rule.is_match(self.title.as_str()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for description in &self.descriptions {
|
||||
if rule.is_match(description.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Will panic if the `Model` type does not match the page type.
|
||||
pub fn view<Model: Page<Message>>(
|
||||
mut self,
|
||||
func: impl for<'a> Fn(
|
||||
&'a Binder<Message>,
|
||||
&'a Model,
|
||||
&'a Section<Message>,
|
||||
) -> cosmic::Element<'a, Message>
|
||||
+ 'static,
|
||||
) -> Self {
|
||||
self.view_fn = Box::new(move |binder, model: &dyn Page<Message>, section| {
|
||||
let model = model.downcast_ref::<Model>().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"page model type mismatch: expected {}",
|
||||
std::any::type_name::<Model>()
|
||||
)
|
||||
});
|
||||
|
||||
func(binder, model, section)
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unimplemented<'a, Message: 'static>(
|
||||
_binder: &'a Binder<Message>,
|
||||
_page: &'a dyn Page<Message>,
|
||||
_section: &'a Section<Message>,
|
||||
) -> cosmic::Element<'a, Message> {
|
||||
cosmic::widget::settings::view_column(vec![cosmic::widget::settings::view_section("").into()])
|
||||
.into()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue