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

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;