Merge branch 'master' of git.tfcconnection.org:chris/lumina-iced
Some checks are pending
/ test (push) Waiting to run

This commit is contained in:
Chris Cochrun 2025-09-24 09:46:11 -05:00
commit 77daa03db6
19 changed files with 4325 additions and 748 deletions

1218
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,9 @@ description = "A cli presentation system"
[dependencies]
clap = { version = "4.5.20", features = ["debug", "derive"] }
lexpr = "0.2.7"
miette = { version = "7.2.0", features = ["fancy"] }
pretty_assertions = "1.4.1"
serde = { version = "1.0.213", features = ["derive"] }
serde-lexpr = "0.1.3"
tracing = "0.1.40"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "chrono", "time", "local-time", "env-filter"] }
@ -36,12 +34,13 @@ resvg = "0.45.1"
image = "0.25.8"
rapidhash = "4.0.0"
rapidfuzz = "0.5.0"
dragking = { git = "https://github.com/airstrike/dragking" }
# femtovg = { version = "0.16.0", features = ["wgpu"] }
# wgpu = "26.0.1"
# mupdf = "0.5.0"
mupdf = { version = "0.5.0", git = "https://github.com/messense/mupdf-rs", rev="2425c1405b326165b06834dcc1ca859015f92787"}
# rfd = { version = "0.12.1", features = ["xdg-portal"], default-features = false }
# rfd = { version = "0.14.1" }
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic"

24
flake.lock generated
View file

@ -6,11 +6,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1755585599,
"narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=",
"lastModified": 1758177713,
"narHash": "sha256-4Mesi0sOxCzrwnFHeAhL/vv1K1Wcwsl4D9duQ7ndYS8=",
"owner": "nix-community",
"repo": "fenix",
"rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42",
"rev": "60316bdc00603b483992560baa14841e42e58a7b",
"type": "github"
},
"original": {
@ -80,11 +80,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1755186698,
"narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=",
"lastModified": 1758035966,
"narHash": "sha256-qqIJ3yxPiB0ZQTT9//nFGQYn8X/PBoJbofA7hRKZnmE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c",
"rev": "8d4ddb19d03c65a36ad8d189d001dc32ffb0306b",
"type": "github"
},
"original": {
@ -112,11 +112,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1755615617,
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
"lastModified": 1758035966,
"narHash": "sha256-qqIJ3yxPiB0ZQTT9//nFGQYn8X/PBoJbofA7hRKZnmE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "20075955deac2583bb12f07151c2df830ef346b4",
"rev": "8d4ddb19d03c65a36ad8d189d001dc32ffb0306b",
"type": "github"
},
"original": {
@ -137,11 +137,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1755504847,
"narHash": "sha256-VX0B9hwhJypCGqncVVLC+SmeMVd/GAYbJZ0MiiUn2Pk=",
"lastModified": 1757362324,
"narHash": "sha256-/PAhxheUq4WBrW5i/JHzcCqK5fGWwLKdH6/Lu1tyS18=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "a905e3b21b144d77e1b304e49f3264f6f8d4db75",
"rev": "9edc9cbe5d8e832b5864e09854fa94861697d2fd",
"type": "github"
},
"original": {

View file

@ -8,6 +8,8 @@ build:
sbuild:
RUST_LOG=debug sccache cargo build
run:
RUST_LOG=debug cargo run -- {{ui}}
run-file:
RUST_LOG=debug cargo run -- {{ui}} {{file}}
srun:
RUST_LOG=debug sccache cargo run -- {{ui}} {{file}}
@ -20,5 +22,6 @@ profile:
alias b := build
alias r := run
alias rf := run-file
alias sr := srun
alias c := clean

View file

@ -1,157 +0,0 @@
use lexpr::Value;
use strum_macros::EnumString;
#[derive(Debug, Clone, Default, PartialEq, Eq, EnumString)]
pub(crate) enum Symbol {
#[strum(ascii_case_insensitive)]
Slide,
#[strum(ascii_case_insensitive)]
Image,
#[strum(ascii_case_insensitive)]
Text,
#[strum(ascii_case_insensitive)]
Video,
#[strum(ascii_case_insensitive)]
Song,
#[strum(disabled)]
ImageFit(ImageFit),
#[strum(disabled)]
VerseOrder(VerseOrder),
#[strum(disabled)]
#[default]
None,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumString)]
pub(crate) enum Keyword {
ImageFit(ImageFit),
}
#[derive(Debug, Default, Clone, PartialEq, Eq, EnumString)]
pub(crate) enum ImageFit {
#[strum(ascii_case_insensitive)]
#[default]
Cover,
#[strum(ascii_case_insensitive)]
Fill,
#[strum(ascii_case_insensitive)]
Crop,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, EnumString)]
pub(crate) enum VerseOrder {
#[strum(ascii_case_insensitive)]
#[default]
V1,
#[strum(ascii_case_insensitive)]
V2,
#[strum(ascii_case_insensitive)]
V3,
#[strum(ascii_case_insensitive)]
V4,
#[strum(ascii_case_insensitive)]
V5,
#[strum(ascii_case_insensitive)]
V6,
#[strum(ascii_case_insensitive)]
C1,
#[strum(ascii_case_insensitive)]
C2,
#[strum(ascii_case_insensitive)]
C3,
#[strum(ascii_case_insensitive)]
C4,
#[strum(ascii_case_insensitive)]
B1,
#[strum(ascii_case_insensitive)]
B2,
#[strum(ascii_case_insensitive)]
B3,
#[strum(ascii_case_insensitive)]
B4,
#[strum(ascii_case_insensitive)]
O1,
#[strum(ascii_case_insensitive)]
O2,
#[strum(ascii_case_insensitive)]
O3,
#[strum(ascii_case_insensitive)]
O4,
#[strum(ascii_case_insensitive)]
E1,
#[strum(ascii_case_insensitive)]
E2,
#[strum(ascii_case_insensitive)]
I1,
#[strum(ascii_case_insensitive)]
I2,
}
#[derive(Clone, Debug, PartialEq, Eq, EnumString)]
pub(crate) enum SongKeyword {
#[strum(ascii_case_insensitive)]
Title,
#[strum(ascii_case_insensitive)]
Author,
#[strum(ascii_case_insensitive)]
Ccli,
#[strum(ascii_case_insensitive)]
Audio,
#[strum(ascii_case_insensitive)]
Font,
#[strum(ascii_case_insensitive)]
FontSize,
#[strum(ascii_case_insensitive)]
Background,
#[strum(ascii_case_insensitive)]
VerseOrder(VerseOrder),
}
#[derive(Clone, Debug, PartialEq, Eq, EnumString)]
pub(crate) enum ImageKeyword {
#[strum(ascii_case_insensitive)]
Source,
#[strum(ascii_case_insensitive)]
Fit,
}
#[derive(Clone, Debug, Eq, PartialEq, EnumString)]
pub(crate) enum VideoKeyword {
#[strum(ascii_case_insensitive)]
Source,
#[strum(ascii_case_insensitive)]
Fit,
}
pub(crate) fn get_lists(exp: &Value) -> Vec<Value> {
if exp.is_cons() {
exp.as_cons().unwrap().to_vec().0
} else {
vec![]
}
}
#[cfg(test)]
mod test {
// #[test]
// fn test_list() {
// let lisp =
// read_to_string("./test_presentation.lisp").expect("oops");
// // println!("{lisp}");
// let mut parser =
// Parser::from_str_custom(&lisp, Options::elisp());
// for atom in parser.value_iter() {
// match atom {
// Ok(atom) => {
// // println!("{atom}");
// let lists = get_lists(&atom);
// assert_eq!(lists, vec![Value::Null])
// }
// Err(e) => {
// panic!("{e}");
// }
// }
// }
// }
}

View file

@ -1,7 +1,6 @@
pub mod content;
pub mod images;
pub mod kinds;
pub mod lisp;
pub mod model;
pub mod presentations;
pub mod service_items;

View file

@ -95,7 +95,10 @@ mod test {
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_eq!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/moms-funeral.png");
assert_eq!(
screenshot_string,
"/home/chris/.local/share/lumina/thumbnails/moms-funeral.png"
);
// let runtime = tokio::runtime::Runtime::new().unwrap();
let result = bg_from_video(video, &screenshot);
@ -118,6 +121,9 @@ mod test {
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_ne!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/All WebDev Sucks and you know it.webm");
assert_ne!(
screenshot_string,
"/home/chris/.local/share/lumina/thumbnails/All WebDev Sucks and you know it.webm"
);
}
}

View file

@ -9,26 +9,34 @@ use cosmic::app::{Core, Settings, Task};
use cosmic::iced::alignment::Vertical;
use cosmic::iced::keyboard::{Key, Modifiers};
use cosmic::iced::window::{Mode, Position};
use cosmic::iced::{self, event, window, Length, Point};
use cosmic::iced::{
self, event, window, Background as IcedBackground, Border, Color,
Length,
};
use cosmic::iced_core::text::Wrapping;
use cosmic::iced_futures::Subscription;
use cosmic::iced_widget::{column, row, stack};
use cosmic::theme;
use cosmic::widget::dnd_destination::dnd_destination;
use cosmic::widget::menu::key_bind::Modifier;
use cosmic::widget::menu::{ItemWidth, KeyBind};
use cosmic::widget::nav_bar::nav_bar_style;
use cosmic::widget::tooltip::Position as TPosition;
use cosmic::widget::Container;
use cosmic::widget::{
button, horizontal_space, mouse_area, nav_bar, search_input,
tooltip, vertical_space, Space,
button, context_menu, horizontal_space, mouse_area, nav_bar,
nav_bar_toggle, responsive, scrollable, search_input, tooltip,
vertical_space, Space,
};
use cosmic::widget::{container, text};
use cosmic::widget::{icon, slider};
use cosmic::widget::{menu, Container};
use cosmic::{executor, Application, ApplicationExt, Element};
use crisp::types::Value;
use lisp::parse_lisp;
use miette::{miette, Result};
use rayon::prelude::*;
use resvg::usvg::fontdb;
use std::collections::HashMap;
use std::fs::read_to_string;
use std::path::PathBuf;
use std::sync::Arc;
@ -42,6 +50,7 @@ use ui::EditorMode;
use crate::core::kinds::ServiceItemKind;
use crate::ui::text_svg::{self};
use crate::ui::widgets::draggable;
pub mod core;
pub mod lisp;
@ -54,7 +63,7 @@ struct Cli {
watch: bool,
#[arg(short = 'i', long)]
ui: bool,
file: PathBuf,
file: Option<PathBuf>,
}
fn main() -> Result<()> {
@ -106,7 +115,9 @@ struct App {
presenter: Presenter,
windows: Vec<window::Id>,
service: Vec<ServiceItem>,
selected_items: Vec<usize>,
current_item: (usize, usize),
hovered_item: Option<usize>,
presentation_open: bool,
cli_mode: bool,
library: Option<Library>,
@ -119,6 +130,8 @@ struct App {
search_id: cosmic::widget::Id,
library_dragged_item: Option<ServiceItem>,
fontdb: Arc<fontdb::Database>,
menu_keys: HashMap<KeyBind, MenuAction>,
context_menu: Option<usize>,
}
#[derive(Debug, Clone)]
@ -129,7 +142,7 @@ enum Message {
File(PathBuf),
OpenWindow,
CloseWindow(Option<window::Id>),
WindowOpened(window::Id, Option<Point>),
WindowOpened(window::Id),
WindowClosed(window::Id),
AddLibrary(Library),
LibraryToggle,
@ -138,15 +151,53 @@ enum Message {
None,
EditorToggle(bool),
ChangeServiceItem(usize),
SelectServiceItem(usize),
AddSelectServiceItem(usize),
HoveredServiceItem(Option<usize>),
AddServiceItem(usize, ServiceItem),
RemoveServiceItem(usize),
AddServiceItemDrop(usize),
AppendServiceItem(ServiceItem),
AddService(Vec<ServiceItem>),
ReorderService(usize, usize),
ContextMenuItem(usize),
SearchFocus,
Search(String),
CloseSearch,
UpdateSearchResults(Vec<ServiceItem>),
OpenEditor(ServiceItem),
New,
Open,
OpenFile(PathBuf),
Save(Option<PathBuf>),
SaveAs,
OpenSettings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
New,
Save,
SaveAs,
Open,
OpenSettings,
DeleteItem(usize),
}
impl menu::Action for MenuAction {
type Message = Message;
fn message(&self) -> Self::Message {
match self {
MenuAction::New => Message::New,
MenuAction::Save => Message::Save(None),
MenuAction::SaveAs => Message::SaveAs,
MenuAction::Open => Message::Open,
MenuAction::OpenSettings => Message::OpenSettings,
MenuAction::DeleteItem(index) => {
Message::RemoveServiceItem(*index)
}
}
}
}
const HEADER_SPACE: u16 = 6;
@ -179,30 +230,36 @@ impl cosmic::Application for App {
windows.push(core.main_window_id().unwrap());
}
let items = match read_to_string(input.file) {
Ok(lisp) => {
let mut service_items = vec![];
let lisp = crisp::reader::read(&lisp);
match lisp {
Value::List(vec) => {
// let items = vec
// .into_par_iter()
// .map(|value| parse_lisp(value))
// .collect();
// slide_vector.append(items);
for value in vec {
let mut inner_vector = parse_lisp(value);
service_items.append(&mut inner_vector);
let items = if let Some(file) = input.file {
match read_to_string(file) {
Ok(lisp) => {
let mut service_items = vec![];
let lisp = crisp::reader::read(&lisp);
match lisp {
Value::List(vec) => {
// let items = vec
// .into_par_iter()
// .map(|value| parse_lisp(value))
// .collect();
// slide_vector.append(items);
for value in vec {
let mut inner_vector =
parse_lisp(value);
service_items
.append(&mut inner_vector);
}
}
_ => todo!(),
}
_ => todo!(),
service_items
}
Err(e) => {
warn!("Missing file or could not read: {e}");
vec![]
}
service_items
}
Err(e) => {
warn!("Missing file or could not read: {e}");
vec![]
}
} else {
vec![]
};
let items: Vec<ServiceItem> = items
@ -230,12 +287,35 @@ impl cosmic::Application for App {
// nav_model.insert().text(item.title()).data(item.clone());
// }
let mut menu_keys = HashMap::new();
menu_keys.insert(
KeyBind {
modifiers: vec![Modifier::Ctrl],
key: Key::Character("s".into()),
},
MenuAction::Save,
);
menu_keys.insert(
KeyBind {
modifiers: vec![Modifier::Ctrl],
key: Key::Character("o".into()),
},
MenuAction::Open,
);
menu_keys.insert(
KeyBind {
modifiers: vec![Modifier::Ctrl],
key: Key::Character(".".into()),
},
MenuAction::OpenSettings,
);
// nav_model.activate_position(0);
let mut app = Self {
presenter,
core,
nav_model,
service: items,
selected_items: vec![],
file: PathBuf::default(),
windows,
presentation_open: false,
@ -251,6 +331,9 @@ impl cosmic::Application for App {
current_item: (0, 0),
library_dragged_item: None,
fontdb: Arc::clone(&fontdb),
menu_keys,
hovered_item: None,
context_menu: None,
};
let mut batch = vec![];
@ -270,7 +353,82 @@ impl cosmic::Application for App {
}
fn header_start(&self) -> Vec<Element<Self::Message>> {
vec![]
let file_menu = menu::Tree::with_children(
Into::<Element<Message>>::into(menu::root("File")),
menu::items(
&self.menu_keys,
vec![
menu::Item::Button(
"New",
Some(
icon::from_name("document-new")
.symbolic(true)
.into(),
),
MenuAction::New,
),
menu::Item::Button(
"Open",
Some(
icon::from_name("document-open")
.symbolic(true)
.into(),
),
MenuAction::Open,
),
menu::Item::Button(
"Save",
Some(
icon::from_name("document-save")
.symbolic(true)
.into(),
),
MenuAction::Save,
),
menu::Item::Button(
"Save As",
Some(
icon::from_name("document-save-as")
.symbolic(true)
.into(),
),
MenuAction::SaveAs,
),
],
),
);
let settings_menu = menu::Tree::with_children(
Into::<Element<Message>>::into(
menu::root("Settings").on_press(Message::None),
),
menu::items(
&self.menu_keys,
vec![menu::Item::Button(
"Open Settings",
Some(
icon::from_name("settings")
.symbolic(true)
.into(),
),
MenuAction::OpenSettings,
)],
),
);
let menu_bar =
menu::bar::<Message>(vec![file_menu, settings_menu])
.item_width(ItemWidth::Static(250))
.main_offset(10);
let library_button = tooltip(
nav_bar_toggle().on_toggle(Message::LibraryToggle),
if self.library_open {
"Hide library"
} else {
"Show library"
},
TPosition::Bottom,
)
.gap(cosmic::theme::spacing().space_xs);
vec![library_button.into(), menu_bar.into()]
}
fn header_center(&self) -> Vec<Element<Self::Message>> {
@ -309,7 +467,8 @@ impl cosmic::Application for App {
.on_press(Message::SearchFocus),
"Search Library",
TPosition::Bottom,
),
)
.gap(cosmic::theme::spacing().space_xs),
tooltip(
button::custom(
row!(
@ -332,7 +491,8 @@ impl cosmic::Application for App {
)),
"Enter Edit Mode",
TPosition::Bottom,
),
)
.gap(cosmic::theme::spacing().space_xs),
tooltip(
button::custom(
row!(
@ -363,7 +523,8 @@ impl cosmic::Application for App {
}),
"Start Presentation",
TPosition::Bottom,
),
)
.gap(cosmic::theme::spacing().space_xs),
tooltip(
button::custom(
row!(
@ -385,6 +546,7 @@ impl cosmic::Application for App {
"Open Library",
TPosition::Bottom,
)
.gap(cosmic::theme::spacing().space_xs),
]
.spacing(HEADER_SPACE)
.into();
@ -434,23 +596,33 @@ impl cosmic::Application for App {
debug!("Closing window");
Some(Message::CloseWindow(Some(id)))
}
window::Event::Opened {
position, ..
} => {
window::Event::Opened { .. } => {
debug!(?window_event, ?id);
Some(Message::WindowOpened(id, position))
Some(Message::WindowOpened(id))
}
window::Event::Closed => {
debug!("Closed window");
Some(Message::WindowClosed(id))
}
window::Event::FileHovered(file) => {
debug!(?file);
None
}
window::Event::FileDropped(file) => {
debug!(?file);
None
}
_ => None,
}
}
iced::Event::Touch(_touch) => None,
iced::Event::A11y(_id, _action_request) => None,
iced::Event::Dnd(_dnd_event) => None,
iced::Event::PlatformSpecific(_platform_specific) => {
iced::Event::Dnd(_dnd_event) => {
// debug!(?dnd_event);
None
}
iced::Event::PlatformSpecific(platform_specific) => {
debug!(?platform_specific);
None
}
}
@ -580,7 +752,7 @@ impl cosmic::Application for App {
})
}
song_editor::Action::UpdateSong(song) => {
if let Some(library) = &mut self.library {
if let Some(_) = &mut self.library {
self.update(Message::Library(
library::Message::UpdateSong(song),
))
@ -612,19 +784,11 @@ impl cosmic::Application for App {
self.service.get(item_index)
{
if item.slides.len() > slide_index + 1 {
// let slide_length = item.slides.len();
// debug!(
// slide_index,
// slide_length,
// ?item,
// "Slides are longer"
// );
let slide = item.slides
[slide_index + 1]
.clone();
let slide_index = slide_index + 1;
let action = self.presenter.update(
presenter::Message::SlideChange(
slide,
presenter::Message::ActivateSlide(
item_index,
slide_index,
),
);
match action {
@ -644,10 +808,12 @@ impl cosmic::Application for App {
// debug!("Slides are not longer");
self.current_item =
(item_index + 1, 0);
if let Some(item) =
self.service.get(item_index + 1)
if self
.service
.get(item_index + 1)
.is_some()
{
let action = self.presenter.update(presenter::Message::SlideChange(item.slides[0].clone()));
let action = self.presenter.update(presenter::Message::ActivateSlide(self.current_item.0, self.current_item.1));
match action {
presenter::Action::Task(
task,
@ -678,12 +844,11 @@ impl cosmic::Application for App {
self.service.get(item_index)
{
if slide_index != 0 {
let slide = item.slides
[slide_index - 1]
.clone();
let slide_index = slide_index - 1;
let action = self.presenter.update(
presenter::Message::SlideChange(
slide,
presenter::Message::ActivateSlide(
item_index,
slide_index,
),
);
match action {
@ -718,10 +883,12 @@ impl cosmic::Application for App {
item_index - 1,
previous_item_slides_length - 1,
);
if let Some(item) =
self.service.get(item_index - 1)
if self
.service
.get(item_index - 1)
.is_some()
{
let action = self.presenter.update(presenter::Message::SlideChange(item.slides[previous_item_slides_length - 1].clone()));
let action = self.presenter.update(presenter::Message::ActivateSlide(self.current_item.0, self.current_item.1));
match action {
presenter::Action::Task(
task,
@ -818,9 +985,7 @@ impl cosmic::Application for App {
.set_window_title(format!("window_{count}"), id);
spawn_window.map(|id| {
cosmic::Action::App(Message::WindowOpened(
id, None,
))
cosmic::Action::App(Message::WindowOpened(id))
})
}
Message::CloseWindow(id) => {
@ -830,7 +995,7 @@ impl cosmic::Application for App {
Task::none()
}
}
Message::WindowOpened(id, _) => {
Message::WindowOpened(id) => {
debug!(?id, "Window opened");
if self.cli_mode
|| id > self.core.main_window_id().expect("Cosmic core seems to be missing a main window, was this started in cli mode?")
@ -873,10 +1038,6 @@ impl cosmic::Application for App {
self.library = Some(library);
Task::none()
}
Message::AddService(service) => {
self.service = service;
Task::none()
}
Message::None => Task::none(),
Message::EditorToggle(edit) => {
if edit {
@ -892,6 +1053,18 @@ impl cosmic::Application for App {
self.search_id.clone(),
)
}
Message::HoveredServiceItem(index) => {
self.hovered_item = index;
Task::none()
}
Message::SelectServiceItem(index) => {
self.selected_items = vec![index];
Task::none()
}
Message::AddSelectServiceItem(index) => {
self.selected_items.push(index);
Task::none()
}
Message::ChangeServiceItem(index) => {
if let Some((index, item)) = self
.service
@ -902,8 +1075,9 @@ impl cosmic::Application for App {
{
self.current_item = (index, 0);
self.presenter.update(
presenter::Message::SlideChange(
slide.clone(),
presenter::Message::ActivateSlide(
self.current_item.0,
self.current_item.1,
),
);
}
@ -925,6 +1099,15 @@ impl cosmic::Application for App {
self.presenter.update_items(self.service.clone());
Task::none()
}
Message::RemoveServiceItem(index) => {
self.service.remove(index);
self.presenter.update_items(self.service.clone());
Task::none()
}
Message::ContextMenuItem(index) => {
self.context_menu = Some(index);
Task::none()
}
Message::AddServiceItemDrop(index) => {
if let Some(item) = &self.library_dragged_item {
self.service.insert(index, item.clone());
@ -947,6 +1130,12 @@ impl cosmic::Application for App {
self.presenter.update_items(self.service.clone());
Task::none()
}
Message::ReorderService(index, target_index) => {
let item = self.service.remove(index);
self.service.insert(target_index, item);
self.presenter.update_items(self.service.clone());
Task::none()
}
Message::Search(query) => {
self.search_query = query.clone();
self.search(query)
@ -978,6 +1167,34 @@ impl cosmic::Application for App {
ServiceItemKind::Content(_slide) => todo!(),
}
}
Message::New => {
debug!("new file");
Task::none()
}
Message::Open => {
debug!("Open file");
Task::none()
}
Message::OpenFile(file) => {
debug!(?file, "opening file");
Task::none()
}
Message::Save(file) => {
let Some(file) = file else {
debug!("saving current");
return Task::none();
};
debug!(?file, "saving new file");
Task::none()
}
Message::SaveAs => {
debug!("saving as a file");
Task::none()
}
Message::OpenSettings => {
debug!("Opening settings");
Task::none()
}
}
}
@ -1069,8 +1286,7 @@ impl cosmic::Application for App {
let song_editor =
self.song_editor.view().map(Message::SongEditor);
let row = row![
library,
let service_row = row![
service_list,
Container::new(
button::icon(icon_left)
@ -1104,23 +1320,33 @@ impl cosmic::Application for App {
.height(Length::Fill)
.spacing(20);
let column = column![
Container::new(row).center_y(Length::Fill),
let preview_bar = if self.editor_mode.is_none() {
Container::new(
self.presenter.preview_bar().map(Message::Present)
self.presenter.preview_bar().map(Message::Present),
)
.clip(true)
.width(Length::Fill)
.center_y(180)
];
} else {
Container::new(horizontal_space())
};
if let Some(_editor) = &self.editor_mode {
let main_area = if let Some(editor) = &self.editor_mode {
container(song_editor)
.padding(cosmic::theme::spacing().space_xxl)
.into()
} else {
Element::from(column)
}
Container::new(service_row).center_y(Length::Fill)
};
let column = column![
row![
library.width(Length::FillPortion(1)),
main_area.width(Length::FillPortion(4))
],
preview_bar
];
column.into()
}
// View for presentation
@ -1159,9 +1385,8 @@ where
});
self.windows.push(id);
_ = self.set_window_title("Lumina Presenter".to_owned(), id);
spawn_window.map(|id| {
cosmic::Action::App(Message::WindowOpened(id, None))
})
spawn_window
.map(|id| cosmic::Action::App(Message::WindowOpened(id)))
}
fn add_library(&self) -> Task<Message> {
@ -1185,36 +1410,6 @@ where
}
}
fn add_service(
&self,
items: Vec<ServiceItem>,
fontdb: Arc<fontdb::Database>,
) -> Task<Message> {
Task::perform(
async move {
let items: Vec<ServiceItem> = items
.into_par_iter()
.map(|mut item| {
item.slides = item
.slides
.into_par_iter()
.map(|mut slide| {
text_svg::text_svg_generator(
&mut slide,
Arc::clone(&fontdb),
);
slide
})
.collect();
item
})
.collect();
items
},
|x| cosmic::Action::App(Message::AddService(x)),
)
}
fn process_key_press(
&mut self,
key: Key,
@ -1237,6 +1432,15 @@ where
}
}
match (key, modifiers) {
(Key::Character(k), Modifiers::CTRL) if k == *"s" => {
self.update(Message::Save(None))
}
(Key::Character(k), Modifiers::CTRL) if k == *"o" => {
self.update(Message::Open)
}
(Key::Character(k), Modifiers::CTRL) if k == *"." => {
self.update(Message::OpenSettings)
}
(Key::Character(k), Modifiers::CTRL)
if k == *"k" || k == *"f" =>
{
@ -1278,65 +1482,189 @@ where
}
fn service_list(&self) -> Element<Message> {
let list = self
.service
.iter()
.enumerate()
.map(|(index, item)| {
let button = button::standard(item.title.clone())
.leading_icon({
match item.kind {
core::kinds::ServiceItemKind::Song(_) => {
icon::from_name("folder-music-symbolic")
},
core::kinds::ServiceItemKind::Video(_) => {
icon::from_name("folder-videos-symbolic")
},
core::kinds::ServiceItemKind::Image(_) => {
icon::from_name("folder-pictures-symbolic")
},
core::kinds::ServiceItemKind::Presentation(_) => {
icon::from_name("x-office-presentation-symbolic")
},
core::kinds::ServiceItemKind::Content(_) => {
icon::from_name("x-office-presentation-symbolic")
},
}
})
// .icon_size(cosmic::theme::spacing().space_l)
.class(cosmic::theme::style::Button::HeaderBar)
// .spacing(cosmic::theme::spacing().space_l)
// .padding(cosmic::theme::spacing().space_m)
// .height(cosmic::theme::spacing().space_xxxl)
.width(Length::Fill)
.on_press(Message::ChangeServiceItem(index));
let tooltip = tooltip(button,
text::body(item.kind.to_string()),
TPosition::Right);
dnd_destination(tooltip, vec!["application/service-item".into()])
.data_received_for::<ServiceItem>( move |item| {
if let Some(item) = item {
Message::AddServiceItem(index, item)
} else {
Message::None
}
}).on_finish(move |mime, data, action, x, y| {
debug!(mime, ?data, ?action, x, y);
let Ok(item) = ServiceItem::try_from((data, mime)) else {
return Message::None;
};
debug!(?item);
Message::AddServiceItem(index, item)
})
let list =
self.service.iter().enumerate().map(|(index, item)| {
let icon = match item.kind {
ServiceItemKind::Song(_) => {
icon::from_name("folder-music-symbolic")
}
ServiceItemKind::Video(_) => {
icon::from_name("folder-videos-symbolic")
}
ServiceItemKind::Image(_) => {
icon::from_name("folder-pictures-symbolic")
}
ServiceItemKind::Presentation(_) => {
icon::from_name(
"x-office-presentation-symbolic",
)
}
ServiceItemKind::Content(_) => icon::from_name(
"x-office-presentation-symbolic",
),
};
let title = responsive(|size| {
text::heading(library::elide_text(
&item.title,
size.width,
))
.align_y(Vertical::Center)
.wrapping(Wrapping::None)
.into()
});
let container = container(
row![icon, title]
.align_y(Vertical::Center)
.spacing(cosmic::theme::spacing().space_xs),
)
.height(cosmic::theme::spacing().space_xl)
.padding(cosmic::theme::spacing().space_s)
.class(cosmic::theme::style::Container::Secondary)
.style(move |t| {
container::Style::default()
.background(IcedBackground::Color(
if self.hovered_item.is_some_and(
|hovered_index| {
index == hovered_index
},
) || self
.selected_items
.contains(&index)
{
t.cosmic().button.hover.into()
} else {
t.cosmic().button.base.into()
},
))
.border(Border::default().rounded(
t.cosmic().corner_radii.radius_m,
))
})
.width(Length::Fill);
let mouse_area = mouse_area(container)
.on_enter(Message::HoveredServiceItem(Some(
index,
)))
.on_exit(Message::HoveredServiceItem(None))
.on_double_press(Message::ChangeServiceItem(
index,
))
.on_drag(Message::None)
.on_right_press(Message::ContextMenuItem(index))
.on_release(Message::SelectServiceItem(index));
let single_item = if let Some(context_menu_item) =
self.context_menu
{
if context_menu_item == index {
let context_menu = context_menu(
mouse_area,
self.context_menu.map_or_else(
|| None,
|i| {
if i == index {
let menu =
vec![menu::Item::Button(
"Delete",
None,
MenuAction::DeleteItem(index),
)];
Some(menu::items(
&HashMap::new(),
menu,
))
} else {
None
}
},
),
)
.close_on_escape(true);
Element::from(context_menu)
} else {
Element::from(mouse_area)
}
} else {
Element::from(mouse_area)
};
let tooltip = tooltip(
single_item,
text::body(item.kind.to_string()),
TPosition::Right,
)
.gap(cosmic::theme::spacing().space_xs);
dnd_destination(
tooltip,
vec!["application/service-item".into()],
)
.data_received_for::<ServiceItem>(move |item| {
if let Some(item) = item {
Message::AddServiceItem(index, item)
} else {
Message::None
}
})
.on_finish(move |mime, data, action, x, y| {
debug!(mime, ?data, ?action, x, y);
let Ok(item) =
ServiceItem::try_from((data, mime))
else {
return Message::None;
};
debug!(?item);
Message::AddServiceItem(index, item)
})
.into()
});
let scrollable = scrollable(
draggable::column::column(list)
.spacing(10)
.on_drag(|event| match event {
draggable::DragEvent::Picked { .. } => {
Message::None
}
draggable::DragEvent::Dropped {
index,
target_index,
..
} => Message::ReorderService(index, target_index),
draggable::DragEvent::Canceled { .. } => {
Message::None
}
})
.style(|t| draggable::column::Style {
scale: 1.05,
moved_item_overlay: Color::from(
t.cosmic().primary.base,
)
.scale_alpha(0.2)
.into(),
ghost_border: Border {
width: 1.0,
color: t.cosmic().secondary.base.into(),
radius: t.cosmic().radius_m().into(),
},
ghost_background: Color::from(
t.cosmic().secondary.base,
)
.scale_alpha(0.2)
.into(),
})
.height(Length::Shrink),
)
.anchor_top()
.height(Length::Fill);
let column = column![
text::heading("Service List")
.center()
.width(Length::Fill),
iced::widget::horizontal_rule(1),
column(list).spacing(10),
scrollable
]
.padding(10)
.spacing(10);
let container = Container::new(stack![
dnd_destination(
vertical_space().width(Length::Fill),
vec!["application/service-item".into()]
@ -1358,33 +1686,11 @@ where
debug!(?item);
Message::AppendServiceItem(item)
}
)
]
.padding(10)
.spacing(10);
let container = Container::new(column)
// .height(Length::Fill)
.style(nav_bar_style);
),
column
])
.style(nav_bar_style);
container.center(Length::FillPortion(2)).into()
}
}
#[cfg(test)]
mod test {
fn test_slide() -> String {
let slide = r#"(slide (image :source "./somehting.jpg" :fill cover
(text "Something cooler" :font-size 50)))"#;
String::from(slide)
}
// #[test]
// fn test_lisp() {
// let slide = test_slide();
// if let Ok(data) = lexpr::parse::from_str_elisp(slide.as_str()) {
// assert_eq!(slide, data)
// } else {
// assert!(false)
// }
// }
}

View file

@ -52,7 +52,6 @@ pub struct Library {
enum MenuMessage {
Delete((LibraryKind, i32)),
Open,
None,
}
impl MenuAction for MenuMessage {
@ -64,7 +63,6 @@ impl MenuAction for MenuMessage {
Message::DeleteItem((*kind, *index))
}
MenuMessage::Open => todo!(),
MenuMessage::None => todo!(),
}
}
}
@ -314,9 +312,44 @@ impl<'a> Library {
})
.ok()
}
LibraryKind::Video => todo!(),
LibraryKind::Image => todo!(),
LibraryKind::Presentation => todo!(),
LibraryKind::Video => {
let video = Video::default();
self.video_library
.add_item(video)
.map(|_| {
let index =
self.video_library.items.len();
(LibraryKind::Video, index as i32)
})
.ok()
}
LibraryKind::Image => {
let image = Image::default();
self.image_library
.add_item(image)
.map(|_| {
let index =
self.image_library.items.len();
(LibraryKind::Image, index as i32)
})
.ok()
}
LibraryKind::Presentation => {
let presentation = Presentation::default();
self.presentation_library
.add_item(presentation)
.map(|_| {
let index = self
.presentation_library
.items
.len();
(
LibraryKind::Presentation,
index as i32,
)
})
.ok()
}
};
return self.update(Message::OpenItem(item));
}
@ -591,40 +624,43 @@ impl<'a> Library {
column({
model.items.iter().enumerate().map(
|(index, item)| {
let service_item = item.to_service_item();
let visual_item = self
.single_item(index, item, model)
.map(|()| Message::None);
DndSource::<Message, ServiceItem>::new({
let mouse_area = Element::from(mouse_area(visual_item)
.on_drag(Message::DragItem(service_item.clone()))
.on_enter(Message::HoverItem(
Some((
model.kind,
index as i32,
)),
))
.on_double_click(
Message::OpenItem(Some((
model.kind,
index as i32,
))),
)
.on_right_press(Message::OpenContext(index as i32))
.on_exit(Message::HoverItem(None))
.on_press(Message::SelectItem(
Some((
model.kind,
index as i32,
)),
)));
let mouse_area = mouse_area(visual_item);
let mouse_area = mouse_area.on_enter(Message::HoverItem(
Some((
model.kind,
index as i32,
)),
))
.on_double_click(
Message::OpenItem(Some((
model.kind,
index as i32,
))),
)
.on_right_press(Message::OpenContext(index as i32))
.on_exit(Message::HoverItem(None))
.on_press(Message::SelectItem(
Some((
model.kind,
index as i32,
)),
));
if let Some(context_id) = self.context_menu {
if index == context_id as usize {
let menu_items = vec![menu::Item::Button("Delete", None, MenuMessage::Delete((model.kind, index as i32)))];
let context_menu = context_menu(
mouse_area,
self.context_menu.map_or_else(|| None, |_| {
Some(menu::items(&self.menu_keys,
vec![menu::Item::Button("Delete", None, MenuMessage::Delete((model.kind, index as i32)))]))
menu_items))
})
);
Element::from(context_menu)
@ -958,7 +994,7 @@ async fn add_db() -> Result<SqlitePool> {
SqlitePool::connect(&db_url).await.into_diagnostic()
}
fn elide_text(text: impl AsRef<str>, width: f32) -> String {
pub fn elide_text(text: impl AsRef<str>, width: f32) -> String {
const CHAR_SIZE: f32 = 8.0;
let text: String = text.as_ref().to_owned();
let text_length = text.len() as f32 * CHAR_SIZE;

View file

@ -3,6 +3,7 @@ use crate::core::model::LibraryKind;
pub mod double_ended_slider;
pub mod library;
pub mod presenter;
pub mod service;
pub mod slide_editor;
pub mod song_editor;
pub mod text_svg;

View file

@ -1,5 +1,10 @@
use miette::{IntoDiagnostic, Result};
use std::{fs::File, io::BufReader, path::PathBuf, sync::Arc};
use std::{
fs::File,
io::BufReader,
path::PathBuf,
sync::{Arc, LazyLock},
};
use cosmic::{
iced::{
@ -31,7 +36,8 @@ use crate::{
};
const REFERENCE_WIDTH: f32 = 1920.0;
const REFERENCE_HEIGHT: f32 = 1080.0;
static DEFAULT_SLIDE: LazyLock<Slide> =
LazyLock::new(|| Slide::default());
// #[derive(Default, Clone, Debug)]
pub(crate) struct Presenter {
@ -62,6 +68,7 @@ pub(crate) enum Message {
NextSlide,
PrevSlide,
SlideChange(Slide),
ActivateSlide(usize, usize),
EndVideo,
StartVideo,
StartAudio,
@ -147,14 +154,22 @@ impl Presenter {
let total_slides: usize =
items.iter().fold(0, |a, item| a + item.slides.len());
let slide =
items.get(0).map(|item| item.slides.get(0)).flatten();
let audio = items
.get(0)
.map(|item| item.slides.get(0).map(|slide| slide.audio()))
.flatten()
.flatten();
Self {
current_slide: items[0].slides[0].clone(),
current_slide: slide.unwrap_or(&DEFAULT_SLIDE).clone(),
current_item: 0,
current_slide_index: 0,
absolute_slide_index: 0,
total_slides,
video,
audio: items[0].slides[0].audio(),
audio,
service: items,
video_position: 0.0,
hovered_slide: None,
@ -200,6 +215,19 @@ impl Presenter {
// self.current_slide_index - 1,
// ));
}
Message::ActivateSlide(item_index, slide_index) => {
if let Some(slide) = self
.service
.get(item_index)
.map(|item| item.slides.get(slide_index))
.flatten()
{
self.current_item = item_index;
self.current_slide_index = slide_index;
return self
.update(Message::SlideChange(slide.clone()));
}
}
Message::SlideChange(slide) => {
let slide_text = slide.text();
debug!(slide_text, "slide changed");
@ -221,10 +249,34 @@ impl Presenter {
self.reset_video();
}
let mut target_item = 0;
self.service.iter().enumerate().try_for_each(
|(index, item)| {
item.slides.iter().enumerate().try_for_each(
|(slide_index, _)| {
target_item += 1;
if (index, slide_index)
== (
self.current_item,
self.current_slide_index,
)
{
None
} else {
Some(())
}
},
)
},
);
debug!(target_item);
let offset = AbsoluteOffset {
x: {
if self.current_slide_index > 2 {
(self.current_slide_index as f32)
if target_item > 2 {
(target_item as f32)
.mul_add(187.5, -187.5)
} else {
0.0
@ -232,7 +284,7 @@ impl Presenter {
},
y: 0.0,
};
debug!(?offset);
let mut tasks = vec![];
tasks.push(scroll_to(self.scroll_id.clone(), offset));
@ -399,21 +451,11 @@ impl Presenter {
}
pub fn view(&self) -> Element<Message> {
slide_view(
self.current_slide.clone(),
&self.video,
false,
true,
)
slide_view(&self.current_slide, &self.video, false, true)
}
pub fn view_preview(&self) -> Element<Message> {
slide_view(
self.current_slide.clone(),
&self.video,
false,
false,
)
slide_view(&self.current_slide, &self.video, false, false)
}
pub fn preview_bar(&self) -> Element<Message> {
@ -423,19 +465,6 @@ impl Presenter {
let mut slides = vec![];
item.slides.iter().enumerate().for_each(
|(slide_index, slide)| {
let font_name = slide.font().into_boxed_str();
let family =
Family::Name(Box::leak(font_name));
let weight = Weight::Normal;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
family,
weight,
stretch,
style,
};
let is_current_slide =
(item_index, slide_index)
== (
@ -444,7 +473,7 @@ impl Presenter {
);
let container = slide_view(
slide.clone(),
&slide,
&self.video,
true,
false,
@ -518,8 +547,9 @@ impl Presenter {
)))
})
.on_exit(Message::HoveredSlide(None))
.on_press(Message::SlideChange(
slide.clone(),
.on_press(Message::ActivateSlide(
item_index,
slide_index,
));
slides.push(delegate.into());
},
@ -705,7 +735,7 @@ fn scale_font(font_size: f32, width: f32) -> f32 {
}
pub(crate) fn slide_view<'a>(
slide: Slide,
slide: &'a Slide,
video: &'a Option<Video>,
delegate: bool,
hide_mouse: bool,
@ -775,7 +805,7 @@ pub(crate) fn slide_view<'a>(
})
.content_fit(ContentFit::Cover),
)
.center(Length::Shrink)
.center(Length::Fill)
.clip(true)
// Container::new(Space::new(0, 0))
} else {

353
src/ui/service.rs Normal file
View file

@ -0,0 +1,353 @@
use cosmic::iced::Size;
use cosmic::iced_core::widget::tree;
use cosmic::{
iced::{
clipboard::dnd::{DndEvent, SourceEvent},
event, mouse, Event, Length, Point, Rectangle, Vector,
},
iced_core::{
self, image::Renderer, layout, renderer, widget::Tree,
Clipboard, Shell,
},
widget::Widget,
Element,
};
use tracing::debug;
use crate::core::service_items::ServiceItem;
pub fn service<'a, Message: Clone + 'static>(
service: &'a Vec<ServiceItem>,
) -> Service<'a, Message> {
Service::new(service)
}
pub struct Service<'a, Message> {
service: &'a Vec<ServiceItem>,
on_start: Option<Message>,
on_cancelled: Option<Message>,
on_finish: Option<Message>,
drag_threshold: f32,
width: Length,
height: Length,
}
impl<'a, Message: Clone + 'static> Service<'a, Message> {
pub fn new(service: &'a Vec<ServiceItem>) -> Self {
Self {
service,
drag_threshold: 8.0,
on_start: None,
on_cancelled: None,
on_finish: None,
width: Length::Fill,
height: Length::Fill,
}
}
#[must_use]
pub fn drag_threshold(mut self, threshold: f32) -> Self {
self.drag_threshold = threshold;
self
}
// pub fn start_dnd(
// &self,
// clipboard: &mut dyn Clipboard,
// bounds: Rectangle,
// offset: Vector,
// ) {
// let Some(content) = self.drag_content.as_ref().map(|f| f())
// else {
// return;
// };
// iced_core::clipboard::start_dnd(
// clipboard,
// false,
// if let Some(window) = self.window.as_ref() {
// Some(iced_core::clipboard::DndSource::Surface(
// *window,
// ))
// } else {
// Some(iced_core::clipboard::DndSource::Widget(
// self.id.clone(),
// ))
// },
// self.drag_icon.as_ref().map(|f| {
// let (icon, state, offset) = f(offset);
// iced_core::clipboard::IconSurface::new(
// container(icon)
// .width(Length::Fixed(bounds.width))
// .height(Length::Fixed(bounds.height))
// .into(),
// state,
// offset,
// )
// }),
// Box::new(content),
// DndAction::Move,
// );
// }
pub fn on_start(mut self, on_start: Option<Message>) -> Self {
self.on_start = on_start;
self
}
pub fn on_cancel(
mut self,
on_cancelled: Option<Message>,
) -> Self {
self.on_cancelled = on_cancelled;
self
}
pub fn on_finish(mut self, on_finish: Option<Message>) -> Self {
self.on_finish = on_finish;
self
}
}
impl<Message: Clone + 'static>
Widget<Message, cosmic::Theme, cosmic::Renderer>
for Service<'_, Message>
{
fn size(&self) -> iced_core::Size<Length> {
Size {
width: Length::Fill,
height: Length::Fill,
}
}
fn layout(
&self,
_tree: &mut Tree,
_renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(limits, self.width, self.height)
}
fn state(&self) -> iced_core::widget::tree::State {
tree::State::new(State::new())
}
// fn operate(
// &self,
// tree: &mut Tree,
// layout: layout::Layout<'_>,
// renderer: &cosmic::Renderer,
// operation: &mut dyn iced_core::widget::Operation<()>,
// ) {
// operation.custom(
// (&mut tree.state) as &mut dyn Any,
// Some(&self.id),
// );
// operation.container(
// Some(&self.id),
// layout.bounds(),
// &mut |operation| {
// self.container.as_widget().operate(
// &mut tree.children[0],
// layout,
// renderer,
// operation,
// )
// },
// );
// }
fn on_event(
&mut self,
tree: &mut Tree,
event: Event,
layout: layout::Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) -> event::Status {
let state = tree.state.downcast_mut::<State>();
match event {
Event::Mouse(mouse_event) => match mouse_event {
mouse::Event::ButtonPressed(mouse::Button::Left) => {
if let Some(position) = cursor.position() {
if !state.hovered {
return event::Status::Ignored;
}
state.left_pressed_position = Some(position);
return event::Status::Captured;
}
}
mouse::Event::ButtonReleased(mouse::Button::Left)
if state.left_pressed_position.is_some() =>
{
state.left_pressed_position = None;
return event::Status::Captured;
}
mouse::Event::CursorMoved { .. } => {
if let Some(position) = cursor.position() {
if state.hovered {
// We ignore motion if we do not possess drag content by now.
if let Some(left_pressed_position) =
state.left_pressed_position
{
if position
.distance(left_pressed_position)
> self.drag_threshold
{
if let Some(on_start) =
self.on_start.as_ref()
{
shell
.publish(on_start.clone())
}
let offset = Vector::new(
left_pressed_position.x
- layout.bounds().x,
left_pressed_position.y
- layout.bounds().y,
);
state.is_dragging = true;
state.left_pressed_position =
None;
}
}
if !cursor.is_over(layout.bounds()) {
state.hovered = false;
return event::Status::Ignored;
}
} else if cursor.is_over(layout.bounds()) {
state.hovered = true;
}
return event::Status::Captured;
}
}
_ => return event::Status::Ignored,
},
Event::Dnd(DndEvent::Source(SourceEvent::Cancelled)) => {
debug!("canceled");
if state.is_dragging {
if let Some(m) = self.on_cancelled.as_ref() {
shell.publish(m.clone());
}
state.is_dragging = false;
return event::Status::Captured;
}
return event::Status::Ignored;
}
Event::Dnd(DndEvent::Source(SourceEvent::Finished)) => {
debug!("dropped");
if state.is_dragging {
if let Some(m) = self.on_finish.as_ref() {
shell.publish(m.clone());
}
state.is_dragging = false;
return event::Status::Captured;
}
return event::Status::Ignored;
}
Event::Dnd(event) => debug!(?event),
_ => return event::Status::Ignored,
}
event::Status::Ignored
}
// fn mouse_interaction(
// &self,
// tree: &Tree,
// layout: layout::Layout<'_>,
// cursor_position: mouse::Cursor,
// viewport: &Rectangle,
// renderer: &cosmic::Renderer,
// ) -> mouse::Interaction {
// let state = tree.state.downcast_ref::<State>();
// if state.is_dragging {
// return mouse::Interaction::Grabbing;
// }
// self.container.as_widget().mouse_interaction(
// &tree.children[0],
// layout,
// cursor_position,
// viewport,
// renderer,
// )
// }
fn draw(
&self,
tree: &Tree,
renderer: &mut cosmic::Renderer,
theme: &cosmic::Theme,
renderer_style: &renderer::Style,
layout: layout::Layout<'_>,
cursor_position: mouse::Cursor,
viewport: &Rectangle,
) {
// let state = tree.state.downcast_mut::<State>();
for item in self.service {}
}
// fn overlay<'b>(
// &'b mut self,
// tree: &'b mut Tree,
// layout: layout::Layout<'_>,
// renderer: &cosmic::Renderer,
// translation: Vector,
// ) -> Option<
// overlay::Element<
// 'b,
// Message,
// cosmic::Theme,
// cosmic::Renderer,
// >,
// > {
// self.container.as_widget_mut().overlay(
// &mut tree.children[0],
// layout,
// renderer,
// translation,
// )
// }
// #[cfg(feature = "a11y")]
// /// get the a11y nodes for the widget
// fn a11y_nodes(
// &self,
// layout: iced_core::Layout<'_>,
// state: &Tree,
// p: mouse::Cursor,
// ) -> iced_accessibility::A11yTree {
// let c_state = &state.children[0];
// self.container.as_widget().a11y_nodes(layout, c_state, p)
// }
}
impl<'a, Message: Clone + 'static> From<Service<'a, Message>>
for Element<'a, Message>
{
fn from(e: Service<'a, Message>) -> Element<'a, Message> {
Element::new(e)
}
}
/// Local state of the [`MouseListener`].
#[derive(Debug, Default)]
struct State {
hovered: bool,
left_pressed_position: Option<Point>,
is_dragging: bool,
cached_bounds: Rectangle,
}
impl State {
fn new() -> Self {
Self::default()
}
}

View file

@ -1,27 +1,28 @@
use std::{io, path::PathBuf, sync::Arc};
use cosmic::{
dialog::file_chooser::open::Dialog,
iced::{
font::{Family, Stretch, Style, Weight},
Font, Length,
},
dialog::file_chooser::{open::Dialog, FileFilter},
iced::{alignment::Vertical, Length},
iced_wgpu::graphics::text::cosmic_text::fontdb,
iced_widget::row,
iced_widget::{column, row},
theme,
widget::{
button, column, combo_box, container, horizontal_space, icon,
scrollable, text, text_editor, text_input,
button, combo_box, container, horizontal_space, icon,
progress_bar, scrollable, text, text_editor, text_input,
vertical_space,
},
Element, Task,
};
use dirs::font_dir;
use iced_video_player::Video;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use tracing::{debug, error};
use crate::{
core::{service_items::ServiceTrait, songs::Song},
ui::{presenter::slide_view, slide_editor::SlideEditor},
core::{service_items::ServiceTrait, slide::Slide, songs::Song},
ui::{
presenter::slide_view, slide_editor::SlideEditor, text_svg,
},
Background, BackgroundKind,
};
@ -30,7 +31,6 @@ pub struct SongEditor {
pub song: Option<Song>,
title: String,
font_db: Arc<fontdb::Database>,
fonts: Vec<(fontdb::ID, String)>,
fonts_combo: combo_box::State<String>,
font_sizes: combo_box::State<String>,
font: String,
@ -42,8 +42,8 @@ pub struct SongEditor {
editing: bool,
background: Option<Background>,
video: Option<Video>,
current_font: Font,
ccli: String,
song_slides: Option<Vec<Slide>>,
slide_state: SlideEditor,
}
@ -67,29 +67,19 @@ pub enum Message {
Edit(bool),
None,
ChangeAuthor(String),
PauseVideo,
}
impl SongEditor {
pub fn new(font_db: Arc<fontdb::Database>) -> Self {
let fonts = font_dir();
debug!(?fonts);
let mut fonts: Vec<(fontdb::ID, String)> = font_db
let mut fonts: Vec<String> = font_db
.faces()
.map(|f| {
let id = f.id;
let font_base_name: String =
f.families.iter().map(|f| f.0.clone()).collect();
let font_weight = f.weight;
let font_style = f.style;
let font_stretch = f.stretch;
(id, font_base_name)
})
.map(|f| f.families[0].0.clone())
.collect();
fonts.dedup();
// let fonts = vec![
// String::from("Quicksand"),
// String::from("Noto Sans"),
// ];
fonts.sort();
let font_sizes = vec![
"5".to_string(),
"6".to_string(),
@ -111,33 +101,51 @@ impl SongEditor {
"65".to_string(),
"70".to_string(),
"80".to_string(),
"90".to_string(),
"100".to_string(),
"110".to_string(),
"120".to_string(),
"130".to_string(),
"140".to_string(),
"150".to_string(),
"160".to_string(),
"170".to_string(),
];
let font_texts = fonts.iter().map(|f| f.1.clone()).collect();
Self {
song: None,
font_db,
fonts,
fonts_combo: combo_box::State::new(font_texts),
title: "Death was Arrested".to_owned(),
font: "Quicksand".to_owned(),
fonts_combo: combo_box::State::new(fonts),
title: "Death was Arrested".to_string(),
font: "Quicksand".to_string(),
font_size: 16,
font_sizes: combo_box::State::new(font_sizes),
verse_order: "Death was Arrested".to_owned(),
verse_order: "Death was Arrested".to_string(),
lyrics: text_editor::Content::new(),
editing: false,
author: "North Point Worship".into(),
audio: PathBuf::new(),
background: None,
video: None,
current_font: cosmic::font::default(),
ccli: "8".to_owned(),
ccli: "8".to_string(),
slide_state: SlideEditor::default(),
song_slides: None,
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangeSong(song) => {
self.song = Some(song.clone());
self.song_slides = song.to_slides().ok().map(|v| {
v.into_par_iter()
.map(|mut s| {
text_svg::text_svg_generator(
&mut s,
Arc::clone(&self.font_db),
);
s
})
.collect::<Vec<Slide>>()
});
self.title = song.title;
if let Some(font) = song.font {
self.font = font;
@ -171,35 +179,21 @@ impl SongEditor {
self.background = song.background;
}
Message::ChangeFont(font) => {
let font_id = self
.fonts
.iter()
.filter(|f| f.1 == font)
.map(|f| f.0)
.next();
if let Some(id) = font_id
&& let Some(face) = self.font_db.face(id)
{
self.font = face.post_script_name.clone();
// self.current_font = Font::from(face);
}
self.font = font.clone();
let font_name = font.into_boxed_str();
let family = Family::Name(Box::leak(font_name));
let weight = Weight::Bold;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
family,
weight,
stretch,
style,
};
self.current_font = font;
// return self.update_song(song);
if let Some(song) = &mut self.song {
song.font = Some(font);
let song = song.to_owned();
return self.update_song(song);
}
}
Message::ChangeFontSize(size) => {
self.font_size = size;
if let Some(song) = &mut self.song {
song.font_size = Some(size as i32);
let song = song.to_owned();
return self.update_song(song);
}
}
Message::ChangeFontSize(size) => self.font_size = size,
Message::ChangeTitle(title) => {
self.title = title.clone();
if let Some(song) = &mut self.song {
@ -259,66 +253,92 @@ impl SongEditor {
Message::ChangeBackground,
));
}
Message::PauseVideo => {
if let Some(video) = &mut self.video {
let paused = video.paused();
video.set_paused(!paused);
};
}
_ => (),
}
Action::None
}
pub fn view(&self) -> Element<Message> {
let video_elements = if let Some(video) = &self.video {
let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start")
} else {
icon::from_name("media-playback-pause")
})
.on_press(Message::PauseVideo);
let video_track = progress_bar(
0.0..=video.duration().as_secs_f32(),
video.position().as_secs_f32(),
)
.height(cosmic::theme::spacing().space_s)
.width(Length::Fill);
container(
row![play_button, video_track]
.align_y(Vertical::Center)
.spacing(cosmic::theme::spacing().space_m),
)
.padding(cosmic::theme::spacing().space_s)
.center_x(Length::FillPortion(2))
} else {
container(vertical_space())
};
let slide_preview = container(self.slide_preview())
.width(Length::FillPortion(2));
let column = column::with_children(vec![
let slide_section = column![video_elements, slide_preview]
.spacing(cosmic::theme::spacing().space_s);
let column = column![
self.toolbar(),
row![
container(self.left_column())
.center_x(Length::FillPortion(2)),
container(slide_preview)
.center_x(Length::FillPortion(3))
]
.into(),
])
container(slide_section)
.center_x(Length::FillPortion(2))
],
]
.spacing(theme::active().cosmic().space_l());
column.into()
}
fn slide_preview(&self) -> Element<Message> {
if let Some(song) = &self.song {
if let Ok(slides) = song.to_slides() {
let slides: Vec<Element<Message>> = slides
.into_iter()
.enumerate()
.map(|(index, slide)| {
container(
slide_view(
slide,
if index == 0 {
&self.video
} else {
&None
},
false,
false,
)
.map(|_| Message::None),
if let Some(slides) = &self.song_slides {
let slides: Vec<Element<Message>> = slides
.into_iter()
.enumerate()
.map(|(index, slide)| {
container(
slide_view(
&slide,
if index == 0 {
&self.video
} else {
&None
},
false,
false,
)
.height(250)
.center_x(Length::Fill)
.padding([0, 20])
.clip(true)
.into()
})
.collect();
scrollable(
column::with_children(slides)
.spacing(theme::active().cosmic().space_l()),
)
.height(Length::Fill)
.width(Length::Fill)
.into()
} else {
horizontal_space().into()
}
.map(|_| Message::None),
)
.height(250)
.center_x(Length::Fill)
.padding([0, 20])
.clip(true)
.into()
})
.collect();
scrollable(
cosmic::widget::column::with_children(slides)
.spacing(theme::active().cosmic().space_l()),
)
.height(Length::Fill)
.width(Length::Fill)
.into()
} else {
horizontal_space().into()
}
@ -345,37 +365,26 @@ order",
.on_input(Message::ChangeVerseOrder);
let lyric_title = text("Lyrics");
let lyric_input = column::with_children(vec![
lyric_title.into(),
let lyric_input = column![
lyric_title,
text_editor(&self.lyrics)
.on_action(Message::ChangeLyrics)
.height(Length::Fill)
.into(),
])
]
.spacing(5);
column::with_children(vec![
title_input.into(),
author_input.into(),
verse_input.into(),
lyric_input.into(),
])
.spacing(25)
.width(Length::FillPortion(2))
.into()
column![title_input, author_input, verse_input, lyric_input,]
.spacing(25)
.width(Length::FillPortion(2))
.into()
}
fn toolbar(&self) -> Element<Message> {
let selected_font = &self.font;
let selected_font_size = {
let font_size_position = self
.font_sizes
.options()
.iter()
.position(|s| *s == self.font_size.to_string());
self.font_sizes
.options()
.get(font_size_position.unwrap_or_default())
let selected_font_size = if self.font_size > 0 {
Some(&self.font_size.to_string())
} else {
None
};
let font_selector = combo_box(
&self.fonts_combo,
@ -383,7 +392,7 @@ order",
Some(selected_font),
Message::ChangeFont,
)
.width(200);
.width(300);
let font_size = combo_box(
&self.font_sizes,
"Font Size",
@ -405,11 +414,14 @@ order",
.padding(10);
row![
text::body("Font:"),
font_selector,
text::body("Font Size:"),
font_size,
horizontal_space(),
background_selector
]
.align_y(Vertical::Center)
.spacing(10)
.into()
}
@ -420,6 +432,17 @@ order",
fn update_song(&mut self, song: Song) -> Action {
self.song = Some(song.clone());
self.song_slides = song.to_slides().ok().map(|v| {
v.into_par_iter()
.map(|mut s| {
text_svg::text_svg_generator(
&mut s,
Arc::clone(&self.font_db),
);
s
})
.collect::<Vec<Slide>>()
});
Action::UpdateSong(song)
}
@ -448,21 +471,38 @@ impl Default for SongEditor {
async fn pick_background() -> Result<PathBuf, SongError> {
let dialog = Dialog::new().title("Choose a background...");
let bg_filter = FileFilter::new("Videos and Images")
.extension("png")
.extension("jpg")
.extension("mp4")
.extension("webm")
.extension("mkv")
.extension("jpeg");
dialog
.filter(bg_filter)
.directory(dirs::home_dir().expect("oops"))
.open_file()
.await
.map_err(|_| SongError::DialogClosed)
.map_err(|e| {
error!(?e);
SongError::BackgroundDialogClosed
})
.map(|file| file.url().to_file_path().unwrap())
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(
// "Images and Videos",
// &["png", "jpeg", "mp4", "webm", "mkv", "jpg", "mpeg"],
// )
// .set_directory(dirs::home_dir().unwrap())
// .pick_file()
// .await
// .ok_or(SongError::DialogClosed)
// .ok_or(SongError::BackgroundDialogClosed)
// .map(|file| file.path().to_owned())
}
#[derive(Debug, Clone)]
pub enum SongError {
DialogClosed,
BackgroundDialogClosed,
IOError(io::ErrorKind),
}

View file

@ -249,18 +249,20 @@ impl TextSvg {
}
pub fn build(mut self) -> Self {
debug!("starting...");
// debug!("starting...");
let mut path = dirs::data_local_dir().unwrap();
path.push(PathBuf::from("lumina"));
path.push(PathBuf::from("temp"));
let shadow = if let Some(shadow) = &self.shadow {
format!("<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
format!(
"<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
shadow.offset_x,
shadow.offset_y,
shadow.spread,
shadow.color)
shadow.color
)
} else {
String::new()
};
@ -299,20 +301,24 @@ impl TextSvg {
.collect();
let text: String = text_pieces.join("\n");
let final_svg = format!("<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\"><defs>{}</defs><text x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-weight=\"bold\" font-family=\"{}\" font-size=\"{}\" fill=\"{}\" {} style=\"filter:url(#shadow);\">{}</text></svg>",
size.width,
size.height,
shadow,
self.font.name,
font_size,
self.fill, stroke, text);
let final_svg = format!(
"<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\"><defs>{}</defs><text x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-weight=\"bold\" font-family=\"{}\" font-size=\"{}\" fill=\"{}\" {} style=\"filter:url(#shadow);\">{}</text></svg>",
size.width,
size.height,
shadow,
self.font.name,
font_size,
self.fill,
stroke,
text
);
let hashed_title = rapidhash_v3(final_svg.as_bytes());
path.push(PathBuf::from(hashed_title.to_string()));
path.set_extension("png");
if path.exists() {
debug!("cached");
// debug!("cached");
let handle = Handle::from_path(path);
self.handle = Some(handle);
return self;
@ -354,14 +360,6 @@ impl TextSvg {
.height(Length::Fill)
.into()
}
fn text_spans(&self) -> Vec<String> {
self.text
.lines()
.enumerate()
.map(|(i, t)| format!("<tspan x=\"50%\">{t}</tspan>"))
.collect()
}
}
pub fn shadow(
@ -408,27 +406,3 @@ pub fn text_svg_generator(
slide.text_svg = Some(text_svg);
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use super::TextSvg;
#[test]
fn test_text_spans() {
let mut text = TextSvg::new("yes");
text.text = "This is
multiline
text."
.into();
assert_eq!(
vec![
String::from("<tspan>This is</tspan>"),
String::from("<tspan>multiline</tspan>"),
String::from("<tspan>text.</tspan>"),
],
text.text_spans()
)
}
}

View file

@ -0,0 +1,917 @@
//! Distribute draggable content vertically.
// This widget is a modification of the original `Column` widget from [`iced`]
//
// [`iced`]: https://github.com/iced-rs/iced
//
// Copyright 2019 Héctor Ramón, Iced contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
use cosmic::iced::advanced::layout::{self, Layout};
use cosmic::iced::advanced::widget::{tree, Operation, Tree, Widget};
use cosmic::iced::advanced::{overlay, renderer, Clipboard, Shell};
use cosmic::iced::alignment::{self, Alignment};
use cosmic::iced::event::{self, Event};
use cosmic::iced::{self, mouse, Transformation};
use cosmic::iced::{
Background, Border, Color, Element, Length, Padding, Pixels,
Point, Rectangle, Size, Vector,
};
use cosmic::Theme;
use super::{Action, DragEvent, DropPosition};
pub fn column<'a, Message, Theme, Renderer>(
children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
Column::with_children(children)
}
const DRAG_DEADBAND_DISTANCE: f32 = 5.0;
/// A container that distributes its contents vertically.
///
/// # Example
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::{button, column};
///
/// #[derive(Debug, Clone)]
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// column![
/// "I am on top!",
/// button("I am in the center!"),
/// "I am below.",
/// ].into()
/// }
/// ```
#[allow(missing_debug_implementations)]
pub struct Column<
'a,
Message,
Theme = cosmic::Theme,
Renderer = iced::Renderer,
> where
Theme: Catalog,
{
spacing: f32,
padding: Padding,
width: Length,
height: Length,
max_width: f32,
align: Alignment,
clip: bool,
deadband_zone: f32,
children: Vec<Element<'a, Message, Theme, Renderer>>,
on_drag: Option<Box<dyn Fn(DragEvent) -> Message + 'a>>,
class: Theme::Class<'a>,
}
impl<'a, Message, Theme, Renderer>
Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
/// Creates an empty [`Column`].
pub fn new() -> Self {
Self::from_vec(Vec::new())
}
/// Creates a [`Column`] with the given capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self::from_vec(Vec::with_capacity(capacity))
}
/// Creates a [`Column`] with the given elements.
pub fn with_children(
children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self {
let iterator = children.into_iter();
Self::with_capacity(iterator.size_hint().0).extend(iterator)
}
/// Creates a [`Column`] from an already allocated [`Vec`].
///
/// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means
/// it won't automatically adapt to the sizing strategy of its contents.
///
/// If any of the children have a [`Length::Fill`] strategy, you will need to
/// call [`Column::width`] or [`Column::height`] accordingly.
pub fn from_vec(
children: Vec<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
spacing: 0.0,
padding: Padding::ZERO,
width: Length::Shrink,
height: Length::Shrink,
max_width: f32::INFINITY,
align: Alignment::Start,
clip: false,
deadband_zone: DRAG_DEADBAND_DISTANCE,
children,
class: Theme::default(),
on_drag: None,
}
}
/// Sets the vertical spacing _between_ elements.
///
/// Custom margins per element do not exist in iced. You should use this
/// method instead! While less flexible, it helps you keep spacing between
/// elements consistent.
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
self.spacing = amount.into().0;
self
}
/// Sets the [`Padding`] of the [`Column`].
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.padding = padding.into();
self
}
/// Sets the width of the [`Column`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Column`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the maximum width of the [`Column`].
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
self.max_width = max_width.into().0;
self
}
/// Sets the horizontal alignment of the contents of the [`Column`] .
pub fn align_x(
mut self,
align: impl Into<alignment::Horizontal>,
) -> Self {
self.align = Alignment::from(align.into());
self
}
/// Sets whether the contents of the [`Column`] should be clipped on
/// overflow.
pub fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Sets the drag deadband zone of the [`Column`].
pub fn deadband_zone(mut self, deadband_zone: f32) -> Self {
self.deadband_zone = deadband_zone;
self
}
/// Adds an element to the [`Column`].
pub fn push(
mut self,
child: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let child = child.into();
let child_size = child.as_widget().size_hint();
self.width = self.width.enclose(child_size.width);
self.height = self.height.enclose(child_size.height);
self.children.push(child);
self
}
/// Adds an element to the [`Column`], if `Some`.
pub fn push_maybe(
self,
child: Option<
impl Into<Element<'a, Message, Theme, Renderer>>,
>,
) -> Self {
if let Some(child) = child {
self.push(child)
} else {
self
}
}
/// Sets the style of the [`Column`].
#[must_use]
pub fn style(
mut self,
style: impl Fn(&Theme) -> Style + 'a,
) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
/// Sets the style class of the [`Column`].
#[must_use]
pub fn class(
mut self,
class: impl Into<Theme::Class<'a>>,
) -> Self {
self.class = class.into();
self
}
/// Extends the [`Column`] with the given children.
pub fn extend(
self,
children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self {
children.into_iter().fold(self, Self::push)
}
/// The message produced by the [`Column`] when a child is dragged.
pub fn on_drag(
mut self,
on_reorder: impl Fn(DragEvent) -> Message + 'a,
) -> Self {
self.on_drag = Some(Box::new(on_reorder));
self
}
// Computes the index and position where a dragged item should be dropped.
fn compute_target_index(
&self,
cursor_position: Point,
layout: Layout<'_>,
dragged_index: usize,
) -> (usize, DropPosition) {
let cursor_y = cursor_position.y;
for (i, child_layout) in layout.children().enumerate() {
let bounds = child_layout.bounds();
let y = bounds.y;
let height = bounds.height;
if cursor_y >= y && cursor_y <= y + height {
if i == dragged_index {
// Cursor is over the dragged item itself
return (i, DropPosition::Swap);
}
let thickness = height / 4.0;
let top_threshold = y + thickness;
let bottom_threshold = y + height - thickness;
if cursor_y < top_threshold {
// Near the top edge - insert above
return (i, DropPosition::Before);
} else if cursor_y > bottom_threshold {
// Near the bottom edge - insert below
return (i + 1, DropPosition::After);
} else {
// Middle area - swap
return (i, DropPosition::Swap);
}
} else if cursor_y < y {
// Cursor is above this child
return (i, DropPosition::Before);
}
}
// Cursor is below all children
(self.children.len(), DropPosition::After)
}
}
impl<'a, Message, Renderer> Default
for Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
fn default() -> Self {
Self::new()
}
}
impl<'a, Message, Theme, Renderer: renderer::Renderer>
FromIterator<Element<'a, Message, Theme, Renderer>>
for Column<'a, Message, Theme, Renderer>
where
Theme: Catalog,
{
fn from_iter<
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
>(
iter: T,
) -> Self {
Self::with_children(iter)
}
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Action>()
}
fn state(&self) -> tree::State {
tree::State::new(Action::Idle)
}
fn children(&self) -> Vec<Tree> {
self.children.iter().map(Tree::new).collect()
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(self.children.as_mut_slice());
}
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 {
let limits = limits.max_width(self.max_width);
layout::flex::resolve(
layout::flex::Axis::Vertical,
renderer,
&limits,
self.width,
self.height,
self.padding,
self.spacing,
self.align,
&self.children,
&mut tree.children,
)
}
fn operate(
&self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(
None,
layout.bounds(),
&mut |operation| {
self.children
.iter()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), c_layout)| {
child.as_widget().operate(
state,
c_layout.with_virtual_offset(
layout.virtual_offset(),
),
renderer,
operation,
);
});
},
);
}
fn on_event(
&mut self,
tree: &mut Tree,
event: Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) -> event::Status {
let mut event_status = event::Status::Ignored;
let action = tree.state.downcast_mut::<Action>();
match event {
Event::Mouse(mouse::Event::ButtonPressed(
mouse::Button::Left,
)) => {
if let Some(cursor_position) =
cursor.position_over(layout.bounds())
{
for (index, child_layout) in
layout.children().enumerate()
{
if child_layout
.bounds()
.contains(cursor_position)
{
*action = Action::Picking {
index,
origin: cursor_position,
};
event_status = event::Status::Captured;
break;
}
}
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
match *action {
Action::Picking { index, origin } => {
if let Some(cursor_position) =
cursor.position()
{
if cursor_position.distance(origin)
> self.deadband_zone
{
// Start dragging
*action = Action::Dragging {
index,
origin,
last_cursor: cursor_position,
};
if let Some(on_reorder) =
&self.on_drag
{
shell.publish(on_reorder(
DragEvent::Picked { index },
));
}
event_status =
event::Status::Captured;
}
}
}
Action::Dragging { origin, index, .. } => {
if let Some(cursor_position) =
cursor.position()
{
*action = Action::Dragging {
last_cursor: cursor_position,
origin,
index,
};
event_status = event::Status::Captured;
}
}
_ => {}
}
}
Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
)) => {
match *action {
Action::Dragging { index, .. } => {
if let Some(cursor_position) =
cursor.position()
{
let bounds = layout.bounds();
if bounds.contains(cursor_position) {
let (target_index, drop_position) =
self.compute_target_index(
cursor_position,
layout,
index,
);
if let Some(on_reorder) =
&self.on_drag
{
shell.publish(on_reorder(
DragEvent::Dropped {
index,
target_index,
drop_position,
},
));
event_status =
event::Status::Captured;
}
} else if let Some(on_reorder) =
&self.on_drag
{
shell.publish(on_reorder(
DragEvent::Canceled { index },
));
event_status =
event::Status::Captured;
}
}
*action = Action::Idle;
}
Action::Picking { .. } => {
// Did not move enough to start dragging
*action = Action::Idle;
}
_ => {}
}
}
_ => {}
}
let child_status = self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.map(|((child, state), c_layout)| {
child.as_widget_mut().on_event(
state,
event.clone(),
c_layout
.with_virtual_offset(layout.virtual_offset()),
cursor,
renderer,
clipboard,
shell,
viewport,
)
})
.fold(event::Status::Ignored, event::Status::merge);
event::Status::merge(event_status, child_status)
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let action = tree.state.downcast_ref::<Action>();
if let Action::Dragging { .. } = *action {
return mouse::Interaction::Grabbing;
}
self.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.map(|((child, state), c_layout)| {
child.as_widget().mouse_interaction(
state,
c_layout
.with_virtual_offset(layout.virtual_offset()),
cursor,
viewport,
renderer,
)
})
.max()
.unwrap_or_default()
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
default_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let action = tree.state.downcast_ref::<Action>();
let style = theme.style(&self.class);
match action {
Action::Dragging {
index,
last_cursor,
origin,
..
} => {
let child_count = self.children.len();
// Determine the target index based on cursor position
let target_index = if cursor.position().is_some() {
let (target_index, _) = self
.compute_target_index(
*last_cursor,
layout,
*index,
);
target_index.min(child_count - 1)
} else {
*index
};
// Store the width of the dragged item
let drag_bounds =
layout.children().nth(*index).unwrap().bounds();
let drag_height = drag_bounds.height + self.spacing;
// Draw all children except the one being dragged
let mut translations = 0.0;
for i in 0..child_count {
let child = &self.children[i];
let state = &tree.children[i];
let child_layout =
layout.children().nth(i).unwrap();
// Draw the dragged item separately
// TODO: Draw a shadow below the picked item to enhance the
// floating effect
if i == *index {
let scaling =
Transformation::scale(style.scale);
let translation =
*last_cursor - *origin * scaling;
renderer.with_translation(
translation,
|renderer| {
renderer.with_transformation(
scaling,
|renderer| {
renderer.with_layer(
child_layout.bounds(),
|renderer| {
child
.as_widget()
.draw(
state,
renderer,
theme,
default_style,
child_layout,
cursor,
viewport,
);
},
);
},
);
},
);
} else {
let offset: i32 =
match target_index.cmp(index) {
std::cmp::Ordering::Less
if i >= target_index
&& i < *index =>
{
1
}
std::cmp::Ordering::Greater
if i > *index
&& i <= target_index =>
{
-1
}
_ => 0,
};
let translation = Vector::new(
0.0,
offset as f32 * drag_height,
);
renderer.with_translation(
translation,
|renderer| {
child.as_widget().draw(
state,
renderer,
theme,
default_style,
child_layout,
cursor,
viewport,
);
// Draw an overlay if this item is being moved
// TODO: instead of drawing an overlay, it would be nicer to
// draw the item with a reduced opacity, but that's not possible today
if offset != 0 {
renderer.fill_quad(
renderer::Quad {
bounds: child_layout
.bounds(),
..renderer::Quad::default(
)
},
style.moved_item_overlay,
);
// Keep track of the total translation so we can
// draw the "ghost" of the dragged item later
translations -= (child_layout
.bounds()
.height
+ self.spacing)
* offset.signum() as f32;
}
},
);
}
}
// Draw a ghost of the dragged item in its would-be position
let ghost_translation =
Vector::new(0.0, translations);
renderer.with_translation(
ghost_translation,
|renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: drag_bounds,
border: style.ghost_border,
..renderer::Quad::default()
},
style.ghost_background,
);
},
);
}
_ => {
// Draw all children normally when not dragging
if let Some(clipped_viewport) =
layout.bounds().intersection(viewport)
{
let viewport = if self.clip {
&clipped_viewport
} else {
viewport
};
for ((child, state), c_layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.filter(|(_, layout)| {
layout.bounds().intersects(viewport)
})
{
child.as_widget().draw(
state,
renderer,
theme,
default_style,
c_layout.with_virtual_offset(
layout.virtual_offset(),
),
cursor,
viewport,
);
}
}
}
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
overlay::from_children(
&mut self.children,
tree,
layout,
renderer,
translation,
)
}
fn drag_destinations(
&self,
state: &Tree,
layout: Layout<'_>,
renderer: &Renderer,
dnd_rectangles: &mut cosmic::iced_core::clipboard::DndDestinationRectangles,
) {
for ((e, c_layout), state) in self
.children
.iter()
.zip(layout.children())
.zip(state.children.iter())
{
e.as_widget().drag_destinations(
state,
c_layout.with_virtual_offset(layout.virtual_offset()),
renderer,
dnd_rectangles,
);
}
}
}
impl<'a, Message, Theme, Renderer>
From<Column<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: Catalog + 'a,
Renderer: renderer::Renderer + 'a,
{
fn from(column: Column<'a, Message, Theme, Renderer>) -> Self {
Self::new(column)
}
}
/// The theme catalog of a [`Column`].
pub trait Catalog {
/// The item class of the [`Catalog`].
type Class<'a>;
/// The default class produced by the [`Catalog`].
fn default<'a>() -> Self::Class<'a>;
/// The [`Style`] of a class with the given status.
fn style(&self, class: &Self::Class<'_>) -> Style;
}
/// The appearance of a [`Column`].
#[derive(Debug, Clone, Copy)]
pub struct Style {
/// The scaling to apply to a picked element while it's being dragged.
pub scale: f32,
/// The color of the overlay on items that are moved around
pub moved_item_overlay: Color,
/// The outline border of the dragged item's ghost
pub ghost_border: Border,
/// The background of the dragged item's ghost
pub ghost_background: Background,
}
/// A styling function for a [`Column`].
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
impl Catalog for cosmic::Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(default)
}
fn style(&self, class: &Self::Class<'_>) -> Style {
class(self)
}
}
pub fn default(theme: &Theme) -> Style {
Style {
scale: 1.05,
moved_item_overlay: Color::from(theme.cosmic().primary.base)
.scale_alpha(0.2)
.into(),
ghost_border: Border {
width: 1.0,
color: theme.cosmic().secondary.base.into(),
radius: 0.0.into(),
},
ghost_background: Color::from(theme.cosmic().secondary.base)
.scale_alpha(0.2)
.into(),
}
}

View file

@ -0,0 +1,42 @@
use cosmic::iced::Point;
pub use self::column::column;
pub use self::row::row;
pub mod column;
pub mod row;
#[derive(Debug, Clone)]
pub enum Action {
Idle,
Picking {
index: usize,
origin: Point,
},
Dragging {
index: usize,
origin: Point,
last_cursor: Point,
},
}
#[derive(Debug, Clone, Copy)]
pub enum DropPosition {
Before,
Swap,
After,
}
#[derive(Debug, Clone)]
pub enum DragEvent {
Picked {
index: usize,
},
Dropped {
index: usize,
target_index: usize,
drop_position: DropPosition,
},
Canceled {
index: usize,
},
}

File diff suppressed because it is too large Load diff

View file

@ -1 +1,2 @@
// pub mod slide_text;
pub mod draggable;

View file

@ -1,10 +1,14 @@
#+TITLE: The Task list for Lumina
* TODO [#A] Develop DnD for library items
This is limited by the fact that I need to develop this in cosmic. I am honestly thinking that I'll need to build my own drag and drop system or at least work with system76 to fix their dnd system on other systems.
This needs lots more attention
* DONE [#A] Add removal and reordering of service_items
Reordering is finished
* TODO Add OBS integration
This will be based on each slide having the ability to activate an OBS scene when it is active.
* TODO Move text_generation function to be asynchronous so that UI doesn't lock up during song editing.
* TODO Build a video editor
* TODO Build an image editor
* TODO Build a presentation editor
* TODO [#A] Need to fix tests now that the basic app is working
@ -23,13 +27,15 @@ This also means in our custom widget with our custom fork, we can animate each i
** Actually.....
I tried out a way of generating the svg and rasterizing it ahead of time and then storing it in the file system to be cached. This works out very well. The text is one whole image for a slides text that gets layered on top of the background, but it works out well for now.
* TODO Check into =mupdf-rs= for loading PDF's.
The problem with this approach is that every change to a song's text or font metrics means we need to rebuild all the text items for that song. I need to think of a way for the text generation to be done asynchronously so that the ui isn't locked up.
* TODO [#C] Make the presenter more modular so things are easier to change.
* TODO Build library to see all available songs, images, videos, presentations, and slides
** DONE Develop ui for libraries
I've got the library basic layer done, I need to develop a way to open the libraries accordion button and then show the list of items in the library
** TODO Need to do search and creation systems yet
* TODO [#B] Build editors for each possible item
** TODO Develop ui for editors
@ -39,8 +45,10 @@ I've got the library basic layer done, I need to develop a way to open the libra
* TODO [#B] Functions for text alignments
This will need to be matched on for the =TextAlignment= from the user
* TODO [#C] Figure out why the Video element seems to have problems when moving the mouse around
* TODO [#B] Find a way to load and discover every font on the system for slide building
* DONE [#B] Find a way to load and discover every font on the system for slide building
This may not be necessary since it is possible to create a font using =Box::leak()=.
#+begin_src rust
let font = self.current_slide.font().into_boxed_str();
@ -60,8 +68,16 @@ This code creates a font by leaking the Box to a ='static &str=. I just am not s
Krimzin on Discord told me that maybe the =update= method is a better place for this Box to be created or updated and then maybe I could generate the view from there.
* DONE [#A] Develop DnD for library items
This is limited by the fact that I need to develop this in cosmic. I am honestly thinking that I'll need to build my own drag and drop system or at least work with system76 to fix their dnd system on other systems.
This needs lots more attention
* DONE Use Rich Text instead of normal text for slides
This will make it so that we can add styling to the text like borders and backgrounds or highlights. Maybe in the future it'll add shadows too.
* DONE Check into =mupdf-rs= for loading PDF's.
* DONE Build Menu
* DONE Find a way for text to pass through a service item to a slide i.e. content piece
This proved easier by just creating the =Slide= first and inserting it into the =ServiceItem=.
* DONE [#A] Change return type of all components to an Action enum instead of the Task<Message> type [0%] [0/0]