Compare commits

..

4 commits

Author SHA1 Message Date
80a9b48ae9 my fork of iced_video_player
Some checks failed
/ test (push) Has been cancelled
2025-08-27 09:02:46 -05:00
1861f357a8 well crap
Some checks are pending
/ test (push) Waiting to run
2025-08-26 15:25:04 -05:00
4ae6a9a9a7 ohh
Some checks are pending
/ test (push) Waiting to run
2025-08-26 09:47:35 -05:00
c886d18134 beginning the switch... lots to go yet
Some checks are pending
/ test (push) Waiting to run
2025-08-26 09:44:06 -05:00
70 changed files with 5319 additions and 17584 deletions

View file

@ -1 +0,0 @@
experimental = [ "benchmarks" ]

2
.envrc
View file

@ -1,4 +1,4 @@
DATABASE_URL="sqlite://./test.db" DATABASE_URL="sqlite:///home/chris/.local/share/lumina/library-db.sqlite3"
use flake . use flake .
# eval $(guix shell -D --search-paths) # eval $(guix shell -D --search-paths)

View file

@ -0,0 +1,18 @@
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
- run: |
apt update
apt install sudo
apt install just
- uses: https://github.com/cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- run: nix develop --command just test

View file

@ -1,9 +0,0 @@
on: [push]
jobs:
clippy:
runs-on: nixos-latest
steps:
- run: nix-env --install nodejs
- name: checkout
uses: actions/checkout@v4
- run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop --command cargo clippy -- -D clippy::pedantic -D clippy::perf -D clippy::nursery -D clippy::unwrap_used

View file

@ -1,9 +0,0 @@
on: [push]
jobs:
test:
runs-on: nixos-latest
steps:
- run: nix-env --install nodejs
- name: checkout
uses: actions/checkout@v4
- run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop --command just ci-test

11
.gitignore vendored
View file

@ -3,14 +3,3 @@
.sqlx .sqlx
.env .env
data.db data.db
/flamegraph.svg
/.zed/
/perf.data
/perf.data.old
.aider*
test.db-shm
test.db-wal
test.lum
test.pres
profile.json.gz

5542
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,13 @@ description = "A cli presentation system"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
clap = { version = "4.5.20", features = ["derive"] } clap = { version = "4.5.20", features = ["debug", "derive"] }
# libcosmic = { git = "https://github.com/pop-os/libcosmic", default-features = false, features = ["debug", "winit", "desktop", "winit_wgpu", "winit_tokio", "tokio", "rfd", "dbus-config", "a11y", "wgpu", "multi-window"] }
lexpr = "0.2.7"
miette = { version = "7.2.0", features = ["fancy"] } miette = { version = "7.2.0", features = ["fancy"] }
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
serde-lexpr = "0.1.3"
tracing = "0.1.40" tracing = "0.1.40"
tracing-log = "0.2.0" tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "chrono", "time", "local-time", "env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "chrono", "time", "local-time", "env-filter"] }
@ -18,48 +21,35 @@ strum = "0.26.3"
strum_macros = "0.26.4" strum_macros = "0.26.4"
ron = "0.8.1" ron = "0.8.1"
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio"] } sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio"] }
dirs = "6.0.0" dirs = "5.0.1"
tokio = "1.41.1" tokio = "1.41.1"
crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" } crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" }
rodio = { version = "0.21.1", features = ["symphonia-all", "tracing"] } rodio = { version = "0.20.1", features = ["symphonia-all", "tracing"] }
gstreamer = "0.23" gstreamer = "0.23"
gstreamer-app = "0.23" gstreamer-app = "0.23"
# gstreamer-video = "0.23" # gstreamer-video = "0.23"
# gstreamer-allocators = "0.23" # gstreamer-allocators = "0.23"
# cosmic-time = { git = "https://githubg.com/pop-os/cosmic-time" } # cosmic-time = { git = "https://githubg.com/pop-os/cosmic-time" }
url = "2" url = "2"
# colors-transform = "0.2.11" colors-transform = "0.2.11"
rayon = "1.11.0" rayon = "1.11.0"
resvg = "0.45.1" # 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"] } # femtovg = { version = "0.16.0", features = ["wgpu"] }
# wgpu = "26.0.1" # wgpu = "26.0.1"
# mupdf = "0.5.0" # 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 }
tar = "0.4.44" derive_setters = "0.1.8"
zstd = "0.13.3" freedesktop-icons = "0.4.0"
fastrand = "2.3.0"
obws = "0.14.0"
derive_more = { version = "2.1.1", features = ["debug"] }
reqwest = "0.13.1"
scraper = "0.25.0"
itertools = "0.14.0"
serde_json = "1.0.149"
# rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] } [dependencies.iced]
git = "https://github.com/iced-rs/iced"
[dependencies.libcosmic] branch = "master"
git = "https://github.com/pop-os/libcosmic" features = ["wgpu", "image", "advanced", "svg", "canvas", "hot", "debug", "lazy", "tokio"]
default-features = false
features = ["debug", "winit", "desktop", "winit_wgpu", "winit_tokio", "tokio", "wayland", "rfd", "dbus-config", "a11y", "wgpu", "multi-window", "process"]
[dependencies.iced_video_player] [dependencies.iced_video_player]
git = "https://github.com/jackpot51/iced_video_player.git" git = "https://git.tfcconnection.org/chris/iced_video_player"
branch = "cosmic" branch = "master"
features = ["wgpu"] # branch = "cosmic"
# [profile.dev] # [profile.dev]
# opt-level = 3 # opt-level = 3

137
TODO.org
View file

@ -1,137 +0,0 @@
#+TITLE: The Task list for Lumina
* TODO [#A] Add Action system
This will be based on each slide having the ability to activate an action (i.e. OBS scene switch, OBS start or stop) when it is active.
This is working but the right click context menu is all the way on the edge of the ui so you can't control all the slides. It also needs a lot of help in making the system more robust and potentially lest reliant on the Presenter struct itself.
* TODO [#B] Font in the song editor doesn't always use the original version
There seems to be some issue with fontdb not able to decipher all the versions of some fonts that are OTF and then end up loading the wrong ones in some issues.
* TODO [#B] Find a way to use auth-token in tests for ci
If I can find out how to use my secrets in ci that would free up more tests, but I could also just turn that test off for the CI so that it won't constantly fail for now
* TODO [#C] Rename menu actions to menu commands and build a reverse hashmap for settings to map commands to key-binding such that we can allow for remapping them on the fly.
* TODO [#B] Saving and loading font awareness
Someday we should make the saving and loading to be aware of the fonts on the system and find a way to embed them into the save file.
* TODO [#B] Develop ui for settings
* TODO [#B] Develop library system for slides that are more than images or video i.e. content
* TODO [#C] Use orgize as a file parser and allow for orgdown files to represent a presentation.
Orgize has some very nice features that will let me determine what things are in an orgdown file and thus take said file and turn it into a presentation.
After looking more and more at how the orgize docs describe things and the testing platform found at: https://poiscript.github.io/orgize/ I believe this will work. The main things are that I can possibly decide how to interpret certain pieces of orgdown to mean certain things in lumina. Essentially a properties drawer or tag can indicate backgrounds and other info for the slides or songs and then the notes blocks can indicate text that shouldn't be printed into the slide, thus allowing a single orgdown document to illustrate both an entire presentation, but also the notes and plan for the presenter.
I could potentially do the same with markdown, but since this is for me first, I'll use orgdown because I enjoy the syntax a lot more.
* TODO [#C] Allow for a way to split the presentation up with a right click menu for the presentation preview row.
* TODO [#C] Text could be built by using SVG instead of the text element. Maybe I could construct my own text element even
This does almost work. There is a clear amount of lag or rather hang up since switching to the =text_svg= element. I think I may only keep it till I can figure out how to do strokes and shadows in iced's normal text element.
Actually, what if we just made the svg at load/creation time and stored it in the file system for later, then load the entire songs svg's into memory during the presentation to speed things up? Would that be faster than creating them at on the fly? Is it the creation of them that is slow or the rendering?
** SVG performs badly
Since SVG's apparently run poorly in iced, instead I'll need to see about either creating a new text element, or teaching Iced to render strokes and shadows on text.
** Fork Cryoglyph
This fork will render text 3 times. Once for the text, once for the stroke, once for the shadow. This will only be used in the slides and therefore should not be much of a performance hit since we will only be render 3 copies of the given text. This should not be bad performance since it's not a large amount of text.
This also means in our custom widget with our custom fork, we can animate each individually perhaps.
** 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.
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.
I bet this is tricking up the loading mechanism. Loading only grabs all the backgrounds and audio pieces, not the text_svg pieces. So maybe it should so that the generator can run again and grab the same pieces from the filesystem rather than recreate them. This gets extra tricky because we may have fonts that are missing when loading a file. In such a case the loading mechanism ought to suggest to the user to grab those fonts and then perhaps load the cached file while being extra clear that any changes will mess up the text since they no longer possess the font that is in the loaded file. Maybe what we can do is during save, save a copy of all the fonts as well and then during load check to see if the computer has them, if they don't offer to install them on the spot such that they can use the font as is. I wonder if we are allowed to pass fonts around that way.
** Made this slightly faster
Since strings are allocated on the heap, I've changed how to construct the svg string a bit, but honestly, it doesn't matter too much because most of the performance cost seems to be in rendering the string using resvg. So, this can still be something that get's fixed later, and I believe that fix will come in the form of a multi-channel signed distance field wgpu rendered text eventually. We can work on this much later though.
* TODO [#C] Make the presenter more modular so things are easier to change. This is vague...
* TODO [#C] Figure out why the Video element seems to have problems when moving the mouse around
* DONE [#A] Create a view of all slides in a PDF presenation
* 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 [#A] File saving and loading
Need to make sure we can save a file with all files archived in it and load it back up.
This is giving me a lot of thoughts...
1. That saving and loading needs to know about fonts as well.
2. That TextSvgs should likely be saved as well since the other machines may not always have the same fonts.
3. That means that TextSvg should have a path option that could hold the cached svg that has already been rendered and that this gets changed to the loaded files directory rather than using the default cache directory.
* DONE [#A] Add removal and reordering of service_items
Reordering is finished
* DONE [#A] Change return type of all components to an Action enum instead of the Task<Message> type [0%] [0/0]
** DONE Library
** DONE SongEditor
** DONE Presenter
* DONE [#A] Need to fix tests now that the basic app is working
Lots of them have been tweaked to be completing now, but there is more work to do and several need to likely be a lot more robust.
Still failing 4 tests, all to do with the db or lisp. I might throw out the lisp code at some point tho. I keep thinking that a better alternative would be to have a markdown serialization system such that you can write slides in markdown somehow and they would be able to be loaded.
* DONE [#A] Make sure updating verse updates the lyrics too
[[file:~/dev/lumina-iced/src/core/songs.rs::old_verse = verse;]]
This is necessary so that the entire song gets changed and we can propogate those changes then back to the db.
There is likely some work that still needs to be done here, I believe I am somehow deleting some of my verses.
* DONE [#A] Need to fixup how songs are edited in the editors
Currently the song is cloned many times to pass around and then finally get updated in DB. Instead, we need to edit the song directly in the editor and after it's been changed appropriatel, run the update_song method to get the current song and create slides from it and then update it in the DB.
* DONE [#B] Functions for text alignments
This will need to be matched on for the =TextAlignment= from the user
* DONE Move text_generation function to be asynchronous so that UI doesn't lock up during song editing.
* DONE Build a presentation editor
* DONE 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
** DONE Need to do search and creation systems yet
* DONE [#B] Build editors for each possible item
** DONE Develop ui for editors
* 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();
let family = Family::Name(Box::leak(font));
let weight = Weight::Normal;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
family,
weight,
stretch,
style,
};
#+end_src
This code creates a font by leaking the Box to a ='static &str=. I just am not sure if the &str stays around in memory after the view function. If it does, then it's not on the stack anymore and should be fine, but if it isn't cleaned up then we will have a memory leak.
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 Build an image editor
* 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 Build a video editor
* 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=.

67
flake.lock generated
View file

@ -6,11 +6,11 @@
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1770794449, "lastModified": 1755585599,
"narHash": "sha256-1nFkhcZx9+Sdw5OXwJqp5TxvGncqRqLeK781v0XV3WI=", "narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "b19d93fdf9761e6101f8cb5765d638bacebd9a1b", "rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -65,11 +65,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1769799857, "lastModified": 1752689277,
"narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=", "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=",
"owner": "nix-community", "owner": "nix-community",
"repo": "naersk", "repo": "naersk",
"rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339", "rev": "0e72363d0938b0208d6c646d10649164c43f4d64",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -80,11 +80,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770562336, "lastModified": 1755186698,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -112,11 +112,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1770562336, "lastModified": 1755615617,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "rev": "20075955deac2583bb12f07151c2df830ef346b4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -126,39 +126,22 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_4": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"fenix": "fenix", "fenix": "fenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"naersk": "naersk", "naersk": "naersk",
"nixpkgs": "nixpkgs_3", "nixpkgs": "nixpkgs_3"
"rust-overlay": "rust-overlay"
} }
}, },
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1770702974, "lastModified": 1755504847,
"narHash": "sha256-CbvWu72rpGHK5QynoXwuOnVzxX7njF2LYgk8wRSiAQ0=", "narHash": "sha256-VX0B9hwhJypCGqncVVLC+SmeMVd/GAYbJZ0MiiUn2Pk=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "07a594815f7c1d6e7e39f21ddeeedb75b21795f4", "rev": "a905e3b21b144d77e1b304e49f3264f6f8d4db75",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -185,24 +168,6 @@
"type": "github" "type": "github"
} }
}, },
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1770779462,
"narHash": "sha256-ykcXTKtV+dOaKlOidAj6dpewBHjni9/oy/6VKcqfzfY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "8a53b3ade61914cdb10387db991b90a3a6f3c441",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": { "systems": {
"locked": { "locked": {
"lastModified": 1681028828, "lastModified": 1681028828,

209
flake.nix
View file

@ -6,134 +6,97 @@
naersk.url = "github:nix-community/naersk"; naersk.url = "github:nix-community/naersk";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
fenix.url = "github:nix-community/fenix"; fenix.url = "github:nix-community/fenix";
rust-overlay.url = "github:oxalica/rust-overlay";
}; };
outputs = outputs = inputs: with inputs;
inputs: flake-utils.lib.eachDefaultSystem
with inputs; (system:
flake-utils.lib.eachDefaultSystem ( let
system: pkgs = import nixpkgs {
let inherit system;
overlays = [ (import rust-overlay) ]; overlays = [fenix.overlays.default];
pkgs = import nixpkgs { # overlays = [cargo2nix.overlays.default];
inherit system overlays; };
# overlays = [ rust-overlay.overlays.default ]; naersk' = pkgs.callPackage naersk {};
# overlays = [cargo2nix.overlays.default]; nbi = with pkgs; [
}; # Rust tools
naersk' = pkgs.callPackage naersk { }; alejandra
(pkgs.fenix.stable.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
rust-analyzer
vulkan-loader
wayland
wayland-protocols
libxkbcommon
pkg-config
sccache
];
# toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]); bi = with pkgs; [
gcc
stdenv
gnumake
gdb
lldb
cmake
makeWrapper
vulkan-headers
vulkan-loader
vulkan-tools
libGL
cargo-flamegraph
fontconfig
glib
alsa-lib
gst_all_1.gst-libav
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-ugly
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-rs
gst_all_1.gst-vaapi
gst_all_1.gstreamer
# podofo
# mpv
ffmpeg-full
# yt-dlp
nativeBuildInputs = with pkgs; [ just
# Rust tools sqlx-cli
# toolchain cargo-watch
# (pkgs.fenix.default.withComponents [ ];
# "cargo" in rec
# "clippy" {
# "rust-std" devShell = pkgs.mkShell.override {
# # "rust-src" # stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv;
# "rustc" } {
# "rustfmt" nativeBuildInputs = nbi;
# ]) buildInputs = bi;
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
extensions = [ "rust-src" "rust-analyzer" "clippy" ]; with pkgs;
})) pkgs.lib.makeLibraryPath [
cargo-nextest pkgs.vulkan-loader
cargo-criterion pkgs.wayland
# rust-analyzer-nightly pkgs.wayland-protocols
vulkan-loader pkgs.libxkbcommon
wayland ]
wayland-protocols }";
libxkbcommon DATABASE_URL = "sqlite:///home/chris/.local/share/lumina/library-db.sqlite3";
pkg-config };
sccache defaultPackage = naersk'.buildPackage {
];
buildInputs = with pkgs; [
gcc
stdenv
gnumake
gdb
lldb
cmake
clang
libclang
makeWrapper
vulkan-headers
vulkan-loader
vulkan-tools
libGL
cargo-flamegraph
bacon
fontconfig
glib
alsa-lib
gst_all_1.gst-libav
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-ugly
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-rs
gst_all_1.gst-vaapi
gst_all_1.gstreamer
ffmpeg-full
mupdf
# yt-dlp
just
sqlx-cli
cargo-watch
samply
];
LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
with pkgs;
pkgs.lib.makeLibraryPath [
pkgs.alsa-lib
pkgs.gst_all_1.gst-libav
pkgs.gst_all_1.gstreamer
pkgs.gst_all_1.gst-plugins-bad
pkgs.gst_all_1.gst-plugins-good
pkgs.gst_all_1.gst-plugins-ugly
pkgs.gst_all_1.gst-plugins-base
pkgs.gst_all_1.gst-plugins-rs
pkgs.gst_all_1.gst-vaapi
pkgs.glib
pkgs.fontconfig
pkgs.vulkan-loader
pkgs.wayland
pkgs.wayland-protocols
pkgs.libxkbcommon
pkgs.mupdf
pkgs.libclang
]
}";
in
rec {
devShell =
pkgs.mkShell.override
{
# stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv;
}
{
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
# LIBCLANG_PATH = "${pkgs.clang}";
DATABASE_URL = "sqlite://./test.db";
# RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library";
};
defaultPackage = naersk'.buildPackage {
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.;
};
packages = {
default = naersk'.buildPackage {
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.; src = ./.;
}; };
}; packages = {
} default = naersk'.buildPackage {
); src = ./.;
};
};
}
);
} }

View file

@ -1,37 +1,24 @@
ui := "-i" ui := "-i"
verbose := "-v"
file := "~/dev/lumina-iced/test_presentation.lisp" file := "~/dev/lumina-iced/test_presentation.lisp"
export RUSTC_WRAPPER := "sccache"
# export RUST_LOG := "debug"
default: default:
just --list just --list
build: build:
cargo build RUST_LOG=debug cargo build
build-release: sbuild:
cargo build --release RUST_LOG=debug sccache cargo build
run: run:
cargo run -- {{verbose}} {{ui}} RUST_LOG=debug cargo run -- {{ui}} {{file}}
run-release: srun:
cargo run --release -- {{verbose}} {{ui}} RUST_LOG=debug sccache cargo run -- {{ui}} {{file}}
run-file:
cargo run -- {{verbose}} {{ui}} {{file}}
clean: clean:
cargo clean RUST_LOG=debug cargo clean
test: test:
cargo nextest run RUST_LOG=debug cargo test --benches --tests --all-features -- --nocapture
ci-test:
cargo nextest run -- --skip test_db_and_model --skip test_update --skip test_song_slide_speed --skip test_song_to_slide --skip test_song_from_db
bench:
export NEXTEST_EXPERIMENTAL_BENCHMARKS=1
cargo nextest bench
profile: profile:
samply record cargo run --release -- {{verbose}} {{ui}} cargo flamegraph --image-width 8000 -- {{ui}} {{file}}
alias b := build alias b := build
alias r := run alias r := run
alias br := build-release alias sr := srun
alias rr := run-release
alias rf := run-file
alias c := clean alias c := clean

View file

@ -1,3 +0,0 @@
-- Add migration script here
ALTER TABLE presentations
DROP COLUMN pageCount;

View file

@ -1,6 +0,0 @@
-- Add migration script here
ALTER TABLE presentations
ADD COLUMN starting_index INTEGER;
ALTER TABLE presentations
ADD COLUMN ending_index INTEGER;

View file

@ -1,19 +0,0 @@
-- Add migration script here
ALTER TABLE songs
ADD COLUMN stroke_size INTEGER;
ALTER TABLE songs
ADD COLUMN stroke_color TEXT;
ALTER TABLE songs
ADD COLUMN shadow_size INTEGER;
ALTER TABLE songs
ADD COLUMN shadow_offset_x INTEGER;
ALTER TABLE songs
ADD COLUMN shadow_offset_y INTEGER;
ALTER TABLE songs
ADD COLUMN shadow_color TEXT;

View file

@ -1,6 +0,0 @@
-- Add migration script here
ALTER TABLE songs
ADD COLUMN weight TEXT;
ALTER TABLE songs
ADD COLUMN style TEXT;

View file

@ -4,12 +4,14 @@
Lumina is a presentation app that works from a cli or a UI. The goal is that through a simple text file, you can describe an entire presentation and then load and control it either from the command line, or a UI. The UI also provides user friendly ways of creating the presentation to allow for flexibility for users to make something that works for regular folk as well as developers and nerds. Lumina is a presentation app that works from a cli or a UI. The goal is that through a simple text file, you can describe an entire presentation and then load and control it either from the command line, or a UI. The UI also provides user friendly ways of creating the presentation to allow for flexibility for users to make something that works for regular folk as well as developers and nerds.
* Why build this? * Why build this?
Well for one, I want more experience developing things and I don't have a good tool for this kind of thing on Linux.
Primarily, I don't think there is a good tool for this kind of thing on Linux. On Windows and Mac there is ProPresenter or Proclaim. Both amazing presentation software built for churches or worship centers and can be used by others for other things too, but incredible tools. I want to have a similar tool on Linux. The available tools out there now are often old, broken, or very difficult to use. I want something incredibly easy, with very sane or at least very customizable keyboard controls that allow me to quickly build a presentation and make it VERY easy to run it too. Primarily, I don't think there is a good tool for this kind of thing on Linux. On Windows and Mac there is ProPresenter or Proclaim. Both amazing presentation software built for churches or worship centers and can be used by others for other things too, but incredible tools. I want to have a similar tool on Linux. The available tools out there now are often old, broken, or very difficult to use. I want something incredibly easy, with very sane or at least very customizable keyboard controls that allow me to quickly build a presentation and make it VERY easy to run it too.
** Features (planned are in parentheses) ** Features (planned are in parentheses)
- Presents songs lyrics with image and video backgrounds - Presents songs lyrics with image and video backgrounds
- Simple song creation with a powerful text parser - Simple song creation with a powerful text parser
- Present Slides. PDF works. (PowerPoint, and Impress are in not implemented yet) - Present Slides (PDF, PowerPoint, and Impress are in not implemented yet)
- (Present Reveal.js slides) - (Present Reveal.js slides)
- (Custom slide builder) - (Custom slide builder)
- (an intuitive UI) - Still needs A LOT of polish - (an intuitive UI) - Still needs A LOT of polish

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 19,35L 19,43L 27,43L 27,35L 19,35 Z M 30.9999,35L 30.9999,43L 56.9998,43L 56.9999,35L 30.9999,35 Z M 31,32L 57,32L 57,24L 31,24L 31,32 Z M 32,31L 32,25L 56,25L 56,31L 32,31 Z M 21,33L 25,33L 25,30L 28,30L 28,26L 25,26L 25,23L 21,23L 21,26L 18,26L 18,30L 21,30L 21,33 Z M 22,32L 22,29L 19,29L 19,27L 22,27L 22,24L 24,24L 24,27L 27,27L 27,29L 24,29L 24,32L 22,32 Z M 19.0001,46L 19.0001,54L 27.0001,54L 27.0001,46L 19.0001,46 Z M 31.0001,46L 31.0001,54L 57,54L 57,46L 31.0001,46 Z "/>
</svg>

Before

Width:  |  Height:  |  Size: 1,022 B

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 19,42L 19,34L 27,34L 27,42L 19,42 Z M 30.9999,42L 30.9999,34L 56.9999,34L 56.9999,42L 30.9999,42 Z M 31,45L 57,45L 57,53L 31,53L 31,45 Z M 32,46L 32,52L 56,52L 56,46L 32,46 Z M 21,44L 25,44L 25,47L 28,47L 28,51L 25,51L 25,54L 21,54L 21,51L 18,51L 18,47L 21,47L 21,44 Z M 22,45L 22,48L 19,48L 19,50L 22,50L 22,53L 24,53L 24,50L 27,50L 27,48L 24,48L 24,45L 22,45 Z M 19.0001,31L 19.0001,23L 27.0001,23L 27.0001,31L 19.0001,31 Z M 31.0001,31L 31.0001,23L 57,23L 57,31L 31.0001,31 Z "/>
</svg>

Before

Width:  |  Height:  |  Size: 1,022 B

Binary file not shown.

View file

@ -1,79 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704Z"
fill="#000000"
/>
<path
opacity=".05"
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.78296 13.376C8.73904 9.95284 8.73904 5.04719 6.78296 1.62405L7.21708 1.37598C9.261 4.95283 9.261 10.0472 7.21708 13.624L6.78296 13.376Z"
fill="#000000"
/>
<path
opacity=".1"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.28204 13.4775C9.23929 9.99523 9.23929 5.00475 7.28204 1.52248L7.71791 1.2775C9.76067 4.9119 9.76067 10.0881 7.71791 13.7225L7.28204 13.4775Z"
fill="#000000"
/>
<path
opacity=".15"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.82098 13.5064C9.72502 9.99523 9.72636 5.01411 7.82492 1.50084L8.26465 1.26285C10.2465 4.92466 10.2451 10.085 8.26052 13.7448L7.82098 13.5064Z"
fill="#000000"
/>
<path
opacity=".2"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.41284 13.429C10.1952 9.92842 10.1957 5.07537 8.41435 1.57402L8.85999 1.34729C10.7139 4.99113 10.7133 10.0128 8.85841 13.6559L8.41284 13.429Z"
fill="#000000"
/>
<path
opacity=".25"
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.02441 13.2956C10.6567 9.8379 10.6586 5.17715 9.03005 1.71656L9.48245 1.50366C11.1745 5.09919 11.1726 9.91629 9.47657 13.5091L9.02441 13.2956Z"
fill="#000000"
/>
<path
opacity=".3"
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.66809 13.0655C11.1097 9.69572 11.1107 5.3121 9.67088 1.94095L10.1307 1.74457C11.6241 5.24121 11.6231 9.76683 10.1278 13.2622L9.66809 13.0655Z"
fill="#000000"
/>
<path
opacity=".35"
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.331 12.7456C11.5551 9.52073 11.5564 5.49103 10.3347 2.26444L10.8024 2.0874C12.0672 5.42815 12.0659 9.58394 10.7985 12.9231L10.331 12.7456Z"
fill="#000000"
/>
<path
opacity=".4"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.0155 12.2986C11.9938 9.29744 11.9948 5.71296 11.0184 2.71067L11.4939 2.55603C12.503 5.6589 12.502 9.35178 11.4909 12.4535L11.0155 12.2986Z"
fill="#000000"
/>
<path
opacity=".45"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.7214 11.668C12.4254 9.01303 12.4262 5.99691 11.7237 3.34116L12.2071 3.21329C12.9318 5.95292 12.931 9.05728 12.2047 11.7961L11.7214 11.668Z"
fill="#000000"
/>
<path
opacity=".5"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.4432 10.752C12.8524 8.63762 12.8523 6.36089 12.4429 4.2466L12.9338 4.15155C13.3553 6.32861 13.3554 8.66985 12.9341 10.847L12.4432 10.752Z"
fill="#000000"
/>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
width="800px"
height="800px"
viewBox="0 0 76 76"
version="1.1"
enable-background="new 0 0 76.00 76.00"
xml:space="preserve"
id="svg1"
sodipodi:docname="split-above.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.74629807"
inkscape:cx="252.58004"
inkscape:cy="447.54236"
inkscape:window-width="1463"
inkscape:window-height="909"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg1" />
<path
fill="#000000"
fill-opacity="1"
stroke-width="0.404256"
stroke-linejoin="round"
d="M 3.8256403,28.314338 V 46.261937 H 18.394593 V 28.314338 Z m 21.8532467,0 v 17.947599 h 47.348916 l 1.82e-4,-17.947599 z m 1.83e-4,-6.730349 H 73.028167 V 3.6363892 H 25.67907 Z m 1.821118,-2.243451 V 5.8798398 h 43.70686 V 19.340538 Z M 3.8258225,52.992286 V 70.939887 H 18.394774 V 52.992286 Z m 21.8534285,0 V 70.939887 H 73.028167 V 52.992286 Z"
id="path1"
sodipodi:nodetypes="cccccccccccccccccccccccccccccc" />
<path
d="M 3.5834285,21.523408 H 18.345395 V 3.5758086 H 3.5834285 Z M 5.3255032,19.102699 V 5.7129051 L 16.732439,5.5582305 16.783963,19.315409 Z"
style="fill:#000000;fill-opacity:1;stroke-width:0.225723;stroke-linejoin:round"
id="path1-2"
sodipodi:nodetypes="cccccccccc" /><rect
style="fill:#000000;stroke-width:0.11759"
id="rect1"
width="72.699356"
height="1.642724"
x="1.9440452"
y="24.184471" /></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
width="800px"
height="800px"
viewBox="0 0 76 76"
version="1.1"
enable-background="new 0 0 76.00 76.00"
xml:space="preserve"
id="svg1"
sodipodi:docname="split-below.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.76425252"
inkscape:cx="252.53433"
inkscape:cy="367.67952"
inkscape:window-width="1463"
inkscape:window-height="909"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg1" />
<path
fill="#000000"
fill-opacity="1"
stroke-width="0.404256"
stroke-linejoin="round"
d="M 3.8256403,28.314338 V 46.261937 H 18.394593 V 28.314338 Z m 21.8532467,0 v 17.947599 h 47.348916 l 1.82e-4,-17.947599 z"
id="path1"
sodipodi:nodetypes="cccccccccc" />
<path
d="M 3.8,71.717599 H 18.561966 V 53.77 H 3.8 Z M 5.5420747,69.29689 V 55.907096 L 16.94901,55.752421 17.00053,69.5096 Z"
style="fill:#000000;fill-opacity:1;stroke-width:0.225723;stroke-linejoin:round"
id="path1-2"
sodipodi:nodetypes="cccccccccc" /><rect
style="fill:#000000;stroke-width:0.11759"
id="rect1"
width="72.699356"
height="1.642724"
x="1.9440452"
y="49.006992" /><path
d="M 3.8,3.924661 V 21.872262 H 18.368951 V 3.924661 Z m 21.853428,0 V 21.872262 H 73.002344 V 3.924661 Z"
style="fill:#000000;fill-opacity:1;stroke-width:0.404256;stroke-linejoin:round"
id="path1-7" /><path
d="M 25.65,71.7176 H 72.999097 V 53.77 H 25.65 Z m 1.821118,-2.243451 V 56.013451 h 43.70686 v 13.460698 z"
style="fill:#000000;fill-opacity:1;stroke-width:0.404256;stroke-linejoin:round"
id="path1-6" /></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Text-effects-24-regular SVG Icon</title><path fill="currentColor" d="M14.298 4.015a2.5 2.5 0 0 0-4.595 0l-4.264 9.95q-.023.05-.045.103l-1.691 3.947a2.5 2.5 0 1 0 4.596 1.97L9.363 17.5h5.275l1.065 2.485a2.5 2.5 0 0 0 4.596-1.97l-1.692-3.947l-.045-.104zM12.495 12.5l-.494-1.153l-.495 1.153zm.425-7.894l4.277 9.98l.018.041l1.705 3.98a1 1 0 0 1-1.839.787L15.627 16H8.374L6.92 19.394a1 1 0 1 1-1.839-.788l1.706-3.979l.017-.041l4.277-9.98a1 1 0 0 1 1.839 0M14.77 14H9.23L12 7.539z"/></svg>

Before

Width:  |  Height:  |  Size: 573 B

View file

@ -1,12 +0,0 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="Layer_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<style type="text/css">
.st0{fill:#6040EC;}
.st1{fill:#0BDC49;}
</style>
<g>
<path class="st1" d="M425.6,63.6H95.9c-2.1-1.4-4.5-2.1-7-2.1c-6.9,0-12.4,5.6-12.4,12.4c0,2.7,0.9,5.3,2.4,7.4v66.1 c0,6.6,5.4,12,12,12c6.6,0,12-5.4,12-12V87.6h151.9V458c0,6.6,5.4,12,12,12s12-5.4,12-12V87.6h146.8c2.8,0,5.1,2.6,5.1,5.9v53.6 c0,6.6,5.4,12,12,12s12-5.4,12-12V93.4C454.7,77,441.6,63.6,425.6,63.6z"/>
<path class="st0" d="M404,42H86.4c-16,0-29.1,13.4-29.1,29.9v53.9c0,6.6,5.4,12,12,12s12-5.4,12-12V71.9c0-3.2,2.3-5.9,5.1-5.9 h146.8v385c0,6.6,5.4,12,12,12s12-5.4,12-12V66H404c2.8,0,5.1,2.6,5.1,5.9v53.6c0,6.6,5.4,12,12,12s12-5.4,12-12V71.9 C433.1,55.4,420.1,42,404,42z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 976 B

View file

@ -1,3 +1,3 @@
max_width = 70 max_width = 70
style_edition = "2024" # style_edition = "2018"
# version = "Two" # version = "Two"

View file

@ -1,534 +1,356 @@
use crate::core::{
kinds::ServiceItemKind, service_items::ServiceItem,
slide::Background,
};
use cosmic::widget::image::Handle;
use miette::{IntoDiagnostic, Result, miette};
use std::{
fs::{self, File},
io::Write,
iter,
path::{Path, PathBuf},
};
use tar::{Archive, Builder}; use tar::{Archive, Builder};
use tracing::{debug, error}; use tracing::error;
use zstd::{Decoder, Encoder}; use zstd::Encoder;
use std::{fs::{self, File}, iter, path::{Path, PathBuf}};
use color_eyre::eyre::{eyre, Context, Result};
use serde_json::Value;
use sqlx::{query, query_as, FromRow, SqliteConnection};
use crate::{images::{get_image_from_db, Image}, kinds::ServiceItemKind, model::get_db, presentations::{get_presentation_from_db, PresKind, Presentation}, service_items::ServiceItem, slides::Background, songs::{get_song_from_db, Song}, videos::{get_video_from_db, Video}};
#[allow(clippy::too_many_lines)] pub async fn save(list: Vec<ServiceItem>, path: impl AsRef<Path>) -> Result<()> {
pub fn save(
list: Vec<ServiceItem>,
path: impl AsRef<Path>,
overwrite: bool,
) -> Result<()> {
let path = path.as_ref(); let path = path.as_ref();
if overwrite && path.exists() { let save_file = File::create(path)?;
fs::remove_file(path).into_diagnostic()?; let mut db = get_db().await;
} let json = process_service_items(&list, &mut db).await?;
let save_file = File::create(path).into_diagnostic()?; let archive = store_service_items(&list, &mut db, &save_file, &json).await?;
let ron_pretty = ron::ser::PrettyConfig::default(); Ok(())
let ron = ron::ser::to_string_pretty(&list, ron_pretty)
.into_diagnostic()?;
let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail")
.auto_finish();
let mut tar = Builder::new(encoder);
let mut temp_dir = dirs::data_dir().expect(
"there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
);
temp_dir.push("lumina");
let mut s: String =
iter::repeat_with(fastrand::alphanumeric).take(5).collect();
s.insert_str(0, "temp_");
temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?;
let service_file = temp_dir.join("serviceitems.ron");
debug!(?service_file);
fs::File::create(&service_file).into_diagnostic()?;
match fs::File::options()
.read(true)
.write(true)
.open(service_file)
{
Ok(mut f) => {
match f.write(ron.as_bytes()) {
Ok(size) => {
debug!(size);
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => {
debug!(
"should have added serviceitems.ron to the file"
);
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
}
Err(e) => {
error!("There were problems making a file i guess: {e}");
return Err(miette!("There was a problem: {e}"));
}
}
let mut append_file = |path: PathBuf| -> Result<()> {
let file_name = path.file_name().unwrap_or_default();
let mut file = fs::File::open(&path).into_diagnostic()?;
tar.append_file(file_name, &mut file).into_diagnostic()?;
Ok(())
};
for item in list {
let background;
let audio: Option<PathBuf>;
match &item.kind {
ServiceItemKind::Song(song) => {
background = song.background.clone();
audio = song.audio.clone();
}
ServiceItemKind::Image(image) => {
background = Some(
Background::try_from(image.path.clone())
.into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Video(video) => {
background = Some(
Background::try_from(video.path.clone())
.into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Presentation(presentation) => {
background = Some(
Background::try_from(presentation.path.clone())
.into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Content(_slide) => {
todo!()
}
}
if let Some(path) = audio
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
if let Some(background) = background
&& let path = background.path
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
for slide in item.slides {
if let Some(svg) = slide.text_svg
&& let Some(path) = svg.path
{
append_file(path)?;
}
}
}
match tar.finish() {
Ok(()) => (),
Err(e) => {
error!(?e);
return Err(miette!("tar error: {e}"));
}
}
fs::remove_dir_all(temp_dir).into_diagnostic()
} }
#[allow(clippy::too_many_lines)] async fn store_service_items(items: &Vec<ServiceItem>, db: &mut SqliteConnection, save_file: &File, json: &Value) -> Result<()> {
pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> { let encoder = Encoder::new(save_file, 3).unwrap();
let decoder = let mut tar = Builder::new(encoder);
Decoder::new(fs::File::open(&path).into_diagnostic()?) let mut temp_dir = dirs::data_dir().unwrap();
.into_diagnostic()?; temp_dir.push("lumina");
let mut tar = Archive::new(decoder); let mut s: String =
iter::repeat_with(fastrand::alphanumeric)
let mut cache_dir = .take(5)
dirs::cache_dir().expect("Should be a cache dir"); .collect();
cache_dir.push("lumina"); s.insert_str(0, "temp_");
cache_dir.push("cached_save_files"); temp_dir.push(s);
fs::create_dir_all(&temp_dir)?;
let save_name_ext = path let service_file = temp_dir.join("serviceitems.json");
.as_ref() fs::File::create(&service_file)?;
.extension() match fs::File::options().read(true).write(true).open(service_file) {
.expect("Should have extension") Ok(f) => {
.to_str() serde_json::to_writer_pretty(f, json)?;
.expect("Should be fine"); },
let save_name_string = path Err(e) => error!("There were problems making a file i guess: {e}"),
.as_ref() };
.file_name() for item in items {
.expect("Should be a name") let background;
.to_os_string() let audio: Option<PathBuf>;
.into_string() match item.kind {
.expect("Should be fine"); ServiceItemKind::Song => {
let save_name = save_name_string let song = get_song_from_db(item.database_id, db).await?;
.trim_end_matches(&format!(".{save_name_ext}")); background = song.background;
cache_dir.push(save_name); audio = song.audio;
},
if let Err(e) = fs::remove_dir_all(&cache_dir) { ServiceItemKind::Image => {
debug!("There is no dir here: {e}"); let image = get_image_from_db(item.database_id, db).await?;
} background = Some(Background::try_from(image.path)?);
fs::create_dir_all(&cache_dir).into_diagnostic()?; audio = None;
},
for entry in tar.entries().into_diagnostic()? { ServiceItemKind::Video => {
let mut entry = entry.into_diagnostic()?; let video = get_video_from_db(item.database_id, db).await?;
entry.unpack_in(&cache_dir).into_diagnostic()?; background = Some(Background::try_from(video.path)?);
} audio = None;
},
let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?; ServiceItemKind::Presentation(_) => {
let ron_file = dir let presentation = get_presentation_from_db(item.database_id, db).await?;
.find_map(|file| { background = Some(Background::try_from(presentation.path)?);
if file.as_ref().ok()?.path().extension()?.to_str()? audio = None;
== "ron" },
{ ServiceItemKind::Content => {
Some(file.ok()?.path()) todo!()
},
};
if let Some(file) = audio {
let audio_file = temp_dir.join(file.file_name().expect("Audio file couldn't be added to temp_dir"));
if let Ok(file) = file.strip_prefix("file://") {
fs::File::create(&audio_file).wrap_err("Couldn't create audio file")?;
fs::copy(file, audio_file).wrap_err("Audio file could not be copied, the source file doesn't exist not be found");
} else { } else {
None fs::File::create(&audio_file).wrap_err("Couldn't create audio file")?;
fs::copy(file, audio_file).wrap_err("Audio file could not be copied, the source file doesn't exist not be found");
} }
}) };
.expect("Should have a ron file"); if let Some(file) = background {
let background_file = temp_dir.join(file.path.file_name().expect("Background file couldn't be added to temp_dir"));
let ron_string = if let Ok(file) = file.path.strip_prefix("file://") {
fs::read_to_string(ron_file).into_diagnostic()?; fs::File::create(&background_file).wrap_err("Couldn't create background file")?;
fs::copy(file, background_file).wrap_err("Background file could not be copied, the source file doesn't exist not be found");
let mut items = } else {
ron::de::from_str::<Vec<ServiceItem>>(&ron_string) fs::File::create(&background_file).wrap_err("Couldn't create background file")?;
.into_diagnostic()?; fs::copy(file.path, background_file).wrap_err("Background file could not be copied, the source file doesn't exist not be found");
for item in &mut items {
let dir = fs::read_dir(&cache_dir).into_diagnostic()?;
for file in dir {
for slide in &mut item.slides {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
let audio_path =
slide.audio().clone().unwrap_or_default();
let text_path = slide
.text_svg
.as_ref()
.and_then(|svg| svg.path.clone());
if Some(file_name.as_os_str())
== slide.background.path.file_name()
{
slide.background.path = file.path();
} else if Some(file_name.as_os_str())
== audio_path.file_name()
{
let new_slide = slide
.clone()
.set_audio(Some(file.path()));
*slide = new_slide;
} else if Some(file_name.as_os_str())
== text_path
.clone()
.unwrap_or_default()
.file_name()
&& let Some(svg) = slide.text_svg.as_mut()
{
svg.path = Some(file.path());
svg.handle =
Some(Handle::from_path(file.path()));
}
}
}
match &mut item.kind {
ServiceItemKind::Song(song) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
let audio_path =
song.audio.clone().unwrap_or_default();
if Some(file_name.as_os_str())
== song
.background
.clone()
.unwrap_or_default()
.path
.file_name()
{
let background = song.background.clone();
song.background =
background.map(|mut background| {
background.path = file.path();
background
});
} else if Some(file_name.as_os_str())
== audio_path.file_name()
{
song.audio = Some(file.path());
}
}
}
ServiceItemKind::Video(video) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str())
== video.path.file_name()
{
video.path = file.path();
}
}
}
ServiceItemKind::Image(image) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str())
== image.path.file_name()
{
image.path = file.path();
}
}
}
ServiceItemKind::Presentation(presentation) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str())
== presentation.path.file_name()
{
presentation.path = file.path();
}
}
}
ServiceItemKind::Content(_slide) => todo!(),
} }
} }
} }
Ok(items) Ok(())
}
async fn clear_temp_dir(temp_dir: &Path) -> Result<()> {
todo!()
}
async fn process_service_items(items: &Vec<ServiceItem>, db: &mut SqliteConnection) -> Result<Value> {
let mut values: Vec<Value> = vec![];
for item in items {
match item.kind {
ServiceItemKind::Song => {
let value = process_song(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Image => {
let value = process_image(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Video => {
let value = process_video(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Presentation(_) => {
let value = process_presentation(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Content => {
todo!()
},
}
}
let json = Value::from(values);
Ok(json)
}
async fn process_song(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let song = get_song_from_db(database_id, db).await?;
let song_json = serde_json::to_value(&song)?;
let kind_json = serde_json::to_value(ServiceItemKind::Song)?;
let json = serde_json::json!({"item": song_json, "kind": kind_json});
Ok(json)
}
async fn process_image(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let image = get_image_from_db(database_id, db).await?;
let image_json = serde_json::to_value(&image)?;
let kind_json = serde_json::to_value(ServiceItemKind::Image)?;
let json = serde_json::json!({"item": image_json, "kind": kind_json});
Ok(json)
}
async fn process_video(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let video = get_video_from_db(database_id, db).await?;
let video_json = serde_json::to_value(&video)?;
let kind_json = serde_json::to_value(ServiceItemKind::Video)?;
let json = serde_json::json!({"item": video_json, "kind": kind_json});
Ok(json)
}
async fn process_presentation(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let presentation = get_presentation_from_db(database_id, db).await?;
let presentation_json = serde_json::to_value(&presentation)?;
let kind_json = match presentation.kind {
PresKind::Html => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Html))?,
PresKind::Pdf => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Pdf))?,
PresKind::Generic => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Generic))?,
};
let json = serde_json::json!({"item": presentation_json, "kind": kind_json});
Ok(json)
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use rayon::iter::{IntoParallelIterator, ParallelIterator}; use std::path::PathBuf;
use resvg::usvg::fontdb;
use fs::canonicalize;
use sqlx::Connection;
use pretty_assertions::assert_eq;
use tracing::debug;
use super::*; use super::*;
use crate::{
core::{
service_items::ServiceTrait,
slide::{Slide, TextAlignment},
songs::{Song, VerseName},
},
ui::text_svg::text_svg_generator,
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
fn test_song() -> Song { async fn get_db() -> SqliteConnection {
let lyrics = "Some({Verse(number:4):\"Our Savior displayed\\nOn a criminal\\'s cross\\n\\nDarkness rejoiced as though\\nHeaven had lost\\n\\nBut then Jesus arose\\nWith our freedom in hand\\n\\nThat\\'s when death was arrested\\nAnd my life began\\n\\nThat\\'s when death was arrested\\nAnd my life began\",Intro(number:1):\"Death Was Arrested\\nNorth Point Worship\",Verse(number:3):\"Released from my chains,\\nI\\'m a prisoner no more\\n\\nMy shame was a ransom\\nHe faithfully bore\\n\\nHe cancelled my debt and\\nHe called me His friend\\n\\nWhen death was arrested\\nAnd my life began\",Bridge(number:1):\"Oh, we\\'re free, free,\\nForever we\\'re free\\n\\nCome join the song\\nOf all the redeemed\\n\\nYes, we\\'re free, free,\\nForever amen\\n\\nWhen death was arrested\\nAnd my life began\\n\\nOh, we\\'re free, free,\\nForever we\\'re free\\n\\nCome join the song\\nOf all the redeemed\\n\\nYes, we\\'re free, free,\\nForever amen\\n\\nWhen death was arrested\\nAnd my life began\",Other(number:99):\"When death was arrested\\nAnd my life began\\n\\nThat\\'s when death was arrested\\nAnd my life began\",Verse(number:2):\"Ash was redeemed\\nOnly beauty remains\\n\\nMy orphan heart\\nWas given a name\\n\\nMy mourning grew quiet,\\nMy feet rose to dance\\n\\nWhen death was arrested\\nAnd my life began\",Verse(number:1):\"Alone in my sorrow\\nAnd dead in my sin\\n\\nLost without hope\\nWith no place to begin\\n\\nYour love made a way\\nTo let mercy come in\\n\\nWhen death was arrested\\nAnd my life began\",Chorus(number:1):\"Oh, Your grace so free,\\nWashes over me\\n\\nYou have made me new,\\nNow life begins with You\\n\\nIt\\'s Your endless love,\\nPouring down on us\\n\\nYou have made us new,\\nNow life begins with You\"})".to_string(); let mut data = dirs::data_local_dir().unwrap();
let verse_map: Option<HashMap<VerseName, String>> = data.push("lumina");
ron::from_str(&lyrics).unwrap(); data.push("library-db.sqlite3");
Song { let mut db_url = String::from("sqlite://");
id: 7, db_url.push_str(data.to_str().unwrap());
title: "Death Was Arrested".to_string(), SqliteConnection::connect(&db_url)
lyrics: Some(lyrics), .await
author: Some( .expect("problems")
"North Point Worship".to_string(), }
),
ccli: None, #[tokio::test(flavor = "current_thread")]
audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()), async fn test_process_song() {
verse_order: Some(vec!["Some([Chorus(number:1),Intro(number:1),Other(number:99),Bridge(number:1),Verse(number:4),Verse(number:2),Verse(number:3),Verse(number:1)])".to_string()]), let mut db = get_db().await;
background: Some(Background::try_from("/home/chris/nc/tfc/openlp/Flood/motions/Ocean_Floor_HD.mp4").unwrap()), let result = process_song(7, &mut db).await;
text_alignment: Some(TextAlignment::MiddleCenter), let json_song_file = PathBuf::from("./test/test_song.json");
font: None, if let Ok(path) = canonicalize(json_song_file) {
font_size: Some(120), debug!(file = ?&path);
font_style: None, if let Ok(s) = fs::read_to_string(path) {
font_weight: None, debug!(s);
text_color: None, match result {
stroke_size: None, Ok(json) => assert_eq!(json.to_string(), s),
verses: Some(vec![VerseName::Chorus { number: 1 }, VerseName::Intro { number: 1 }, VerseName::Other { number: 99 }, VerseName::Bridge { number: 1 }, VerseName::Verse { number: 4 }, VerseName::Verse { number: 2 }, VerseName::Verse { number: 3 }, VerseName::Verse { number: 1 } Err(e) => panic!("There was an error in processing the song: {e}"),
]), }
verse_map, } else {
..Default::default() panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_song.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_image() {
let mut db = get_db().await;
let result = process_image(3, &mut db).await;
let json_image_file = PathBuf::from("./test/test_image.json");
if let Ok(path) = canonicalize(json_image_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the image: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_image.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_video() {
let mut db = get_db().await;
let result = process_video(73, &mut db).await;
let json_video_file = PathBuf::from("./test/test_video.json");
if let Ok(path) = canonicalize(json_video_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the video: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_video.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_presentation() {
let mut db = get_db().await;
let result = process_presentation(54, &mut db).await;
let json_presentation_file = PathBuf::from("./test/test_presentation.json");
if let Ok(path) = canonicalize(json_presentation_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the presentation: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_presentation.json");
} }
} }
fn get_items() -> Vec<ServiceItem> { fn get_items() -> Vec<ServiceItem> {
let song = test_song();
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let fontdb = Arc::new(fontdb);
let slides = song
.to_slides()
.unwrap()
.into_par_iter()
.map(|slide| {
text_svg_generator(
slide.clone(),
&Arc::clone(&fontdb),
)
.map_or_else(
|e| {
assert!(false, "Couldn't create svg: {e}");
slide
},
|slide| slide,
)
})
.collect::<Vec<Slide>>();
let items = vec![ let items = vec![
ServiceItem { ServiceItem {
database_id: 7, database_id: 7,
kind: ServiceItemKind::Song(song.clone()), kind: ServiceItemKind::Song,
id: 0, id: 0,
title: "Death was Arrested".into(),
slides: slides.clone(),
}, },
ServiceItem { ServiceItem {
database_id: 7, database_id: 54,
kind: ServiceItemKind::Song(song), kind: ServiceItemKind::Presentation(PresKind::Html),
id: 1, id: 0,
title: "Death was Arrested".into(), },
slides: slides, ServiceItem {
database_id: 73,
kind: ServiceItemKind::Video,
id: 0,
}, },
]; ];
items items
} }
#[test] #[tokio::test]
fn test_load() -> Result<(), String> { async fn test_service_items() {
test_save(); let mut db = get_db().await;
let path = PathBuf::from("./test.pres"); let items = get_items();
let result = load(&path); let json_item_file = PathBuf::from("./test/test_service_items.json");
match result { let result = process_service_items(&items, &mut db).await;
Ok(items) => { if let Ok(path) = canonicalize(json_item_file) {
assert!(items.len() > 0); if let Ok(s) = fs::read_to_string(path) {
// assert_eq!(items, get_items()); match result {
let cache_dir = cache_dir(); Ok(strings) => assert_eq!(strings.to_string(), s),
assert!(fs::read_dir(&cache_dir).is_ok()); Err(e) => panic!("There was an error: {e}"),
assert!( }
find_paths(&items),
"Some paths must not have the cache_dir in it's path"
);
find_svgs(&items)?;
Ok(())
} }
Err(e) => Err(e.to_string()),
} }
} }
fn find_svgs(items: &Vec<ServiceItem>) -> Result<(), String> { // #[tokio::test]
let cache_dir = cache_dir(); // async fn test_save() {
items.iter().try_for_each(|item| { // let path = PathBuf::from("~/dev/lumina/src/rust/core/test.pres");
if let ServiceItemKind::Song(..) = item.kind { // let list = get_items();
item.slides.iter().try_for_each(|slide| { // match save(list, path).await {
slide.text_svg.as_ref().map_or(Err(String::from("There is no TextSvg for this song")), |text_svg| { // Ok(_) => assert!(true),
// Err(e) => panic!("There was an error: {e}"),
// }
// }
if text_svg.handle.is_none() { #[tokio::test]
return Err(String::from("There is no handle in this song's TextSvg")); async fn test_store() {
}; let path = PathBuf::from("/home/chris/dev/lumina/src/rust/core/test.pres");
let save_file = match File::create(path) {
text_svg.path.as_ref().map_or(Err(String::from("There is no path in this song's TextSvg")), |path| { Ok(f) => f,
if path.exists() { Err(e) => panic!("Couldn't create save_file: {e}"),
let mut path = path.clone(); };
if path.metadata().unwrap().len() < 20000 { let mut db = get_db().await;
return Err(String::from("SVG text is too small, maybe the svg didn't generate properly"))
}
if path.pop() && path == cache_dir {
Ok(())
} else {
Err(String::from("The path of the TextSvg isn't in the load directory"))
}
} else {
Err(String::from("The path in this TextSvg doesn't exist"))
}
})
})
})
} else {
Ok(())
}
})
}
// checks to make sure all paths in slides and items point to cache_dir
fn find_paths(items: &Vec<ServiceItem>) -> bool {
let cache_dir = cache_dir();
items.iter().all(|item| {
match &item.kind {
ServiceItemKind::Song(song) => {
if let Some(bg) = &song.background {
if !bg.path.starts_with(&cache_dir) {
return false;
}
}
if let Some(audio) = &song.audio {
if !audio.starts_with(&cache_dir) {
return false;
}
}
}
ServiceItemKind::Video(video) => {
if !video.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Image(image) => {
if !image.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Presentation(presentation) => {
if !presentation.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Content(_slide) => todo!(),
}
for slide in &item.slides {
if !slide.background().path.starts_with(&cache_dir) {
return false;
}
if !slide.audio().map_or(true, |audio| {
audio.starts_with(&cache_dir)
}) {
return false;
}
}
true
})
}
fn cache_dir() -> PathBuf {
let mut cache_dir = dirs::cache_dir().unwrap();
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
cache_dir.push("test");
cache_dir
}
#[test]
fn test_save() {
let path = PathBuf::from("./test.pres");
let list = get_items(); let list = get_items();
match save(list, &path, true) { if let Ok(json) = process_service_items(&list, &mut db).await {
Ok(_) => { println!("{:?}", json);
assert!(path.is_file()); match store_service_items(&list, &mut db, &save_file, &json).await {
let Ok(file) = fs::File::open(path) else { Ok(_) => assert!(true),
return assert!(false, "couldn't open file"); Err(e) => panic!("There was an error: {e}"),
};
let Ok(size) = file.metadata().map(|data| data.len())
else {
return assert!(
false,
"couldn't get file metadata"
);
};
assert!(size > 0);
} }
Err(e) => assert!(false, "{e}"), } else {
panic!("There was an error getting the json value");
} }
} }
// #[tokio::test]
// async fn test_things() {
// let mut temp_dir = dirs::data_dir().unwrap();
// temp_dir.push("lumina");
// let mut s: String =
// iter::repeat_with(fastrand::alphanumeric)
// .take(5)
// .collect();
// s.insert_str(0, "temp_");
// temp_dir.push(s);
// let _ = fs::create_dir_all(&temp_dir);
// let mut db = get_db().await;
// let service_file = temp_dir.join("serviceitems.json");
// let list = get_items();
// if let Ok(json) = process_service_items(&list, &mut db).await {
// let _ = fs::File::create(&service_file);
// match fs::write(service_file, json.to_string()) {
// Ok(_) => assert!(true),
// Err(e) => panic!("There was an error: {e}"),
// }
// } else {
// panic!("There was an error getting the json value");
// }
// }
} }

View file

@ -10,14 +10,14 @@ use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result}; use miette::{IntoDiagnostic, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection, pool::PoolConnection, query, query_as, Sqlite, SqliteConnection,
query, query_as, SqlitePool,
}; };
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use tracing::{debug, error}; use tracing::error;
#[derive( #[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)] )]
pub struct Image { pub struct Image {
pub id: i32, pub id: i32,
@ -25,30 +25,8 @@ pub struct Image {
pub path: PathBuf, pub path: PathBuf,
} }
impl From<PathBuf> for Image {
fn from(value: PathBuf) -> Self {
let title = value
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
Self {
id: 0,
title,
path: value.canonicalize().unwrap_or(value),
}
}
}
impl From<&Path> for Image {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl From<&Image> for Value { impl From<&Image> for Value {
fn from(_value: &Image) -> Self { fn from(value: &Image) -> Self {
Self::List(vec![Self::Symbol(Symbol("image".into()))]) Self::List(vec![Self::Symbol(Symbol("image".into()))])
} }
} }
@ -72,10 +50,10 @@ impl Content for Image {
fn subtext(&self) -> String { fn subtext(&self) -> String {
if self.path.exists() { if self.path.exists() {
self.path.file_name().map_or_else( self.path
|| "Missing image".into(), .file_name()
|f| f.to_string_lossy().to_string(), .map(|f| f.to_string_lossy().to_string())
) .unwrap_or("Missing image".into())
} else { } else {
"Missing image".into() "Missing image".into()
} }
@ -88,7 +66,6 @@ impl From<Value> for Image {
} }
} }
#[allow(clippy::option_if_let_else)]
impl From<&Value> for Image { impl From<&Value> for Image {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
@ -108,7 +85,7 @@ impl From<&Value> for Image {
let path = let path =
p.to_str().unwrap_or_default().to_string(); p.to_str().unwrap_or_default().to_string();
let title = let title =
path.rsplit_once('/').unwrap_or_default().1; path.rsplit_once("/").unwrap_or_default().1;
title.to_string() title.to_string()
}); });
Self { Self {
@ -177,51 +154,17 @@ impl Model<Image> {
.await; .await;
match result { match result {
Ok(v) => { Ok(v) => {
for image in v { for image in v.into_iter() {
let _ = self.add_item(image); let _ = self.add_item(image);
} }
} }
Err(e) => { Err(e) => {
error!( error!("There was an error in converting images: {e}")
"There was an error in converting images: {e}"
);
} }
} };
} }
} }
pub async fn remove_from_db(
db: PoolConnection<Sqlite>,
id: i32,
) -> Result<()> {
query!("DELETE FROM images WHERE id = $1", id)
.execute(&mut db.detach())
.await
.into_diagnostic()
.map(|_| ())
}
pub async fn add_image_to_db(
image: Image,
db: PoolConnection<Sqlite>,
) -> Result<()> {
let path = image
.path
.to_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
let mut db = db.detach();
query!(
r#"INSERT INTO images (title, file_path) VALUES ($1, $2)"#,
image.title,
path,
)
.execute(&mut db)
.await
.into_diagnostic()?;
Ok(())
}
pub async fn update_image_in_db( pub async fn update_image_in_db(
image: Image, image: Image,
db: PoolConnection<Sqlite>, db: PoolConnection<Sqlite>,
@ -229,29 +172,19 @@ pub async fn update_image_in_db(
let path = image let path = image
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(|s| s.to_string())
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach(); query!(
debug!(?image, "should be been updated");
let result = query!(
r#"UPDATE images SET title = $2, file_path = $3 WHERE id = $1"#, r#"UPDATE images SET title = $2, file_path = $3 WHERE id = $1"#,
image.id, image.id,
image.title, image.title,
path, path,
) )
.execute(&mut db) .execute(&mut db.detach())
.await.into_diagnostic(); .await
.into_diagnostic()?;
match result { Ok(())
Ok(_) => {
debug!("should have been updated");
Ok(())
}
Err(e) => {
error! {?e};
Err(e)
}
}
} }
pub async fn get_image_from_db( pub async fn get_image_from_db(
@ -269,9 +202,7 @@ mod test {
fn test_image(title: String) -> Image { fn test_image(title: String) -> Image {
Image { Image {
title, title,
path: PathBuf::from( path: PathBuf::from("~/pics/camprules2024.mp4"),
"/home/chris/pics/memes/no-i-dont-think.gif",
),
..Default::default() ..Default::default()
} }
} }
@ -282,10 +213,10 @@ mod test {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
}; };
let mut db = add_db().await.unwrap().acquire().await.unwrap(); let mut db = crate::core::model::get_db().await;
image_model.load_from_db(&mut db).await; image_model.load_from_db(&mut db).await;
if let Some(image) = image_model.find(|i| i.id == 23) { if let Some(image) = image_model.find(|i| i.id == 3) {
let test_image = test_image("no-i-dont-think.gif".into()); let test_image = test_image("nccq5".into());
assert_eq!(test_image.title, image.title); assert_eq!(test_image.title, image.title);
} else { } else {
assert!(false); assert!(false);
@ -319,9 +250,4 @@ mod test {
), ),
} }
} }
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
} }

View file

@ -1,11 +1,8 @@
use std::{error::Error, fmt::Display, path::PathBuf}; use std::{error::Error, fmt::Display};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::Slide;
Slide,
core::{content::Content, service_items::ServiceItem},
};
use super::{ use super::{
images::Image, presentations::Presentation, songs::Song, images::Image, presentations::Presentation, songs::Song,
@ -21,67 +18,14 @@ pub enum ServiceItemKind {
Content(Slide), Content(Slide),
} }
impl TryFrom<PathBuf> for ServiceItemKind {
type Error = miette::Error;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext {
"png" | "jpg" | "jpeg" => {
Ok(Self::Image(Image::from(path)))
}
"mp4" | "mkv" | "webm" => {
Ok(Self::Video(Video::from(path)))
}
"pdf" => Ok(Self::Presentation(Presentation::from(path))),
_ => Err(miette::miette!("Unknown item")),
}
}
}
impl ServiceItemKind {
pub fn title(&self) -> String {
match self {
Self::Song(song) => song.title.clone(),
Self::Video(video) => video.title.clone(),
Self::Image(image) => image.title.clone(),
Self::Presentation(presentation) => {
presentation.title.clone()
}
Self::Content(_slide) => todo!(),
}
}
pub fn to_service_item(&self) -> ServiceItem {
match self {
Self::Song(song) => song.to_service_item(),
Self::Video(video) => video.to_service_item(),
Self::Image(image) => image.to_service_item(),
Self::Presentation(presentation) => {
presentation.to_service_item()
}
Self::Content(_slide) => {
todo!()
}
}
}
}
impl std::fmt::Display for ServiceItemKind { impl std::fmt::Display for ServiceItemKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let s = match self { let s = match self {
Self::Song(_) => "song".to_owned(), Self::Song(s) => "song".to_owned(),
Self::Image(_) => "image".to_owned(), Self::Image(i) => "image".to_owned(),
Self::Video(_) => "video".to_owned(), Self::Video(v) => "video".to_owned(),
Self::Presentation(_) => "html".to_owned(), Self::Presentation(p) => "html".to_owned(),
Self::Content(_) => "content".to_owned(), Self::Content(s) => "content".to_owned(),
}; };
write!(f, "{s}") write!(f, "{s}")
} }
@ -106,7 +50,7 @@ impl std::fmt::Display for ServiceItemKind {
// } // }
impl From<ServiceItemKind> for String { impl From<ServiceItemKind> for String {
fn from(val: ServiceItemKind) -> Self { fn from(val: ServiceItemKind) -> String {
match val { match val {
ServiceItemKind::Song(_) => "song".to_owned(), ServiceItemKind::Song(_) => "song".to_owned(),
ServiceItemKind::Video(_) => "video".to_owned(), ServiceItemKind::Video(_) => "video".to_owned(),
@ -132,9 +76,7 @@ impl Display for ParseError {
f: &mut std::fmt::Formatter<'_>, f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result { ) -> std::fmt::Result {
let message = match self { let message = match self {
Self::UnknownType => { Self::UnknownType => "The type does not exist. It needs to be one of 'song', 'video', 'image', 'presentation', or 'content'",
"The type does not exist. It needs to be one of 'song', 'video', 'image', 'presentation', or 'content'"
}
}; };
write!(f, "Error: {message}") write!(f, "Error: {message}")
} }

157
src/core/lisp.rs Normal file
View file

@ -0,0 +1,157 @@
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,14 +1,11 @@
pub mod content; pub mod content;
pub mod file;
pub mod images; pub mod images;
pub mod kinds; pub mod kinds;
pub mod lisp;
pub mod model; pub mod model;
pub mod presentations; pub mod presentations;
pub mod service_items; pub mod service_items;
pub mod settings;
pub mod slide; pub mod slide;
pub mod slide_actions;
pub mod song_search;
pub mod songs; pub mod songs;
pub mod thumbnail; pub mod thumbnail;
pub mod videos; pub mod videos;

View file

@ -1,10 +1,7 @@
use std::{borrow::Cow, fs, mem::replace, path::PathBuf}; use std::mem::replace;
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes}; use miette::{miette, Result};
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize};
use sqlx::{Connection, SqliteConnection}; use sqlx::{Connection, SqliteConnection};
use tracing::debug;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Model<T> { pub struct Model<T> {
@ -12,9 +9,7 @@ pub struct Model<T> {
pub kind: LibraryKind, pub kind: LibraryKind,
} }
#[derive( #[derive(Debug, Clone, PartialEq, Copy)]
Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize,
)]
pub enum LibraryKind { pub enum LibraryKind {
Song, Song,
Video, Video,
@ -22,98 +17,36 @@ pub enum LibraryKind {
Presentation, Presentation,
} }
#[derive(
Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct KindWrapper(pub (LibraryKind, i32));
impl From<PathBuf> for LibraryKind {
fn from(_value: PathBuf) -> Self {
todo!()
}
}
impl TryFrom<(Vec<u8>, String)> for KindWrapper {
type Error = miette::Error;
fn try_from(
value: (Vec<u8>, String),
) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value;
match mime.as_str() {
"application/service-item" => {
ron::de::from_bytes(&data).into_diagnostic()
}
_ => Err(miette!("Wrong mime type: {mime}")),
}
}
}
impl AllowedMimeTypes for KindWrapper {
fn allowed() -> Cow<'static, [String]> {
Cow::from(vec!["application/service-item".to_string()])
}
}
impl AsMimeTypes for KindWrapper {
fn available(&self) -> Cow<'static, [String]> {
debug!(?self);
Cow::from(vec!["application/service-item".to_string()])
}
fn as_bytes(
&self,
mime_type: &str,
) -> Option<std::borrow::Cow<'static, [u8]>> {
debug!(?self);
debug!(mime_type);
let ron = ron::ser::to_string(self).ok()?;
debug!(ron);
Some(Cow::from(ron.into_bytes()))
}
}
impl<T> Model<T> { impl<T> Model<T> {
pub fn add_item(&mut self, item: T) -> Result<()> { pub fn add_item(&mut self, item: T) -> Result<()> {
self.items.push(item); self.items.push(item);
Ok(()) Ok(())
} }
pub fn add_to_db(&mut self, _item: T) -> Result<()> { pub fn add_to_db(&mut self, item: T) -> Result<()> {
todo!() todo!()
} }
pub fn update_item(&mut self, item: T, index: i32) -> Result<()> { pub fn update_item(&mut self, item: T, index: i32) -> Result<()> {
self.items if let Some(current_item) = self.items.get_mut(index as usize)
.get_mut( {
usize::try_from(index) let _old_item = replace(current_item, item);
.expect("Shouldn't be negative"), Ok(())
) } else {
.map_or_else( Err(miette!(
|| { "Item doesn't exist in model. Id was {}",
Err(miette!( index
"Item doesn't exist in model. Id was {index}" ))
)) }
},
|current_item| {
let _old_item = replace(current_item, item);
Ok(())
},
)
} }
pub fn remove_item(&mut self, index: i32) -> Result<()> { pub fn remove_item(&mut self, index: i32) -> Result<()> {
self.items.remove( self.items.remove(index as usize);
usize::try_from(index).expect("Shouldn't be negative"),
);
Ok(()) Ok(())
} }
#[must_use]
pub fn get_item(&self, index: i32) -> Option<&T> { pub fn get_item(&self, index: i32) -> Option<&T> {
self.items.get( self.items.get(index as usize)
usize::try_from(index).expect("shouldn't be negative"),
)
} }
pub fn find<P>(&self, f: P) -> Option<&T> pub fn find<P>(&self, f: P) -> Option<&T>
@ -124,10 +57,7 @@ impl<T> Model<T> {
} }
pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> { pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> {
self.items.insert( self.items.insert(index as usize, item);
usize::try_from(index).expect("Shouldn't be negative"),
item,
);
Ok(()) Ok(())
} }
} }
@ -144,13 +74,11 @@ impl<T> Model<T> {
// } // }
pub async fn get_db() -> SqliteConnection { pub async fn get_db() -> SqliteConnection {
let mut data = dirs::data_local_dir() let mut data = dirs::data_local_dir().unwrap();
.expect("Should be able to find a data dir");
data.push("lumina"); data.push("lumina");
let _ = fs::create_dir_all(&data);
data.push("library-db.sqlite3"); data.push("library-db.sqlite3");
let mut db_url = String::from("sqlite://"); let mut db_url = String::from("sqlite://");
db_url.push_str(data.to_str().expect("Should be there")); db_url.push_str(data.to_str().unwrap());
SqliteConnection::connect(&db_url).await.expect("problems") SqliteConnection::connect(&db_url).await.expect("problems")
} }

View file

@ -1,14 +1,12 @@
use cosmic::widget::image::Handle;
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result}; use miette::{IntoDiagnostic, Result};
use mupdf::{Colorspace, Document, Matrix};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
Row, Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection, pool::PoolConnection, prelude::FromRow, query, sqlite::SqliteRow,
prelude::FromRow, query, sqlite::SqliteRow, Row, Sqlite, SqliteConnection, SqlitePool,
}; };
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use tracing::{debug, error}; use tracing::error;
use crate::{Background, Slide, SlideBuilder, TextAlignment}; use crate::{Background, Slide, SlideBuilder, TextAlignment};
@ -24,15 +22,14 @@ use super::{
)] )]
pub enum PresKind { pub enum PresKind {
Html, Html,
Pdf {
starting_index: i32,
ending_index: i32,
},
#[default] #[default]
Pdf,
Generic, Generic,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub struct Presentation { pub struct Presentation {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
@ -40,69 +37,8 @@ pub struct Presentation {
pub kind: PresKind, pub kind: PresKind,
} }
impl Eq for Presentation {}
impl PartialEq for Presentation {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.title == other.title
&& self.path == other.path
&& self.kind == other.kind
}
}
impl From<PathBuf> for Presentation {
fn from(value: PathBuf) -> Self {
let title = value
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
let kind = match value
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
{
"pdf" => Document::open(&value.as_path()).map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|count| PresKind::Pdf {
starting_index: 0,
ending_index: count - 1,
},
)
},
),
"html" => PresKind::Html,
_ => PresKind::Generic,
};
Self {
id: 0,
title,
path: value.canonicalize().unwrap_or(value),
kind,
}
}
}
impl From<&Path> for Presentation {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl From<&Presentation> for Value { impl From<&Presentation> for Value {
fn from(_value: &Presentation) -> Self { fn from(value: &Presentation) -> Self {
Self::List(vec![Self::Symbol(Symbol("presentation".into()))]) Self::List(vec![Self::Symbol(Symbol("presentation".into()))])
} }
} }
@ -126,10 +62,10 @@ impl Content for Presentation {
fn subtext(&self) -> String { fn subtext(&self) -> String {
if self.path.exists() { if self.path.exists() {
self.path.file_name().map_or_else( self.path
|| "Missing presentation".into(), .file_name()
|f| f.to_string_lossy().to_string(), .map(|f| f.to_string_lossy().to_string())
) .unwrap_or("Missing presentation".into())
} else { } else {
"Missing presentation".into() "Missing presentation".into()
} }
@ -142,7 +78,6 @@ impl From<Value> for Presentation {
} }
} }
#[allow(clippy::option_if_let_else)]
impl From<&Value> for Presentation { impl From<&Value> for Presentation {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
@ -182,78 +117,22 @@ impl ServiceTrait for Presentation {
} }
fn to_slides(&self) -> Result<Vec<Slide>> { fn to_slides(&self) -> Result<Vec<Slide>> {
debug!(?self); let slide = SlideBuilder::new()
let PresKind::Pdf { .background(
starting_index, Background::try_from(self.path.clone())
ending_index, .into_diagnostic()?,
} = self.kind )
else { .text("")
return Err(miette::miette!( .audio("")
"This is not a pdf presentation" .font("")
)); .font_size(50)
}; .text_alignment(TextAlignment::MiddleCenter)
let background = Background::try_from(self.path.clone()) .video_loop(false)
.into_diagnostic()?; .video_start_time(0.0)
debug!(?background); .video_end_time(0.0)
let document = Document::open(background.path.as_path()) .build()?;
.into_diagnostic()?;
debug!(?document);
let pages = document.pages().into_diagnostic()?;
debug!(?pages);
let pages: Vec<Handle> = pages
.enumerate()
.filter_map(|(index, page)| {
let index = i32::try_from(index)
.expect("Shouldn't be that high");
if index < starting_index || index > ending_index { Ok(vec![slide])
return None;
}
let page = page.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let Ok(pixmap) = page
.to_pixmap(&matrix, &colorspace, true, true)
.into_diagnostic()
else {
error!("Can't turn this page into pixmap");
return None;
};
debug!(?pixmap);
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
})
.collect();
let mut slides: Vec<Slide> = vec![];
for (index, page) in pages.into_iter().enumerate() {
let slide = SlideBuilder::new()
.background(
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("")
.audio("")
.font("")
.font_size(50)
.text_alignment(TextAlignment::MiddleCenter)
.video_loop(false)
.video_start_time(0.0)
.video_end_time(0.0)
.pdf_index(
u32::try_from(index)
.expect("Shouldn't get that high"),
)
.pdf_page(page)
.build()?;
slides.push(slide);
}
debug!(?slides);
Ok(slides)
} }
fn box_clone(&self) -> Box<dyn ServiceTrait> { fn box_clone(&self) -> Box<dyn ServiceTrait> {
@ -262,16 +141,14 @@ impl ServiceTrait for Presentation {
} }
impl Presentation { impl Presentation {
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
title: String::new(), title: "".to_string(),
..Default::default() ..Default::default()
} }
} }
#[must_use] pub fn get_kind(&self) -> &PresKind {
pub const fn get_kind(&self) -> &PresKind {
&self.kind &self.kind
} }
} }
@ -288,10 +165,7 @@ impl FromRow<'_, SqliteRow> for Presentation {
kind: if row.try_get(3)? { kind: if row.try_get(3)? {
PresKind::Html PresKind::Html
} else { } else {
PresKind::Pdf { PresKind::Pdf
starting_index: row.try_get(4)?,
ending_index: row.try_get(5)?,
}
}, },
}) })
} }
@ -311,60 +185,21 @@ impl Model<Presentation> {
pub async fn load_from_db(&mut self, db: &mut SqliteConnection) { pub async fn load_from_db(&mut self, db: &mut SqliteConnection) {
let result = query!( let result = query!(
r#"SELECT id as "id: i32", title, file_path as "path", html, starting_index, ending_index from presentations"# r#"SELECT id as "id: i32", title, file_path as "path", html from presentations"#
) )
.fetch_all(db) .fetch_all(db)
.await; .await;
match result { match result {
Ok(v) => { Ok(v) => {
for presentation in v { for presentation in v.into_iter() {
let _ = self.add_item(Presentation { let _ = self.add_item(Presentation {
id: presentation.id, id: presentation.id,
title: presentation.title, title: presentation.title,
path: presentation.path.clone().into(), path: presentation.path.into(),
kind: if presentation.html { kind: if presentation.html {
PresKind::Html PresKind::Html
} else if let (
Some(starting_index),
Some(ending_index),
) = (
presentation.starting_index,
presentation.ending_index,
) {
PresKind::Pdf {
starting_index: i32::try_from(
starting_index,
)
.expect("Shouldn't get that high"),
ending_index: i32::try_from(
ending_index,
)
.expect("Shouldn't get that high"),
}
} else { } else {
let path = PresKind::Pdf
PathBuf::from(presentation.path);
Document::open(path.as_path()).map_or(
PresKind::Generic,
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|count| {
let ending_index =
count - 1;
PresKind::Pdf {
starting_index: 0,
ending_index,
}
},
)
},
)
}, },
}); });
} }
@ -376,51 +211,6 @@ impl Model<Presentation> {
} }
} }
pub async fn remove_from_db(
db: PoolConnection<Sqlite>,
id: i32,
) -> Result<()> {
query!("DELETE FROM presentations WHERE id = $1", id)
.execute(&mut db.detach())
.await
.into_diagnostic()
.map(|_| ())
}
pub async fn add_presentation_to_db(
presentation: Presentation,
db: PoolConnection<Sqlite>,
) -> Result<()> {
let path = presentation
.path
.to_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
let html = presentation.kind == PresKind::Html;
let mut db = db.detach();
let (starting_index, ending_index) = if let PresKind::Pdf {
starting_index,
ending_index,
} = presentation.kind
{
(starting_index, ending_index)
} else {
(0, 0)
};
query!(
r#"INSERT INTO presentations (title, file_path, html, starting_index, ending_index) VALUES ($1, $2, $3, $4, $5)"#,
presentation.title,
path,
html,
starting_index,
ending_index
)
.execute(&mut db)
.await
.into_diagnostic()?;
Ok(())
}
pub async fn update_presentation_in_db( pub async fn update_presentation_in_db(
presentation: Presentation, presentation: Presentation,
db: PoolConnection<Sqlite>, db: PoolConnection<Sqlite>,
@ -428,89 +218,21 @@ pub async fn update_presentation_in_db(
let path = presentation let path = presentation
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(|s| s.to_string())
.unwrap_or_default(); .unwrap_or_default();
let html = presentation.kind == PresKind::Html; let html = presentation.kind == PresKind::Html;
let mut db = db.detach(); query!(
let (starting_index, ending_index) = if let PresKind::Pdf { r#"UPDATE presentations SET title = $2, file_path = $3, html = $4 WHERE id = $1"#,
starting_index: s_index,
ending_index: e_index,
} =
presentation.get_kind()
{
(*s_index, *e_index)
} else {
(0, 0)
};
debug!(starting_index, ending_index);
let id = presentation.id;
if let Err(e) =
query!("SELECT id FROM presentations where id = $1", id)
.fetch_one(&mut db)
.await
{
if let Ok(ids) = query!("SELECT id FROM presentations")
.fetch_all(&mut db)
.await
{
let Some(mut max) = ids.iter().map(|r| r.id).max() else {
return Err(miette::miette!("cannot find max id"));
};
debug!(
?e,
"Presentation not found adding a new presentation"
);
max += 1;
let result = query!(
r#"INSERT into presentations VALUES($1, $2, $3, $4, $5, $6)"#,
max,
presentation.title,
path,
html,
starting_index,
ending_index,
)
.execute(&mut db)
.await
.into_diagnostic();
return match result {
Ok(_) => {
debug!("presentation should have been added");
Ok(())
}
Err(e) => {
error! {?e};
Err(e)
}
};
}
return Err(miette::miette!("cannot find ids"));
}
debug!(?presentation, "should be been updated");
let result = query!(
r#"UPDATE presentations SET title = $2, file_path = $3, html = $4, starting_index = $5, ending_index = $6 WHERE id = $1"#,
presentation.id, presentation.id,
presentation.title, presentation.title,
path, path,
html, html
starting_index,
ending_index
) )
.execute(&mut db) .execute(&mut db.detach())
.await.into_diagnostic(); .await
.into_diagnostic()?;
match result { Ok(())
Ok(_) => {
debug!("should have been updated");
Ok(())
}
Err(e) => {
error! {?e};
Err(e)
}
}
} }
pub async fn get_presentation_from_db( pub async fn get_presentation_from_db(
@ -528,25 +250,19 @@ mod test {
fn test_presentation() -> Presentation { fn test_presentation() -> Presentation {
Presentation { Presentation {
id: 4, id: 54,
title: "mzt52.pdf".into(), title: "20240327T133649--12-isaiah-and-jesus__lesson_project_tfc".into(),
path: PathBuf::from("/home/chris/docs/mzt52.pdf"), path: PathBuf::from(
kind: PresKind::Pdf { "file:///home/chris/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html",
starting_index: 0, ),
ending_index: 67, kind: PresKind::Html,
},
} }
} }
#[test] #[test]
pub fn test_pres() { pub fn test_pres() {
let pres = Presentation::new(); let pres = Presentation::new();
assert_eq!(pres.get_kind(), &PresKind::Generic) assert_eq!(pres.get_kind(), &PresKind::Pdf)
}
async fn add_db() -> Result<SqlitePool> {
let mut db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
} }
#[tokio::test] #[tokio::test]
@ -555,10 +271,10 @@ mod test {
items: vec![], items: vec![],
kind: LibraryKind::Presentation, kind: LibraryKind::Presentation,
}; };
let mut db = add_db().await.unwrap().acquire().await.unwrap(); let mut db = crate::core::model::get_db().await;
presentation_model.load_from_db(&mut db).await; presentation_model.load_from_db(&mut db).await;
if let Some(presentation) = if let Some(presentation) =
presentation_model.find(|p| p.id == 4) presentation_model.find(|p| p.id == 54)
{ {
let test_presentation = test_presentation(); let test_presentation = test_presentation();
assert_eq!(&test_presentation, presentation); assert_eq!(&test_presentation, presentation);

View file

@ -1,30 +1,29 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::ops::Deref; use std::ops::Deref;
use std::path::PathBuf; use std::sync::Arc;
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result, miette}; // use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use serde::{Deserialize, Serialize}; use miette::Result;
use tracing::{debug, error}; use tracing::{debug, error};
use crate::Slide; use crate::Slide;
use super::images::Image; use super::images::Image;
use super::presentations::Presentation; use super::presentations::Presentation;
use super::songs::{Song, lisp_to_song}; use super::songs::{lisp_to_song, Song};
use super::videos::Video; use super::videos::Video;
use super::kinds::ServiceItemKind; use super::kinds::ServiceItemKind;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Clone)]
pub struct ServiceItem { pub struct ServiceItem {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
pub database_id: i32, pub database_id: i32,
pub kind: ServiceItemKind, pub kind: ServiceItemKind,
pub slides: Vec<Slide>, pub slides: Arc<[Slide]>,
// pub item: Box<dyn ServiceTrait>, // pub item: Box<dyn ServiceTrait>,
} }
@ -32,7 +31,7 @@ impl Eq for ServiceItem {}
impl PartialOrd for ServiceItem { impl PartialOrd for ServiceItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other)) self.id.partial_cmp(&other.id)
} }
} }
@ -48,74 +47,49 @@ impl TryFrom<(Vec<u8>, String)> for ServiceItem {
fn try_from( fn try_from(
value: (Vec<u8>, String), value: (Vec<u8>, String),
) -> std::result::Result<Self, Self::Error> { ) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value; debug!(?value);
debug!(?mime); let val = Value::from(
ron::de::from_bytes(&data).into_diagnostic() String::from_utf8(value.0)
.expect("Value couldn't be made"),
);
Ok(Self::from(&val))
} }
} }
impl AllowedMimeTypes for ServiceItem { // impl AllowedMimeTypes for ServiceItem {
fn allowed() -> Cow<'static, [String]> { // fn allowed() -> Cow<'static, [String]> {
Cow::from(vec![ // Cow::from(vec!["application/service-item".to_string()])
"application/service-item".to_string(), // }
"text/uri-list".to_string(), // }
"x-special/gnome-copied-files".to_string(),
])
}
}
impl AsMimeTypes for ServiceItem { // impl AsMimeTypes for ServiceItem {
fn available(&self) -> Cow<'static, [String]> { // fn available(&self) -> Cow<'static, [String]> {
debug!(?self); // debug!(?self);
Cow::from(vec!["application/service-item".to_string()]) // Cow::from(vec!["application/service-item".to_string()])
} // }
fn as_bytes( // fn as_bytes(
&self, // &self,
mime_type: &str, // mime_type: &str,
) -> Option<std::borrow::Cow<'static, [u8]>> { // ) -> Option<std::borrow::Cow<'static, [u8]>> {
debug!(?self); // debug!(?self);
debug!(mime_type); // debug!(mime_type);
let ron = ron::ser::to_string(self).ok()?; // let val = Value::from(self);
debug!(ron); // let val = String::from(val);
Some(Cow::from(ron.into_bytes())) // Some(Cow::from(val.into_bytes()))
} // }
} // }
impl TryFrom<PathBuf> for ServiceItem {
type Error = miette::Error;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext {
"png" | "jpg" | "jpeg" => {
Ok(Self::from(&Image::from(path)))
}
"mp4" | "mkv" | "webm" => {
Ok(Self::from(&Video::from(path)))
}
_ => Err(miette!("Unkown service item")),
}
}
}
impl From<&ServiceItem> for Value { impl From<&ServiceItem> for Value {
fn from(value: &ServiceItem) -> Self { fn from(value: &ServiceItem) -> Self {
match &value.kind { match &value.kind {
ServiceItemKind::Song(song) => Self::from(song), ServiceItemKind::Song(song) => Value::from(song),
ServiceItemKind::Video(video) => Self::from(video), ServiceItemKind::Video(video) => Value::from(video),
ServiceItemKind::Image(image) => Self::from(image), ServiceItemKind::Image(image) => Value::from(image),
ServiceItemKind::Presentation(presentation) => { ServiceItemKind::Presentation(presentation) => {
Self::from(presentation) Value::from(presentation)
} }
ServiceItemKind::Content(slide) => Self::from(slide), ServiceItemKind::Content(slide) => Value::from(slide),
} }
} }
} }
@ -147,7 +121,7 @@ impl Default for ServiceItem {
title: String::default(), title: String::default(),
database_id: 0, database_id: 0,
kind: ServiceItemKind::Content(Slide::default()), kind: ServiceItemKind::Content(Slide::default()),
slides: vec![], slides: Arc::new([]),
// item: Box::new(Image::default()), // item: Box::new(Image::default()),
} }
} }
@ -159,8 +133,6 @@ impl From<Value> for ServiceItem {
} }
} }
#[allow(clippy::option_if_let_else)]
#[allow(clippy::match_like_matches_macro)]
impl From<&Value> for ServiceItem { impl From<&Value> for ServiceItem {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
@ -177,7 +149,7 @@ impl From<&Value> for ServiceItem {
_ => false, _ => false,
}) })
.map_or_else(|| 1, |pos| pos + 1); .map_or_else(|| 1, |pos| pos + 1);
if let Some(_content) = if let Some(content) =
list.iter().position(|v| match v { list.iter().position(|v| match v {
Value::List(list) Value::List(list)
if list.iter().next() if list.iter().next()
@ -199,13 +171,13 @@ impl From<&Value> for ServiceItem {
kind: ServiceItemKind::Content( kind: ServiceItemKind::Content(
slide.clone(), slide.clone(),
), ),
slides: vec![slide], slides: Arc::new([slide]),
} }
} else if let Some(background) = } else if let Some(background) =
list.get(background_pos) list.get(background_pos)
{ {
if let Value::List(item) = background { match background {
match &item[0] { Value::List(item) => match &item[0] {
Value::Symbol(Symbol(s)) Value::Symbol(Symbol(s))
if s == "image" => if s == "image" =>
{ {
@ -228,29 +200,30 @@ impl From<&Value> for ServiceItem {
)) ))
} }
_ => todo!(), _ => todo!(),
},
_ => {
error!(
"There is no background here: {:?}",
background
);
ServiceItem::default()
} }
} else {
error!(
"There is no background here: {:?}",
background
);
Self::default()
} }
} else { } else {
error!( error!(
"There is no background here: {:?}", "There is no background here: {:?}",
background_pos background_pos
); );
Self::default() ServiceItem::default()
} }
} }
Value::Symbol(Symbol(s)) if s == "song" => { Value::Symbol(Symbol(s)) if s == "song" => {
let song = lisp_to_song(list.clone()); let song = lisp_to_song(list.clone());
Self::from(&song) Self::from(&song)
} }
_ => Self::default(), _ => ServiceItem::default(),
}, },
_ => Self::default(), _ => ServiceItem::default(),
} }
} }
} }
@ -284,96 +257,100 @@ impl From<Vec<ServiceItem>> for Service {
impl From<&Song> for ServiceItem { impl From<&Song> for ServiceItem {
fn from(song: &Song) -> Self { fn from(song: &Song) -> Self {
song.to_slides().map_or_else( if let Ok(slides) = song.to_slides() {
|_| Self { Self {
kind: ServiceItemKind::Song(song.clone()),
database_id: song.id,
title: song.title.clone(),
slides: slides.into(),
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Song(song.clone()), kind: ServiceItemKind::Song(song.clone()),
database_id: song.id, database_id: song.id,
title: song.title.clone(), title: song.title.clone(),
..Default::default() ..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Song(song.clone()),
database_id: song.id,
title: song.title.clone(),
slides,
..Default::default()
},
)
}
}
impl From<&Video> for ServiceItem {
fn from(video: &Video) -> Self {
video.to_slides().map_or_else(
|_| Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
slides,
..Default::default()
},
)
}
}
impl From<&Image> for ServiceItem {
fn from(image: &Image) -> Self {
image.to_slides().map_or_else(
|_| Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
slides,
..Default::default()
},
)
}
}
impl From<&Presentation> for ServiceItem {
fn from(presentation: &Presentation) -> Self {
match presentation.to_slides() {
Ok(slides) => Self {
kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id,
title: presentation.title.clone(),
slides,
..Default::default()
},
Err(e) => {
error!(?e);
Self {
kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id,
title: presentation.title.clone(),
..Default::default()
}
} }
} }
} }
} }
#[allow(unused)] impl From<&Video> for ServiceItem {
fn from(video: &Video) -> Self {
if let Ok(slides) = video.to_slides() {
Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
slides: slides.into(),
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
..Default::default()
}
}
}
}
impl From<&Image> for ServiceItem {
fn from(image: &Image) -> Self {
if let Ok(slides) = image.to_slides() {
Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
slides: slides.into(),
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
..Default::default()
}
}
}
}
impl From<&Presentation> for ServiceItem {
fn from(presentation: &Presentation) -> Self {
if let Ok(slides) = presentation.to_slides() {
Self {
kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id,
title: presentation.title.clone(),
slides: slides.into(),
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id,
title: presentation.title.clone(),
..Default::default()
}
}
}
}
impl Service { impl Service {
fn add_item(&mut self, item: impl Into<ServiceItem>) { fn add_item(
&mut self,
item: impl Into<ServiceItem>,
) -> Result<()> {
let service_item: ServiceItem = item.into(); let service_item: ServiceItem = item.into();
self.items.push(service_item); self.items.push(service_item);
Ok(())
} }
pub fn to_slides(&self) -> Result<Vec<Slide>> { pub fn to_slides(&self) -> Result<Vec<Slide>> {
@ -389,7 +366,7 @@ impl Service {
.collect::<Vec<Slide>>(); .collect::<Vec<Slide>>();
let mut final_slides = vec![]; let mut final_slides = vec![];
for (index, mut slide) in slides.into_iter().enumerate() { for (index, mut slide) in slides.into_iter().enumerate() {
slide.set_index(i32::try_from(index).into_diagnostic()?); slide.set_index(index as i32);
final_slides.push(slide); final_slides.push(slide);
} }
Ok(final_slides) Ok(final_slides)
@ -452,15 +429,19 @@ mod test {
let pres = test_presentation(); let pres = test_presentation();
let pres_item = ServiceItem::from(&pres); let pres_item = ServiceItem::from(&pres);
let mut service_model = Service::default(); let mut service_model = Service::default();
service_model.add_item(&song); match service_model.add_item(&song) {
assert_eq!( Ok(_) => {
ServiceItemKind::Song(song), assert_eq!(
service_model.items[0].kind ServiceItemKind::Song(song),
); service_model.items[0].kind
assert_eq!( );
ServiceItemKind::Presentation(pres), assert_eq!(
pres_item.kind ServiceItemKind::Presentation(pres),
); pres_item.kind
assert_eq!(service_item, service_model.items[0]); );
assert_eq!(service_item, service_model.items[0]);
}
Err(e) => panic!("Problem adding item: {:?}", e),
}
} }
} }

View file

@ -1,70 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
cosmic_config::{
self, CosmicConfigEntry,
cosmic_config_derive::CosmicConfigEntry,
},
theme,
};
use serde::{Deserialize, Serialize};
use std::{collections::VecDeque, path::PathBuf};
pub const SETTINGS_VERSION: u64 = 1;
#[derive(
Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
)]
pub enum AppTheme {
Dark,
Light,
System,
}
impl AppTheme {
pub fn theme(&self) -> theme::Theme {
match self {
Self::Dark => theme::Theme::dark(),
Self::Light => theme::Theme::light(),
Self::System => theme::system_preference(),
}
}
}
#[derive(
Clone,
CosmicConfigEntry,
Debug,
Deserialize,
Eq,
PartialEq,
Serialize,
)]
#[serde(default)]
pub struct Settings {
pub app_theme: AppTheme,
pub obs_url: Option<url::Url>,
}
impl Default for Settings {
fn default() -> Self {
Self {
app_theme: AppTheme::System,
obs_url: None,
}
}
}
#[derive(
Clone,
CosmicConfigEntry,
Debug,
Deserialize,
Eq,
PartialEq,
Serialize,
Default,
)]
pub struct PersistentState {
pub recent_files: VecDeque<PathBuf>,
}

View file

@ -1,9 +1,7 @@
#![allow(clippy::similar_names, unused)] // use iced::dialog::ashpd::url::Url;
use cosmic::widget::image::Handle;
// use cosmic::dialog::ashpd::url::Url;
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use iced_video_player::Video; use iced_video_player::Video;
use miette::{Result, miette}; use miette::{miette, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
fmt::Display, fmt::Display,
@ -11,44 +9,10 @@ use std::{
}; };
use tracing::error; use tracing::error;
use crate::ui::text_svg::{Color, Font, Shadow, Stroke, TextSvg}; use crate::ui::text_svg::{self, TextSvg};
use super::songs::Song; use super::songs::Song;
#[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Slide {
id: i32,
pub(crate) background: Background,
text: String,
font: Option<Font>,
font_size: i32,
stroke: Option<Stroke>,
shadow: Option<Shadow>,
text_alignment: TextAlignment,
text_color: Option<Color>,
audio: Option<PathBuf>,
video_loop: bool,
video_start_time: f32,
video_end_time: f32,
pdf_index: u32,
pub text_svg: Option<TextSvg>,
#[serde(skip)]
pdf_page: Option<Handle>,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum BackgroundKind {
#[default]
Image,
Video,
Pdf,
Html,
}
#[derive( #[derive(
Clone, Clone,
Copy, Copy,
@ -103,9 +67,9 @@ impl TryFrom<&Background> for Video {
fn try_from( fn try_from(
value: &Background, value: &Background,
) -> std::result::Result<Self, Self::Error> { ) -> std::result::Result<Self, Self::Error> {
Self::new( Video::new(
&url::Url::from_file_path(value.path.clone()) &url::Url::from_file_path(value.path.clone())
.map_err(|()| ParseError::BackgroundNotVideo)?, .map_err(|_| ParseError::BackgroundNotVideo)?,
) )
.map_err(|_| ParseError::BackgroundNotVideo) .map_err(|_| ParseError::BackgroundNotVideo)
} }
@ -117,9 +81,9 @@ impl TryFrom<Background> for Video {
fn try_from( fn try_from(
value: Background, value: Background,
) -> std::result::Result<Self, Self::Error> { ) -> std::result::Result<Self, Self::Error> {
Self::new( Video::new(
&url::Url::from_file_path(value.path) &url::Url::from_file_path(value.path)
.map_err(|()| ParseError::BackgroundNotVideo)?, .map_err(|_| ParseError::BackgroundNotVideo)?,
) )
.map_err(|_| ParseError::BackgroundNotVideo) .map_err(|_| ParseError::BackgroundNotVideo)
} }
@ -128,7 +92,7 @@ impl TryFrom<Background> for Video {
impl TryFrom<String> for Background { impl TryFrom<String> for Background {
type Error = ParseError; type Error = ParseError;
fn try_from(value: String) -> Result<Self, Self::Error> { fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str()) Background::try_from(value.as_str())
} }
} }
@ -136,17 +100,14 @@ impl TryFrom<PathBuf> for Background {
type Error = ParseError; type Error = ParseError;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> { fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
let path = if path.starts_with("~") { let path = if path.starts_with("~") {
let path = path let path = path.to_str().unwrap().to_string();
.to_str()
.expect("Should have a string")
.to_string();
let path = path.trim_start_matches("file://"); let path = path.trim_start_matches("file://");
let home = dirs::home_dir() let home = dirs::home_dir()
.expect("We should have a home directory") .unwrap()
.to_str() .to_str()
.expect("Gah") .unwrap()
.to_string(); .to_string();
let path = path.replace('~', &home); let path = path.replace("~", &home);
PathBuf::from(path) PathBuf::from(path)
} else { } else {
path path
@ -160,27 +121,21 @@ impl TryFrom<PathBuf> for Background {
.to_str() .to_str()
.unwrap_or_default(); .unwrap_or_default();
match extension { match extension {
"jpeg" | "jpg" | "png" | "webp" => Ok(Self { "jpeg" | "jpg" | "png" | "webp" | "html" => {
path: value, Ok(Self {
kind: BackgroundKind::Image, path: value,
}), kind: BackgroundKind::Image,
})
}
"mp4" | "mkv" | "webm" => Ok(Self { "mp4" | "mkv" | "webm" => Ok(Self {
path: value, path: value,
kind: BackgroundKind::Video, kind: BackgroundKind::Video,
}), }),
"pdf" => Ok(Self {
path: value,
kind: BackgroundKind::Pdf,
}),
"html" => Ok(Self {
path: value,
kind: BackgroundKind::Html,
}),
_ => Err(ParseError::NonBackgroundFile), _ => Err(ParseError::NonBackgroundFile),
} }
} }
Err(_e) => { Err(e) => {
// error!("Couldn't canonicalize: {e} {:?}", path); error!("Couldn't canonicalize: {e} {:?}", path);
Err(ParseError::CannotCanonicalize) Err(ParseError::CannotCanonicalize)
} }
} }
@ -191,19 +146,17 @@ impl TryFrom<&str> for Background {
type Error = ParseError; type Error = ParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> { fn try_from(value: &str) -> Result<Self, Self::Error> {
let value = value.trim_start_matches("file://"); let value = value.trim_start_matches("file://");
if value.starts_with('~') { if value.starts_with("~") {
dirs::home_dir().map_or_else( if let Some(home) = dirs::home_dir() {
|| Self::try_from(PathBuf::from(value)), if let Some(home) = home.to_str() {
|home| { let value = value.replace("~", home);
home.to_str().map_or_else( Self::try_from(PathBuf::from(value))
|| Self::try_from(PathBuf::from(value)), } else {
|home| { Self::try_from(PathBuf::from(value))
let value = value.replace('~', home); }
Self::try_from(PathBuf::from(value)) } else {
}, Self::try_from(PathBuf::from(value))
) }
},
)
} else if value.starts_with("./") { } else if value.starts_with("./") {
Err(ParseError::CannotCanonicalize) Err(ParseError::CannotCanonicalize)
} else { } else {
@ -250,140 +203,97 @@ impl Display for ParseError {
} }
} }
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum BackgroundKind {
#[default]
Image,
Video,
}
impl From<String> for BackgroundKind { impl From<String> for BackgroundKind {
fn from(value: String) -> Self { fn from(value: String) -> Self {
if value == "image" { if value == "image" {
Self::Image BackgroundKind::Image
} else { } else {
Self::Video BackgroundKind::Video
} }
} }
} }
#[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Slide {
id: i32,
background: Background,
text: String,
font: String,
font_size: i32,
text_alignment: TextAlignment,
audio: Option<PathBuf>,
video_loop: bool,
video_start_time: f32,
video_end_time: f32,
#[serde(skip)]
pub text_svg: TextSvg,
}
impl From<&Slide> for Value { impl From<&Slide> for Value {
fn from(_value: &Slide) -> Self { fn from(value: &Slide) -> Self {
Self::List(vec![Self::Symbol(Symbol("slide".into()))]) Self::List(vec![Self::Symbol(Symbol("slide".into()))])
} }
} }
impl Slide { impl Slide {
#[must_use]
pub fn set_text(mut self, text: impl AsRef<str>) -> Self { pub fn set_text(mut self, text: impl AsRef<str>) -> Self {
self.text = text.as_ref().into(); self.text = text.as_ref().into();
self self
} }
#[must_use]
pub fn with_text_svg(mut self, text_svg: TextSvg) -> Self {
self.text_svg = Some(text_svg);
self
}
#[must_use]
pub fn set_font(mut self, font: impl AsRef<str>) -> Self { pub fn set_font(mut self, font: impl AsRef<str>) -> Self {
self.font = Some(font.as_ref().into()); self.font = font.as_ref().into();
self self
} }
#[must_use] pub fn set_font_size(mut self, font_size: i32) -> Self {
pub const fn set_font_size(mut self, font_size: i32) -> Self {
self.font_size = font_size; self.font_size = font_size;
self self
} }
#[must_use]
pub fn set_audio(mut self, audio: Option<PathBuf>) -> Self { pub fn set_audio(mut self, audio: Option<PathBuf>) -> Self {
self.audio = audio; self.audio = audio;
self self
} }
#[must_use] pub fn background(&self) -> &Background {
pub const fn set_pdf_index(mut self, pdf_index: u32) -> Self {
self.pdf_index = pdf_index;
self
}
#[must_use]
pub const fn set_stroke(mut self, stroke: Stroke) -> Self {
self.stroke = Some(stroke);
self
}
#[must_use]
pub const fn set_shadow(mut self, shadow: Shadow) -> Self {
self.shadow = Some(shadow);
self
}
#[must_use]
pub const fn set_text_color(mut self, color: Color) -> Self {
self.text_color = Some(color);
self
}
#[must_use]
pub const fn background(&self) -> &Background {
&self.background &self.background
} }
#[must_use]
pub fn text(&self) -> String { pub fn text(&self) -> String {
self.text.clone() self.text.clone()
} }
#[must_use] pub fn font_size(&self) -> i32 {
pub const fn text_alignment(&self) -> TextAlignment {
self.text_alignment
}
#[must_use]
pub const fn font_size(&self) -> i32 {
self.font_size self.font_size
} }
#[must_use] pub fn font(&self) -> String {
pub fn font(&self) -> Option<Font> {
self.font.clone() self.font.clone()
} }
#[must_use] pub fn video_loop(&self) -> bool {
pub const fn video_loop(&self) -> bool {
self.video_loop self.video_loop
} }
#[must_use]
pub fn audio(&self) -> Option<PathBuf> { pub fn audio(&self) -> Option<PathBuf> {
self.audio.clone() self.audio.clone()
} }
#[must_use]
pub fn pdf_page(&self) -> Option<Handle> {
self.pdf_page.clone()
}
#[must_use]
pub fn text_color(&self) -> Option<Color> {
self.text_color.clone()
}
#[must_use]
pub fn stroke(&self) -> Option<Stroke> {
self.stroke.clone()
}
#[must_use]
pub fn shadow(&self) -> Option<Shadow> {
self.shadow.clone()
}
#[must_use]
pub const fn pdf_index(&self) -> u32 {
self.pdf_index
}
pub fn song_slides(song: &Song) -> Result<Vec<Self>> { pub fn song_slides(song: &Song) -> Result<Vec<Self>> {
let lyrics = song.get_lyrics()?; let lyrics = song.get_lyrics()?;
let slides: Vec<Self> = lyrics let slides: Vec<Slide> = lyrics
.iter() .iter()
.map(|l| { .map(|l| {
let song = song.clone(); let song = song.clone();
@ -407,10 +317,14 @@ impl Slide {
Ok(slides) Ok(slides)
} }
pub(crate) const fn set_index(&mut self, index: i32) { pub(crate) fn set_index(&mut self, index: i32) {
self.id = index; self.id = index;
} }
pub(crate) fn text_to_image(&self) {
todo!()
}
// pub fn slides_from_item(item: &ServiceItem) -> Result<Vec<Self>> { // pub fn slides_from_item(item: &ServiceItem) -> Result<Vec<Self>> {
// todo!() // todo!()
// } // }
@ -426,13 +340,12 @@ impl From<&Value> for Slide {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
Value::List(list) => lisp_to_slide(list), Value::List(list) => lisp_to_slide(list),
_ => Self::default(), _ => Slide::default(),
} }
} }
} }
#[allow(clippy::option_if_let_else)] fn lisp_to_slide(lisp: &Vec<Value>) -> Slide {
fn lisp_to_slide(lisp: &[Value]) -> Slide {
const DEFAULT_BACKGROUND_LOCATION: usize = 1; const DEFAULT_BACKGROUND_LOCATION: usize = 1;
const DEFAULT_TEXT_LOCATION: usize = 0; const DEFAULT_TEXT_LOCATION: usize = 0;
@ -450,7 +363,7 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
slide = slide.background(lisp_to_background(background)); slide = slide.background(lisp_to_background(background));
} else { } else {
slide = slide.background(Background::default()); slide = slide.background(Background::default());
} };
let text_position = lisp.iter().position(|v| match v { let text_position = lisp.iter().position(|v| match v {
Value::List(vec) => { Value::List(vec) => {
@ -496,7 +409,6 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
} }
} }
#[allow(clippy::option_if_let_else)]
fn lisp_to_font_size(lisp: &Value) -> i32 { fn lisp_to_font_size(lisp: &Value) -> i32 {
match lisp { match lisp {
Value::List(list) => { Value::List(list) => {
@ -529,11 +441,10 @@ fn lisp_to_text(lisp: &Value) -> impl Into<String> {
// Need to return a Result here so that we can propogate // Need to return a Result here so that we can propogate
// errors and then handle them appropriately // errors and then handle them appropriately
#[allow(clippy::option_if_let_else)]
pub fn lisp_to_background(lisp: &Value) -> Background { pub fn lisp_to_background(lisp: &Value) -> Background {
match lisp { match lisp {
Value::List(list) => { Value::List(list) => {
let _kind = list[0].clone(); let kind = list[0].clone();
if let Some(source) = list.iter().position(|v| { if let Some(source) = list.iter().position(|v| {
v == &Value::Keyword(Keyword::from("source")) v == &Value::Keyword(Keyword::from("source"))
}) { }) {
@ -588,19 +499,13 @@ pub fn lisp_to_background(lisp: &Value) -> Background {
pub struct SlideBuilder { pub struct SlideBuilder {
background: Option<Background>, background: Option<Background>,
text: Option<String>, text: Option<String>,
font: Option<Font>, font: Option<String>,
font_size: Option<i32>, font_size: Option<i32>,
audio: Option<PathBuf>, audio: Option<PathBuf>,
stroke: Option<Stroke>,
shadow: Option<Shadow>,
text_color: Option<Color>,
text_alignment: Option<TextAlignment>, text_alignment: Option<TextAlignment>,
video_loop: Option<bool>, video_loop: Option<bool>,
video_start_time: Option<f32>, video_start_time: Option<f32>,
video_end_time: Option<f32>, video_end_time: Option<f32>,
pdf_index: Option<u32>,
#[serde(skip)]
pdf_page: Option<Handle>,
#[serde(skip)] #[serde(skip)]
text_svg: Option<TextSvg>, text_svg: Option<TextSvg>,
} }
@ -632,20 +537,12 @@ impl SlideBuilder {
self self
} }
pub(crate) fn text_color(
mut self,
text_color: impl Into<Color>,
) -> Self {
let _ = self.text_color.insert(text_color.into());
self
}
pub(crate) fn audio(mut self, audio: impl Into<PathBuf>) -> Self { pub(crate) fn audio(mut self, audio: impl Into<PathBuf>) -> Self {
let _ = self.audio.insert(audio.into()); let _ = self.audio.insert(audio.into());
self self
} }
pub(crate) fn font(mut self, font: impl Into<Font>) -> Self { pub(crate) fn font(mut self, font: impl Into<String>) -> Self {
let _ = self.font.insert(font.into()); let _ = self.font.insert(font.into());
self self
} }
@ -655,27 +552,6 @@ impl SlideBuilder {
self self
} }
pub(crate) fn color(mut self, color: impl Into<Color>) -> Self {
let _ = self.text_color.insert(color.into());
self
}
pub(crate) fn stroke(
mut self,
stroke: impl Into<Stroke>,
) -> Self {
let _ = self.stroke.insert(stroke.into());
self
}
pub(crate) fn shadow(
mut self,
shadow: impl Into<Shadow>,
) -> Self {
let _ = self.shadow.insert(shadow.into());
self
}
pub(crate) fn text_alignment( pub(crate) fn text_alignment(
mut self, mut self,
text_alignment: TextAlignment, text_alignment: TextAlignment,
@ -713,19 +589,6 @@ impl SlideBuilder {
self self
} }
pub(crate) fn pdf_page(mut self, pdf_page: Handle) -> Self {
let _ = self.pdf_page.insert(pdf_page);
self
}
pub(crate) fn pdf_index(
mut self,
pdf_index: impl Into<u32>,
) -> Self {
let _ = self.pdf_index.insert(pdf_index.into());
self
}
pub(crate) fn build(self) -> Result<Slide> { pub(crate) fn build(self) -> Result<Slide> {
let Some(background) = self.background else { let Some(background) = self.background else {
return Err(miette!("No background")); return Err(miette!("No background"));
@ -733,6 +596,9 @@ impl SlideBuilder {
let Some(text) = self.text else { let Some(text) = self.text else {
return Err(miette!("No text")); return Err(miette!("No text"));
}; };
let Some(font) = self.font else {
return Err(miette!("No font"));
};
let Some(font_size) = self.font_size else { let Some(font_size) = self.font_size else {
return Err(miette!("No font_size")); return Err(miette!("No font_size"));
}; };
@ -748,24 +614,60 @@ impl SlideBuilder {
let Some(video_end_time) = self.video_end_time else { let Some(video_end_time) = self.video_end_time else {
return Err(miette!("No video_end_time")); return Err(miette!("No video_end_time"));
}; };
Ok(Slide { if let Some(text_svg) = self.text_svg {
background, Ok(Slide {
text, background,
font: self.font, text,
font_size, font,
text_alignment, font_size,
audio: self.audio, text_alignment,
stroke: self.stroke, audio: self.audio,
shadow: self.shadow, video_loop,
text_color: self.text_color, video_start_time,
video_loop, video_end_time,
video_start_time, text_svg,
video_end_time, ..Default::default()
text_svg: self.text_svg, })
pdf_index: self.pdf_index.unwrap_or_default(), } else {
pdf_page: self.pdf_page, let text_svg = TextSvg::new(text.clone())
.alignment(text_alignment)
.fill("#fff")
.shadow(text_svg::shadow(2, 2, 5, "#000000"))
.stroke(text_svg::stroke(3, "#000"))
.font(
text_svg::Font::from(font.clone())
.size(font_size.try_into().unwrap()),
)
.build();
Ok(Slide {
background,
text,
font,
font_size,
text_alignment,
audio: self.audio,
video_loop,
video_start_time,
video_end_time,
text_svg,
..Default::default()
})
}
}
}
#[derive(Debug, Clone, Default)]
struct Image {
pub source: String,
pub fit: String,
pub children: Vec<String>,
}
impl Image {
fn new() -> Self {
Self {
..Default::default() ..Default::default()
}) }
} }
} }
@ -781,8 +683,8 @@ mod test {
text: "This is frodo".to_string(), text: "This is frodo".to_string(),
background: Background::try_from("~/pics/frodo.jpg") background: Background::try_from("~/pics/frodo.jpg")
.unwrap(), .unwrap(),
font: Some("Quicksand".to_string().into()), font: "Quicksand".to_string(),
font_size: 140, font_size: 70,
..Default::default() ..Default::default()
} }
} }
@ -794,21 +696,40 @@ mod test {
"~/vids/test/camprules2024.mp4", "~/vids/test/camprules2024.mp4",
) )
.unwrap(), .unwrap(),
font: Some("Quicksand".to_string().into()), font: "Quicksand".to_string(),
..Default::default() ..Default::default()
} }
} }
#[test]
fn test_lisp_serialize() {
let lisp =
read_to_string("./test_presentation.lisp").expect("oops");
let lisp_value = crisp::reader::read(&lisp);
match lisp_value {
Value::List(value) => {
let slide = Slide::from(value[0].clone());
let test_slide = test_slide();
assert_eq!(slide, test_slide);
let second_slide = Slide::from(value[1].clone());
let second_test_slide = test_second_slide();
assert_eq!(second_slide, second_test_slide)
}
_ => panic!("this should be a lisp"),
}
}
#[test] #[test]
fn test_ron_deserialize() { fn test_ron_deserialize() {
let slide = read_to_string("./test_presentation.ron") let slide = read_to_string("./test_presentation.ron")
.expect("Problem getting file read"); .expect("Problem getting file read");
match ron::from_str::<Vec<Slide>>(&slide) { match ron::from_str::<Vec<Slide>>(&slide) {
Ok(_s) => { Ok(s) => {
assert!(true) assert!(true)
} }
Err(e) => { Err(e) => {
assert!(false, "{:?}", e) assert!(false)
} }
} }
} }

View file

@ -1,41 +0,0 @@
use miette::{IntoDiagnostic, Result};
use std::sync::Arc;
use tracing::warn;
use obws::{Client, responses::scenes::Scene};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum ObsAction {
Scene { scene: Scene },
StartStream,
StopStream,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum Action {
Obs { action: ObsAction },
Other,
}
impl ObsAction {
pub async fn run(&self, client: Arc<Client>) -> Result<()> {
match self {
Self::Scene { scene } => {
warn!(?scene, "Changing obs scenes");
client
.scenes()
.set_current_program_scene(&scene.id)
.await
.into_diagnostic()?;
}
Self::StartStream => {
client.streaming().start().await.into_diagnostic()?;
}
Self::StopStream => {
client.streaming().stop().await.into_diagnostic()?;
}
}
Ok(())
}
}

View file

@ -1,327 +0,0 @@
use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette};
use reqwest::header;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(
Clone,
Debug,
Default,
PartialEq,
PartialOrd,
Ord,
Eq,
Serialize,
Deserialize,
)]
pub struct OnlineSong {
pub lyrics: String,
pub title: String,
pub author: String,
pub site: String,
pub link: String,
}
pub async fn search_genius_links(
query: impl AsRef<str> + std::fmt::Display,
) -> Result<Vec<OnlineSong>> {
let auth_token = env!("GENIUS_TOKEN");
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_static(auth_token),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.into_diagnostic()?;
let response = client
.get(format!("https://api.genius.com/search?q={query}"))
.send()
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let json: Value =
serde_json::from_str(&response).into_diagnostic()?;
let hits = json
.get("response")
.expect("respose")
.get("hits")
.expect("hits")
.as_array()
.expect("array");
Ok(hits
.iter()
.map(|hit| {
let result = hit.get("result").expect("result");
let title = result
.get("full_title")
.expect("title")
.as_str()
.expect("title")
.to_string();
let title = title.replace("\u{a0}", " ");
let author = result
.get("artist_names")
.expect("artists")
.as_str()
.expect("artists")
.to_string();
let link = result
.get("url")
.expect("url")
.as_str()
.expect("url")
.to_string();
OnlineSong {
lyrics: String::new(),
title,
author,
site: String::from("https://genius.com"),
link,
}
})
.collect())
}
pub async fn get_genius_lyrics(
mut song: OnlineSong,
) -> Result<OnlineSong> {
let html = reqwest::get(&song.link)
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(lyrics_root_selector) = scraper::Selector::parse(
r#"div[data-lyrics-container="true"]"#,
) else {
return Err(miette!("error in finding lyrics_root"));
};
let lyrics = document
.select(&lyrics_root_selector)
.map(|root| {
// dbg!(&root);
root.inner_html()
})
.collect::<String>();
let lyrics = lyrics.find("[").map_or_else(
|| {
lyrics.find("</div></div></div>").map_or(
lyrics.clone(),
|position| {
lyrics.split_at(position + 18).1.to_string()
},
)
},
|position| lyrics.split_at(position).1.to_string(),
);
let lyrics = lyrics.replace("<br>", "\n");
song.lyrics = lyrics;
Ok(song)
}
pub async fn search_lyrics_com_links(
query: impl AsRef<str> + std::fmt::Display,
) -> Result<Vec<String>> {
let html =
reqwest::get(format!("http://www.lyrics.com/lyrics/{query}"))
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(best_matches_selector) =
scraper::Selector::parse(".best-matches")
else {
return Err(miette!("error in finding matches"));
};
let Ok(lyric_selector) = scraper::Selector::parse("a") else {
return Err(miette!("error in finding a links"));
};
Ok(document
.select(&best_matches_selector)
.flat_map(|best_section| best_section.select(&lyric_selector))
.map(|a| {
a.value().attr("href").unwrap_or("").trim().to_string()
})
.filter(|a| a.contains("/lyric/"))
.dedup()
.map(|link| {
link.strip_prefix("/lyric/")
.unwrap_or_else(|| &link)
.to_string()
})
.collect())
}
// leaving this lint unfixed because I don't know if we will need this
// id value or not in the future and I'd like to keep the code understanding
// of what this variable might be.
#[allow(clippy::no_effect_underscore_binding)]
pub async fn lyrics_com_link_to_song(
links: Vec<impl AsRef<str> + std::fmt::Display>,
) -> Result<Vec<OnlineSong>> {
let mut songs = vec![];
for link in links {
let parts = link
.as_ref()
.split('/')
.map(std::string::ToString::to_string)
.collect::<Vec<String>>();
let link = format!("https://www.lyrics.com/lyric/{link}");
let _id = &parts[0];
let author = &parts[1].replace('+', " ");
let title = &parts[2].replace('+', " ");
let html = reqwest::get(&link)
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(lyric_selector) =
scraper::Selector::parse(".lyric-body")
else {
return Err(miette!("error in finding lyric-body",));
};
let lyrics = document
.select(&lyric_selector)
.map(|a| a.text().collect::<String>())
.dedup()
.next();
if let Some(lyrics) = lyrics {
let song = OnlineSong {
lyrics,
title: title.clone(),
author: author.clone(),
site: "https://www.lyrics.com".into(),
link,
};
songs.push(song);
}
}
Ok(songs)
}
#[cfg(test)]
mod test {
use crate::core::songs::Song;
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_genius() -> Result<(), String> {
let song = OnlineSong {
lyrics: String::new(),
title: "Death Was Arrested by North Point Worship (Ft. Seth Condrey)".to_string(),
author: "North Point Worship (Ft. Seth Condrey)".to_string(),
site: "https://genius.com".to_string(),
link: "https://genius.com/North-point-worship-death-was-arrested-lyrics".to_string(),
};
let hits = search_genius_links("Death was arrested")
.await
.map_err(|e| e.to_string())?;
let titles: Vec<String> =
hits.iter().map(|song| song.title.clone()).collect();
dbg!(titles);
for hit in hits {
let new_song = get_genius_lyrics(hit)
.await
.map_err(|e| e.to_string())?;
dbg!(&new_song);
if !new_song.lyrics.starts_with("[Verse 1]") {
assert!(new_song.lyrics.len() > 10);
} else {
assert!(new_song.lyrics.contains("[Verse 2]"));
if !new_song.lyrics.contains("[Chorus]") {
assert!(new_song.lyrics.contains("[Chorus 1]"))
}
}
}
Ok(())
}
#[tokio::test]
async fn test_search_to_song() -> Result<(), String> {
let song = OnlineSong {
lyrics: "Alone in my sorrow and dead in my sin\nLost without hope with no place to begin\nYour love Made a way to let mercy come in\nWhen death was arrested and my life began\n\nAsh was redeemed only beauty remains\nMy orphan heart was given a name\nMy mourning grew quiet my feet rose to dance\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nReleased from my chains I'm a prisoner no more\nMy shame was a ransom He faithfully bore\nHe cancelled my debt and He called me His friend\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOur savior displayed on a criminal's cross\nDarkness rejoiced as though heaven had lost\nBut then Jesus arose with our freedom in hand\nThat's when death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nWhen death was arrested and my life began\nWhen death was arrested and my life began".to_string(),
title: "Death Was Arrested".to_string(),
author: "North Point InsideOut".to_string(),
site: "https://www.lyrics.com".to_string(),
link: "https://www.lyrics.com/lyric/35090938/North+Point+InsideOut/Death+Was+Arrested".to_string(),
};
let links = search_lyrics_com_links("Death was arrested")
.await
.map_err(|e| format!("{e}"))?;
let songs = lyrics_com_link_to_song(links)
.await
.map_err(|e| format!("{e}"))?;
if let Some(first) = songs.iter().find_or_first(|song| {
song.author == "North Point InsideOut"
}) {
assert_eq!(&song, first);
online_song_to_song(song)?
}
Ok(())
}
fn online_song_to_song(song: OnlineSong) -> Result<(), String> {
let song = Song::from(song);
if let Some(verse_map) = song.verse_map.as_ref() {
if verse_map.len() < 2 {
return Err(format!(
"VerseMap wasn't built right likely: {:?}",
song
));
}
} else {
return Err(String::from(
"There is no VerseMap in this song",
));
};
Ok(())
}
#[tokio::test]
async fn test_online_search() {
let search =
search_lyrics_com_links("Death was arrested").await;
match search {
Ok(songs) => {
assert_eq!(
songs,
vec![
"33755723/Various+Artists/Death+Was+Arrested",
"35090938/North+Point+InsideOut/Death+Was+Arrested"
]
);
}
Err(e) => assert!(false, "{}", e),
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -11,13 +11,12 @@ pub fn bg_from_video(
video: &Path, video: &Path,
screenshot: &Path, screenshot: &Path,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
if screenshot.exists() { if !screenshot.exists() {
debug!("Screenshot already exists");
} else {
let output_duration = Command::new("ffprobe") let output_duration = Command::new("ffprobe")
.args(["-i", &video.to_string_lossy()]) .args(["-i", &video.to_string_lossy()])
.output()?; .output()
io::stderr().write_all(&output_duration.stderr)?; .expect("failed to execute ffprobe");
io::stderr().write_all(&output_duration.stderr).unwrap();
let mut at_second = 5; let mut at_second = 5;
let mut log = str::from_utf8(&output_duration.stderr) let mut log = str::from_utf8(&output_duration.stderr)
.expect("Using non UTF-8 characters") .expect("Using non UTF-8 characters")
@ -27,9 +26,9 @@ pub fn bg_from_video(
let mut duration = log.split_off(duration_index + 10); let mut duration = log.split_off(duration_index + 10);
duration.truncate(11); duration.truncate(11);
// debug!("rust-duration-is: {duration}"); // debug!("rust-duration-is: {duration}");
let mut hours = String::new(); let mut hours = String::from("");
let mut minutes = String::new(); let mut minutes = String::from("");
let mut seconds = String::new(); let mut seconds = String::from("");
for (i, c) in duration.chars().enumerate() { for (i, c) in duration.chars().enumerate() {
if i <= 1 { if i <= 1 {
hours.push(c); hours.push(c);
@ -64,6 +63,8 @@ pub fn bg_from_video(
.expect("failed to execute ffmpeg"); .expect("failed to execute ffmpeg");
// io::stdout().write_all(&output.stdout).unwrap(); // io::stdout().write_all(&output.stdout).unwrap();
// io::stderr().write_all(&output.stderr).unwrap(); // io::stderr().write_all(&output.stderr).unwrap();
} else {
debug!("Screenshot already exists");
} }
Ok(()) Ok(())
} }
@ -71,18 +72,15 @@ pub fn bg_from_video(
pub fn bg_path_from_video(video: &Path) -> PathBuf { pub fn bg_path_from_video(video: &Path) -> PathBuf {
let video = PathBuf::from(video); let video = PathBuf::from(video);
debug!(?video); debug!(?video);
let mut data_dir = let mut data_dir = dirs::data_local_dir().unwrap();
dirs::cache_dir().expect("Can't find cache dir");
data_dir.push("lumina"); data_dir.push("lumina");
data_dir.push("thumbnails"); data_dir.push("thumbnails");
let _ = fs::create_dir_all(&data_dir);
if !data_dir.exists() { if !data_dir.exists() {
fs::create_dir(&data_dir) fs::create_dir(&data_dir)
.expect("Could not create thumbnails dir"); .expect("Could not create thumbnails dir");
} }
let mut screenshot = data_dir.clone(); let mut screenshot = data_dir.clone();
screenshot screenshot.push(video.file_name().unwrap());
.push(video.file_name().expect("Should have file name"));
screenshot.set_extension("png"); screenshot.set_extension("png");
screenshot screenshot
} }
@ -93,9 +91,16 @@ mod test {
#[test] #[test]
fn test_bg_video_creation() { fn test_bg_video_creation() {
let video = Path::new("./res/bigbuckbunny.mp4"); let video = Path::new("/home/chris/vids/moms-funeral.mp4");
let screenshot = bg_path_from_video(video); let screenshot = bg_path_from_video(video);
match bg_from_video(video, &screenshot) { let screenshot_string =
screenshot.to_str().expect("Should be thing");
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);
// let result = runtime.block_on(future);
match result {
Ok(_o) => assert!(screenshot.exists()), Ok(_o) => assert!(screenshot.exists()),
Err(e) => debug_assert!( Err(e) => debug_assert!(
false, false,
@ -104,4 +109,15 @@ mod test {
), ),
} }
} }
#[test]
fn test_bg_not_same() {
let video = Path::new(
"/home/chris/vids/All WebDev Sucks and you know it.webm",
);
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");
}
} }

View file

@ -11,11 +11,11 @@ use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result}; use miette::{IntoDiagnostic, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection, pool::PoolConnection, query, query_as, Sqlite, SqliteConnection,
query, query_as, SqlitePool,
}; };
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use tracing::{debug, error}; use tracing::error;
#[derive( #[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Serialize, Deserialize,
@ -30,31 +30,11 @@ pub struct Video {
} }
impl From<&Video> for Value { impl From<&Video> for Value {
fn from(_value: &Video) -> Self { fn from(value: &Video) -> Self {
Self::List(vec![Self::Symbol(Symbol("video".into()))]) Self::List(vec![Self::Symbol(Symbol("video".into()))])
} }
} }
impl From<PathBuf> for Video {
fn from(value: PathBuf) -> Self {
let title: String = value.file_name().map_or_else(
|| "Video".into(),
|filename| filename.to_str().unwrap_or("Video").into(),
);
Self {
title,
path: value,
..Default::default()
}
}
}
impl From<&Path> for Video {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl Content for Video { impl Content for Video {
fn title(&self) -> String { fn title(&self) -> String {
self.title.clone() self.title.clone()
@ -74,10 +54,10 @@ impl Content for Video {
fn subtext(&self) -> String { fn subtext(&self) -> String {
if self.path.exists() { if self.path.exists() {
self.path.file_name().map_or_else( self.path
|| "Missing video".into(), .file_name()
|f| f.to_string_lossy().to_string(), .map(|f| f.to_string_lossy().to_string())
) .unwrap_or("Missing video".into())
} else { } else {
"Missing video".into() "Missing video".into()
} }
@ -90,65 +70,64 @@ impl From<Value> for Video {
} }
} }
#[allow(clippy::cast_precision_loss)]
impl From<&Value> for Video { impl From<&Value> for Video {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
Value::List(list) => { Value::List(list) => {
let path = list let path = if let Some(path_pos) =
.iter() list.iter().position(|v| {
.position(|v| {
v == &Value::Keyword(Keyword::from("source")) v == &Value::Keyword(Keyword::from("source"))
}) }) {
.and_then(|path_pos| { let pos = path_pos + 1;
let pos = path_pos + 1; list.get(pos)
list.get(pos) .map(|p| PathBuf::from(String::from(p)))
.map(|p| PathBuf::from(String::from(p))) } else {
}); None
};
let title = path.clone().map(|p| { let title = path.clone().map(|p| {
let path = let path =
p.to_str().unwrap_or_default().to_string(); p.to_str().unwrap_or_default().to_string();
let title = let title =
path.rsplit_once('/').unwrap_or_default().1; path.rsplit_once("/").unwrap_or_default().1;
title.to_string() title.to_string()
}); });
let start_time = list let start_time = if let Some(start_pos) =
.iter() list.iter().position(|v| {
.position(|v| {
v == &Value::Keyword(Keyword::from( v == &Value::Keyword(Keyword::from(
"start-time", "start-time",
)) ))
}) }) {
.and_then(|start_pos| { let pos = start_pos + 1;
let pos = start_pos + 1; list.get(pos).map(|p| i32::from(p) as f32)
list.get(pos).map(|p| i32::from(p) as f32) } else {
}); None
};
let end_time = list let end_time = if let Some(end_pos) =
.iter() list.iter().position(|v| {
.position(|v| {
v == &Value::Keyword(Keyword::from( v == &Value::Keyword(Keyword::from(
"end-time", "end-time",
)) ))
}) }) {
.and_then(|end_pos| { let pos = end_pos + 1;
let pos = end_pos + 1; list.get(pos).map(|p| i32::from(p) as f32)
list.get(pos).map(|p| i32::from(p) as f32) } else {
}); None
};
let looping = list let looping = if let Some(loop_pos) =
.iter() list.iter().position(|v| {
.position(|v| {
v == &Value::Keyword(Keyword::from("loop")) v == &Value::Keyword(Keyword::from("loop"))
}) }) {
.is_some_and(|loop_pos| { let pos = loop_pos + 1;
let pos = loop_pos + 1; list.get(pos)
list.get(pos).is_some_and(|l| { .map(|l| String::from(l) == *"true")
String::from(l) == *"true" .unwrap_or_default()
}) } else {
}); false
};
Self { Self {
title: title.unwrap_or_default(), title: title.unwrap_or_default(),
@ -213,54 +192,17 @@ impl Model<Video> {
let result = query_as!(Video, r#"SELECT title as "title!", file_path as "path!", start_time as "start_time!: f32", end_time as "end_time!: f32", loop as "looping!", id as "id: i32" from videos"#).fetch_all(db).await; let result = query_as!(Video, r#"SELECT title as "title!", file_path as "path!", start_time as "start_time!: f32", end_time as "end_time!: f32", loop as "looping!", id as "id: i32" from videos"#).fetch_all(db).await;
match result { match result {
Ok(v) => { Ok(v) => {
for video in v { for video in v.into_iter() {
let _ = self.add_item(video); let _ = self.add_item(video);
} }
} }
Err(e) => { Err(e) => {
error!( error!("There was an error in converting videos: {e}")
"There was an error in converting videos: {e}"
);
} }
} };
} }
} }
pub async fn remove_from_db(
db: PoolConnection<Sqlite>,
id: i32,
) -> Result<()> {
query!("DELETE FROM videos WHERE id = $1", id)
.execute(&mut db.detach())
.await
.into_diagnostic()
.map(|_| ())
}
pub async fn add_video_to_db(
video: Video,
db: PoolConnection<Sqlite>,
) -> Result<()> {
let path = video
.path
.to_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
let mut db = db.detach();
query!(
r#"INSERT INTO videos (title, file_path, start_time, end_time, loop) VALUES ($1, $2, $3, $4, $5)"#,
video.title,
path,
video.start_time,
video.end_time,
video.looping
)
.execute(&mut db)
.await
.into_diagnostic()?;
Ok(())
}
pub async fn update_video_in_db( pub async fn update_video_in_db(
video: Video, video: Video,
db: PoolConnection<Sqlite>, db: PoolConnection<Sqlite>,
@ -268,11 +210,9 @@ pub async fn update_video_in_db(
let path = video let path = video
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(|s| s.to_string())
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach(); query!(
debug!(?video, "should be been updated");
let result = query!(
r#"UPDATE videos SET title = $2, file_path = $3, start_time = $4, end_time = $5, loop = $6 WHERE id = $1"#, r#"UPDATE videos SET title = $2, file_path = $3, start_time = $4, end_time = $5, loop = $6 WHERE id = $1"#,
video.id, video.id,
video.title, video.title,
@ -281,19 +221,11 @@ pub async fn update_video_in_db(
video.end_time, video.end_time,
video.looping, video.looping,
) )
.execute(&mut db) .execute(&mut db.detach())
.await.into_diagnostic(); .await
.into_diagnostic()?;
match result { Ok(())
Ok(_) => {
debug!("should have been updated");
Ok(())
}
Err(e) => {
error! {?e};
Err(e)
}
}
} }
pub async fn get_video_from_db( pub async fn get_video_from_db(
@ -311,9 +243,7 @@ mod test {
fn test_video(title: String) -> Video { fn test_video(title: String) -> Video {
Video { Video {
title, title,
path: PathBuf::from( path: PathBuf::from("~/vids/camprules2024.mp4"),
"/home/chris/docs/notes/lessons/christ-our-hope.mp4",
),
..Default::default() ..Default::default()
} }
} }
@ -324,10 +254,13 @@ mod test {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
}; };
let mut db = add_db().await.unwrap().acquire().await.unwrap(); let mut db = crate::core::model::get_db().await;
video_model.load_from_db(&mut db).await; video_model.load_from_db(&mut db).await;
if let Some(video) = video_model.find(|v| v.id == 2) { if let Some(video) = video_model.find(|v| v.id == 73) {
let test_video = test_video("christ-our-hope.mp4".into()); let test_video = test_video(
"Getting started with Tokio. The ultimate starter guide to writing async Rust."
.into(),
);
assert_eq!(test_video.title, video.title); assert_eq!(test_video.title, video.title);
} else { } else {
assert!(false); assert!(false);
@ -361,9 +294,4 @@ mod test {
), ),
} }
} }
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
} }

View file

@ -35,157 +35,142 @@ pub fn parse_lisp(value: Value) -> Vec<ServiceItem> {
} }
} }
// #[cfg(test)] #[cfg(test)]
// mod test { mod test {
// use std::{fs::read_to_string, path::PathBuf}; use std::{fs::read_to_string, path::PathBuf};
// use crate::core::{ use crate::{
// images::Image, core::{
// kinds::ServiceItemKind, images::Image, kinds::ServiceItemKind,
// service_items::ServiceTrait, service_items::ServiceTrait, songs::Song, videos::Video,
// slide::{Background, TextAlignment}, },
// songs::Song, Background, TextAlignment,
// videos::Video, };
// };
// use super::*; use super::*;
// use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
// #[test] #[test]
// fn test_parsing_lisp() { fn test_parsing_lisp() {
// let lisp = let lisp =
// read_to_string("./test_slides.lisp").expect("oops"); read_to_string("./test_slides.lisp").expect("oops");
// let lisp_value = crisp::reader::read(&lisp); let lisp_value = crisp::reader::read(&lisp);
// let hard_coded_items = let hard_coded_items =
// vec![service_item_1(), service_item_2()]; vec![service_item_1(), service_item_2()];
// match lisp_value { match lisp_value {
// Value::List(value) => { Value::List(value) => {
// let mut lisp_items = vec![]; let mut lisp_items = vec![];
// for value in value { for value in value {
// let mut vec = parse_lisp(value); let mut vec = parse_lisp(value);
// lisp_items.append(&mut vec); lisp_items.append(&mut vec);
// } }
// assert_eq!(lisp_items, hard_coded_items) assert_eq!(lisp_items, hard_coded_items)
// } }
// _ => panic!("this should be a lisp"), _ => panic!("this should be a lisp"),
// } }
// } }
// // Planning on removing lisp potentially #[test]
// // #[test] fn test_parsing_lisp_presentation() {
// // fn test_parsing_lisp_presentation() { let lisp = read_to_string("./testypres.lisp").expect("oops");
// // let lisp = read_to_string("./testypres.lisp").expect("oops"); let lisp_value = crisp::reader::read(&lisp);
// // let lisp_value = crisp::reader::read(&lisp); let hard_coded_items = vec![
// // let hard_coded_items = vec![ service_item_1(),
// // service_item_1(), service_item_2(),
// // service_item_2(), service_item_3(),
// // service_item_3(), ];
// // ]; match lisp_value {
// // match lisp_value { Value::List(value) => {
// // Value::List(value) => { let mut lisp_items = vec![];
// // let mut lisp_items = vec![]; for value in value {
// // for value in value { let mut vec = parse_lisp(value);
// // let mut vec = parse_lisp(value); lisp_items.append(&mut vec);
// // lisp_items.append(&mut vec); }
// // } let item_1 = &lisp_items[0];
// // let item_1 = &lisp_items[0]; let item_2 = &lisp_items[1];
// // let item_2 = &lisp_items[1]; let item_3 = &lisp_items[2];
// // let item_3 = &lisp_items[2]; assert_eq!(item_1, &hard_coded_items[0]);
// // assert_eq!(item_1, &hard_coded_items[0]); assert_eq!(item_2, &hard_coded_items[1]);
// // assert_eq!(item_2, &hard_coded_items[1]); assert_eq!(item_3, &hard_coded_items[2]);
// // assert_eq!(item_3, &hard_coded_items[2]);
// // assert_eq!(lisp_items, hard_coded_items); assert_eq!(lisp_items, hard_coded_items);
// // } }
// // _ => panic!("this should be a lisp"), _ => panic!("this should be a lisp"),
// // } }
// // } }
// fn service_item_1() -> ServiceItem { fn service_item_1() -> ServiceItem {
// let image = Image { let image = Image {
// title: "This is frodo".to_string(), title: "This is frodo".to_string(),
// path: PathBuf::from("~/pics/frodo.jpg"), path: PathBuf::from("~/pics/frodo.jpg"),
// ..Default::default() ..Default::default()
// }; };
// let slide = &image.to_slides().unwrap()[0]; let slide = &image.to_slides().unwrap()[0];
// let slide = slide let slide = slide
// .clone() .clone()
// .set_text("This is frodo") .set_text("This is frodo")
// .set_font("Quicksand") .set_font("Quicksand")
// .set_font_size(70) .set_font_size(70)
// .set_audio(None); .set_audio(None);
// ServiceItem { ServiceItem {
// title: "This is frodo".to_string(), title: "This is frodo".to_string(),
// kind: ServiceItemKind::Content(slide.clone()), kind: ServiceItemKind::Content(slide.clone()),
// slides: vec![slide], ..Default::default()
// ..Default::default() }
// } }
// }
// fn service_item_2() -> ServiceItem { fn service_item_2() -> ServiceItem {
// let video = Video::from(PathBuf::from( ServiceItem {
// "~/vids/test/camprules2024.mp4", title: "camprules2024.mp4".to_string(),
// )); kind: ServiceItemKind::Video(Video {
// let slide = &video.to_slides().unwrap()[0]; title: "camprules2024.mp4".to_string(),
// ServiceItem { path: PathBuf::from("~/vids/test/camprules2024.mp4"),
// title: "camprules2024.mp4".to_string(), start_time: None,
// kind: ServiceItemKind::Video(Video { end_time: None,
// title: "camprules2024.mp4".to_string(), looping: false,
// path: PathBuf::from("~/vids/test/camprules2024.mp4"), ..Default::default()
// start_time: None, }),
// end_time: None, ..Default::default()
// looping: false, }
// ..Default::default() }
// }),
// slides: vec![slide.clone()],
// ..Default::default()
// }
// }
// fn service_item_3() -> ServiceItem { fn service_item_3() -> ServiceItem {
// ServiceItem { ServiceItem {
// title: "Death Was Arrested".to_string(), title: "Death Was Arrested".to_string(),
// kind: ServiceItemKind::Song(test_song()), kind: ServiceItemKind::Song(test_song()),
// database_id: 7, database_id: 7,
// ..Default::default() ..Default::default()
// } }
// } }
// fn test_song() -> Song { fn test_song() -> Song {
// Song { Song {
// id: 7, id: 7,
// title: "Death Was Arrested".to_string(), title: "Death Was Arrested".to_string(),
// lyrics: Some("Intro 1\nDeath Was Arrested\nNorth Point Worship\n\nVerse 1\nAlone in my sorrow\nAnd dead in my sin\n\nLost without hope\nWith no place to begin\n\nYour love made a way\nTo let mercy come in\n\nWhen death was arrested\nAnd my life began\n\nVerse 2\nAsh was redeemed\nOnly beauty remains\n\nMy orphan heart\nWas given a name\n\nMy mourning grew quiet,\nMy feet rose to dance\n\nWhen death was arrested\nAnd my life began\n\nChorus 1\nOh, Your grace so free,\nWashes over me\n\nYou have made me new,\nNow life begins with You\n\nIt's Your endless love,\nPouring down on us\n\nYou have made us new,\nNow life begins with You\n\nVerse 3\nReleased from my chains,\nI'm a prisoner no more\n\nMy shame was a ransom\nHe faithfully bore\n\nHe cancelled my debt and\nHe called me His friend\n\nWhen death was arrested\nAnd my life began\n\nVerse 4\nOur Savior displayed\nOn a criminal's cross\n\nDarkness rejoiced as though\nHeaven had lost\n\nBut then Jesus arose\nWith our freedom in hand\n\nThat's when death was arrested\nAnd my life began\n\nThat's when death was arrested\nAnd my life began\n\nBridge 1\nOh, we're free, free,\nForever we're free\n\nCome join the song\nOf all the redeemed\n\nYes, we're free, free,\nForever amen\n\nWhen death was arrested\nAnd my life began\n\nOh, we're free, free,\nForever we're free\n\nCome join the song\nOf all the redeemed\n\nYes, we're free, free,\nForever amen\n\nWhen death was arrested\nAnd my life began\n\nEnding 1\nWhen death was arrested\nAnd my life began\n\nThat's when death was arrested\nAnd my life began".to_string()), lyrics: Some("Intro 1\nDeath Was Arrested\nNorth Point Worship\n\nVerse 1\nAlone in my sorrow\nAnd dead in my sin\n\nLost without hope\nWith no place to begin\n\nYour love made a way\nTo let mercy come in\n\nWhen death was arrested\nAnd my life began\n\nVerse 2\nAsh was redeemed\nOnly beauty remains\n\nMy orphan heart\nWas given a name\n\nMy mourning grew quiet,\nMy feet rose to dance\n\nWhen death was arrested\nAnd my life began\n\nChorus 1\nOh, Your grace so free,\nWashes over me\n\nYou have made me new,\nNow life begins with You\n\nIt's Your endless love,\nPouring down on us\n\nYou have made us new,\nNow life begins with You\n\nVerse 3\nReleased from my chains,\nI'm a prisoner no more\n\nMy shame was a ransom\nHe faithfully bore\n\nHe cancelled my debt and\nHe called me His friend\n\nWhen death was arrested\nAnd my life began\n\nVerse 4\nOur Savior displayed\nOn a criminal's cross\n\nDarkness rejoiced as though\nHeaven had lost\n\nBut then Jesus arose\nWith our freedom in hand\n\nThat's when death was arrested\nAnd my life began\n\nThat's when death was arrested\nAnd my life began\n\nBridge 1\nOh, we're free, free,\nForever we're free\n\nCome join the song\nOf all the redeemed\n\nYes, we're free, free,\nForever amen\n\nWhen death was arrested\nAnd my life began\n\nOh, we're free, free,\nForever we're free\n\nCome join the song\nOf all the redeemed\n\nYes, we're free, free,\nForever amen\n\nWhen death was arrested\nAnd my life began\n\nEnding 1\nWhen death was arrested\nAnd my life began\n\nThat's when death was arrested\nAnd my life began".to_string()),
// author: Some( author: Some(
// "North Point Worship".to_string(), "North Point Worship".to_string(),
// ), ),
// ccli: None, ccli: None,
// audio: Some("file:///home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()), audio: Some("file:///home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()),
// verse_order: Some(vec![ verse_order: Some(vec![
// "I1".to_string(), "I1".to_string(),
// "V1".to_string(), "V1".to_string(),
// "V2".to_string(), "V2".to_string(),
// "C1".to_string(), "C1".to_string(),
// "V3".to_string(), "V3".to_string(),
// "C1".to_string(), "C1".to_string(),
// "V4".to_string(), "V4".to_string(),
// "C1".to_string(), "C1".to_string(),
// "B1".to_string(), "B1".to_string(),
// "B1".to_string(), "B1".to_string(),
// "E1".to_string(), "E1".to_string(),
// "E2".to_string(), "E2".to_string(),
// ]), ]),
// background: Some(Background::try_from("file:///home/chris/nc/tfc/openlp/CMG - Bright Mountains 01.jpg").unwrap()), background: Some(Background::try_from("file:///home/chris/nc/tfc/openlp/CMG - Bright Mountains 01.jpg").unwrap()),
// text_alignment: Some(TextAlignment::MiddleCenter), text_alignment: Some(TextAlignment::MiddleCenter),
// font: Some("Quicksand Bold".to_string()), font: Some("Quicksand Bold".to_string()),
// font_size: Some(60), font_size: Some(60)
// stroke_size: Some(2), }
// verses: None, }
// verse_map: None, }
// stroke_color: todo!(),
// shadow_size: todo!(),
// shadow_offset: todo!(),
// shadow_color: todo!(),
// }
// }
// }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
use std::ops::RangeInclusive; use std::ops::RangeInclusive;
use cosmic::iced::Length; use iced::Length;
struct DoubleSlider<'a, T, Message> { struct DoubleSlider<'a, T, Message> {
range: RangeInclusive<T>, range: RangeInclusive<T>,

View file

@ -1,186 +0,0 @@
use std::{io, path::PathBuf};
use crate::core::images::Image;
use cosmic::{
Apply, Element, Task,
dialog::file_chooser::{FileFilter, open::Dialog},
iced::{Length, alignment::Vertical},
iced_widget::{column, row},
theme,
widget::{
self, Space, button, container, horizontal_space, icon, text,
text_input,
},
};
use tracing::{debug, error, warn};
#[derive(Debug)]
pub struct ImageEditor {
pub image: Option<Image>,
title: String,
editing: bool,
}
pub enum Action {
Task(Task<Message>),
UpdateImage(Image),
None,
}
#[derive(Debug, Clone)]
pub enum Message {
ChangeImage(Image),
Update(Image),
ChangeTitle(String),
PickImage,
Edit(bool),
None,
}
impl ImageEditor {
#[must_use]
pub fn new() -> Self {
Self {
image: None,
title: "Death was Arrested".to_string(),
editing: false,
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangeImage(image) => {
self.update_entire_image(&image);
}
Message::ChangeTitle(title) => {
self.title.clone_from(&title);
if let Some(image) = &self.image {
let mut image = image.clone();
image.title = title;
return self.update(Message::Update(image));
}
}
Message::Edit(edit) => {
debug!(edit);
self.editing = edit;
}
Message::Update(image) => {
warn!(?image);
self.update_entire_image(&image);
return Action::UpdateImage(image);
}
Message::PickImage => {
let image_id = self
.image
.as_ref()
.map(|v| v.id)
.unwrap_or_default();
let task = Task::perform(
pick_image(),
move |image_result| {
image_result.map_or(Message::None, |image| {
let mut image = Image::from(image);
image.id = image_id;
Message::Update(image)
})
},
);
return Action::Task(task);
}
Message::None => (),
}
Action::None
}
#[must_use]
pub fn view(&self) -> Element<Message> {
let container = self.image.as_ref().map_or_else(
|| Space::new(0, 0).apply(container),
|pic| widget::image(pic.path.clone()).apply(container),
);
let column = column![
self.toolbar(),
container.center_x(Length::FillPortion(2))
]
.spacing(theme::active().cosmic().space_l());
column.into()
}
fn toolbar(&self) -> Element<Message> {
let title_box = text_input("Title...", &self.title)
.on_input(Message::ChangeTitle);
let image_selector = button::icon(
icon::from_name("folder-images-symbolic").scale(2),
)
.label("Image")
.tooltip("Select a image")
.on_press(Message::PickImage)
.padding(10);
row![
text::body("Title:"),
title_box,
horizontal_space(),
image_selector
]
.align_y(Vertical::Center)
.spacing(10)
.into()
}
#[must_use]
pub const fn editing(&self) -> bool {
self.editing
}
fn update_entire_image(&mut self, image: &Image) {
self.image = Some(image.clone());
self.title.clone_from(&image.title);
}
}
impl Default for ImageEditor {
fn default() -> Self {
Self::new()
}
}
async fn pick_image() -> Result<PathBuf, ImageError> {
let dialog = Dialog::new().title("Choose a image...");
let bg_filter = FileFilter::new("Images")
.extension("png")
.extension("jpeg")
.extension("gif")
.extension("heic")
.extension("webp")
.extension("jpg");
dialog
.filter(bg_filter)
.directory(dirs::home_dir().expect("oops"))
.open_file()
.await
.map_err(|e| {
error!(?e);
ImageError::DialogClosed
})
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(
// "Images and Images",
// &["png", "jpeg", "mp4", "webm", "mkv", "jpg", "mpeg"],
// )
// .set_directory(dirs::home_dir().unwrap())
// .pick_file()
// .await
// .ok_or(ImageError::BackgroundDialogClosed)
// .map(|file| file.path().to_owned())
}
#[derive(Debug, Clone)]
pub enum ImageError {
DialogClosed,
IOError(io::ErrorKind),
}

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,12 @@
use crate::core::model::LibraryKind; use crate::core::model::LibraryKind;
// pub mod double_ended_slider; pub mod double_ended_slider;
pub mod image_editor;
pub mod library; pub mod library;
pub mod presentation_editor;
pub mod presenter; pub mod presenter;
pub mod service;
pub mod slide_editor; pub mod slide_editor;
pub mod song_editor; pub mod song_editor;
pub mod text_svg; pub mod text_svg;
pub mod video; pub mod video;
pub mod video_editor;
pub mod widgets; pub mod widgets;
pub enum EditorMode { pub enum EditorMode {

View file

@ -1,730 +0,0 @@
use std::{
collections::HashMap,
io,
ops::RangeBounds,
path::{Path, PathBuf},
};
use crate::core::presentations::{PresKind, Presentation};
use cosmic::{
Element, Task,
dialog::file_chooser::{FileFilter, open::Dialog},
iced::{Background, ContentFit, Length, alignment::Vertical},
iced_widget::{column, row},
theme,
widget::{
self, Space, button, container, context_menu,
horizontal_space, icon, image::Handle, menu, mouse_area,
scrollable, text, text_input,
},
};
use miette::{IntoDiagnostic, Result, miette};
use mupdf::{Colorspace, Document, Matrix};
use tracing::{debug, error, warn};
#[derive(Debug)]
pub struct PresentationEditor {
pub presentation: Option<Presentation>,
document: Option<Document>,
current_slide: Option<Handle>,
slides: Option<Vec<Handle>>,
page_count: Option<i32>,
current_slide_index: Option<i32>,
title: String,
editing: bool,
hovered_slide: Option<i32>,
context_menu_id: Option<i32>,
}
pub enum Action {
Task(Task<Message>),
UpdatePresentation(Presentation),
SplitAddPresentation((Presentation, Presentation)),
None,
}
#[derive(Debug, Clone)]
pub enum Message {
ChangePresentation(Presentation),
Update(Presentation),
ChangeTitle(String),
PickPresentation,
Edit(bool),
NextPage,
PrevPage,
None,
ChangePresentationFile(Presentation),
AddSlides(Option<Vec<Handle>>),
ChangeSlide(usize),
HoverSlide(Option<i32>),
ContextMenu(usize),
SplitBefore,
SplitAfter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
SplitBefore,
SplitAfter,
}
impl menu::Action for MenuAction {
type Message = Message;
fn message(&self) -> Self::Message {
match self {
Self::SplitBefore => Message::SplitBefore,
Self::SplitAfter => Message::SplitAfter,
}
}
}
impl PresentationEditor {
#[must_use]
pub const fn new() -> Self {
Self {
presentation: None,
document: None,
title: String::new(),
editing: false,
current_slide: None,
current_slide_index: None,
page_count: None,
slides: None,
hovered_slide: None,
context_menu_id: None,
}
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangePresentation(presentation) => {
self.update_entire_presentation(&presentation);
if let Some(presentation) = &self.presentation {
let task;
let path = presentation.path.clone();
if let PresKind::Pdf {
starting_index,
ending_index,
} = presentation.kind.clone()
{
let range = starting_index..=ending_index;
task = Task::perform(
async move { get_pages(range, path) },
Message::AddSlides,
);
} else {
task = Task::perform(
async move { get_pages(.., path) },
Message::AddSlides,
);
}
return Action::Task(task);
}
}
Message::ChangeTitle(title) => {
self.title.clone_from(&title);
if let Some(presentation) = &self.presentation {
let mut presentation = presentation.clone();
presentation.title = title;
return self
.update(Message::Update(presentation));
}
}
Message::Edit(edit) => {
debug!(edit);
self.editing = edit;
}
Message::Update(presentation) => {
warn!(?presentation, "about to update");
return Action::UpdatePresentation(presentation);
}
Message::PickPresentation => {
let presentation_id = self
.presentation
.as_ref()
.map(|v| v.id)
.unwrap_or_default();
let task = Task::perform(
pick_presentation(),
move |presentation_result| {
presentation_result.map_or(
Message::None,
|presentation| {
let mut presentation =
Presentation::from(presentation);
presentation.id = presentation_id;
Message::ChangePresentationFile(
presentation,
)
},
)
},
);
return Action::Task(task);
}
Message::ChangePresentationFile(presentation) => {
self.update_entire_presentation(&presentation);
if let Some(presentation) = &self.presentation {
let mut task;
let path = presentation.path.clone();
if let PresKind::Pdf {
starting_index,
ending_index,
} = presentation.kind.clone()
{
let range = starting_index..=ending_index;
task = Task::perform(
async move { get_pages(range, path) },
Message::AddSlides,
);
} else {
task = Task::perform(
async move { get_pages(.., path) },
Message::AddSlides,
);
}
task = task.chain(Task::done(Message::Update(
presentation.clone(),
)));
return Action::Task(task);
}
}
Message::AddSlides(slides) => {
self.slides = slides;
}
Message::None => (),
Message::NextPage => {
let next_index =
self.current_slide_index.unwrap_or_default() + 1;
let last_index = if let Some(presentation) =
self.presentation.as_ref()
&& let PresKind::Pdf { ending_index, .. } =
presentation.kind
{
ending_index
} else {
self.page_count.unwrap_or_default()
};
if next_index > last_index {
return Action::None;
}
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page = doc.load_page(next_index).ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let Ok(pixmap) = page
.to_pixmap(
&matrix,
&colorspace,
true,
true,
)
.into_diagnostic()
else {
error!(
"Can't turn this page into pixmap"
);
return None;
};
debug!(?pixmap);
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
});
self.current_slide_index = Some(next_index);
}
Message::PrevPage => {
let previous_index =
self.current_slide_index.unwrap_or_default() - 1;
let first_index = if let Some(presentation) =
self.presentation.as_ref()
&& let PresKind::Pdf { starting_index, .. } =
presentation.kind
{
starting_index
} else {
self.page_count.unwrap_or_default()
};
if previous_index < first_index {
return Action::None;
}
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page =
doc.load_page(previous_index).ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(
&matrix,
&colorspace,
true,
true,
)
.ok()?;
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
});
self.current_slide_index = Some(previous_index);
}
Message::ChangeSlide(index) => {
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page = doc
.load_page(i32::try_from(index).ok()?)
.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(
&matrix,
&colorspace,
true,
true,
)
.ok()?;
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
});
self.current_slide_index = i32::try_from(index).ok();
}
Message::HoverSlide(slide) => {
self.hovered_slide = slide;
}
Message::ContextMenu(index) => {
self.context_menu_id = i32::try_from(index).ok();
}
Message::SplitBefore => {
if let Ok((first, second)) = self.split_before() {
debug!(?first, ?second);
self.update_entire_presentation(&first);
return Action::SplitAddPresentation((
first, second,
));
}
}
Message::SplitAfter => {
if let Ok((first, second)) = self.split_after() {
debug!(?first, ?second);
self.update_entire_presentation(&first);
return Action::SplitAddPresentation((
first, second,
));
}
}
}
Action::None
}
pub fn view(&self) -> Element<Message> {
let presentation = self.current_slide.as_ref().map_or_else(
|| container(Space::new(0, 0)),
|slide| {
container(
widget::image(slide)
.content_fit(ContentFit::ScaleDown),
)
.style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
})
},
);
let pdf_pages: Vec<Element<Message>> =
self.slides.as_ref().map_or_else(
|| vec![horizontal_space().into()],
|pages| {
pages
.iter()
.enumerate()
.map(|(index, page)| {
let image = widget::image(page)
.height(
theme::spacing().space_xxxl * 3,
)
.content_fit(ContentFit::ScaleDown);
let slide = container(image).style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
});
let clickable_slide = container(
mouse_area(slide)
.on_enter(Message::HoverSlide(
i32::try_from(index).ok(),
))
.on_exit(Message::HoverSlide(
None,
))
.on_right_press(
Message::ContextMenu(index),
)
.on_press(Message::ChangeSlide(
index,
)),
)
.padding(theme::spacing().space_m)
.clip(true)
.class(self.hovered_slide.map_or(
theme::Container::Card,
|hovered_index| {
if i32::try_from(index).is_ok_and(
|index| {
index == hovered_index
},
) {
theme::Container::Primary
} else {
theme::Container::Card
}
},
));
clickable_slide.into()
})
.collect()
},
);
let pages_column = container(
self.context_menu(
scrollable(
column(pdf_pages)
.spacing(theme::active().cosmic().space_xs())
.padding(theme::spacing().space_xs),
)
.into(),
),
)
.class(theme::Container::Card);
let main_row = row![
pages_column,
container(presentation).center(Length::FillPortion(2))
]
.spacing(theme::spacing().space_xxl);
let control_buttons = row![
button::standard("Previous Page")
.on_press(Message::PrevPage),
horizontal_space(),
button::standard("Next Page").on_press(Message::NextPage),
];
let column =
column![self.toolbar(), main_row, control_buttons]
.spacing(theme::active().cosmic().space_l());
column.into()
}
fn toolbar(&self) -> Element<Message> {
let title_box = text_input(
"Title...",
self.presentation
.as_ref()
.map_or("", |presentation| &presentation.title),
)
.on_input(Message::ChangeTitle);
let presentation_selector = button::icon(
icon::from_name("folder-presentations-symbolic").scale(2),
)
.label("Change Presentation")
.tooltip("Select a presentation")
.on_press(Message::PickPresentation)
.padding(10);
row![
text::body("Title:"),
title_box,
horizontal_space(),
presentation_selector
]
.align_y(Vertical::Center)
.spacing(10)
.into()
}
pub const fn editing(&self) -> bool {
self.editing
}
fn context_menu<'b>(
&self,
items: Element<'b, Message>,
) -> Element<'b, Message> {
if self.context_menu_id.is_some() {
let before_icon =
icon::from_path("./res/split-above.svg".into())
.symbolic(true);
let after_icon =
icon::from_path("./res/split-below.svg".into())
.symbolic(true);
let menu_items = vec![
menu::Item::Button(
"Spit Before",
Some(before_icon),
MenuAction::SplitBefore,
),
menu::Item::Button(
"Split After",
Some(after_icon),
MenuAction::SplitAfter,
),
];
let context_menu = context_menu(
items,
self.context_menu_id.map_or_else(
|| None,
|_| {
Some(menu::items(&HashMap::new(), menu_items))
},
),
);
Element::from(context_menu)
} else {
items
}
}
fn update_entire_presentation(
&mut self,
presentation: &Presentation,
) {
self.presentation = Some(presentation.clone());
self.title.clone_from(&presentation.title);
self.document =
Document::open(&presentation.path.as_path()).ok();
self.page_count = self
.document
.as_ref()
.and_then(|doc| doc.page_count().ok());
warn!("changing presentation");
let pages = if let PresKind::Pdf {
starting_index,
ending_index,
} = presentation.kind
{
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page = doc.load_page(starting_index).ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
});
self.current_slide_index = Some(starting_index);
get_pages(
starting_index..=ending_index,
presentation.path.clone(),
)
} else {
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page = doc.load_page(0).ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
});
self.current_slide_index = Some(0);
get_pages(.., presentation.path.clone())
};
self.slides = pages;
}
fn split_before(&self) -> Result<(Presentation, Presentation)> {
if let Some(index) = self.context_menu_id {
let Some(current_presentation) =
self.presentation.as_ref()
else {
return Err(miette!(
"There is no current presentation"
));
};
let first_presentation = Presentation {
id: current_presentation.id,
title: current_presentation.title.clone(),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { .. } => PresKind::Pdf {
starting_index: 0,
ending_index: index - 1,
},
_ => current_presentation.kind.clone(),
},
};
let second_presentation = Presentation {
id: 0,
title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => {
PresKind::Pdf {
starting_index: index,
ending_index,
}
}
_ => current_presentation.kind.clone(),
},
};
Ok((first_presentation, second_presentation))
} else {
error!("split before no index");
Err(miette!(
"No current index from context menu, has there been a right click on a presentation page"
))
}
}
fn split_after(&self) -> Result<(Presentation, Presentation)> {
if let Some(index) = self.context_menu_id {
let Some(current_presentation) =
self.presentation.as_ref()
else {
return Err(miette!(
"There is no current presentation"
));
};
let first_presentation = Presentation {
id: current_presentation.id,
title: current_presentation.title.clone(),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { .. } => PresKind::Pdf {
starting_index: 0,
ending_index: index,
},
_ => current_presentation.kind.clone(),
},
};
let second_presentation = Presentation {
id: 0,
title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => {
PresKind::Pdf {
starting_index: index + 1,
ending_index,
}
}
_ => current_presentation.kind.clone(),
},
};
Ok((first_presentation, second_presentation))
} else {
error!("split before no index");
Err(miette!(
"No current index from context menu, has there been a right click on a presentation page"
))
}
}
}
impl Default for PresentationEditor {
fn default() -> Self {
Self::new()
}
}
fn get_pages(
range: impl RangeBounds<i32>,
presentation_path: impl AsRef<Path>,
) -> Option<Vec<Handle>> {
let document = Document::open(presentation_path.as_ref()).ok()?;
let pages = document.pages().ok()?;
Some(
pages
.enumerate()
.filter_map(|(index, page)| {
if !range.contains(&i32::try_from(index).expect(
"looking for a pdf index that is way too large",
)) {
return None;
}
let page = page.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
})
.collect(),
)
}
async fn pick_presentation() -> Result<PathBuf, PresentationError> {
let dialog = Dialog::new().title("Choose a presentation...");
let bg_filter = FileFilter::new("Presentations")
.extension("pdf")
.extension("html");
dialog
.filter(bg_filter)
.directory(dirs::home_dir().expect("oops"))
.open_file()
.await
.map_err(|e| {
error!(?e);
PresentationError::DialogClosed
})
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(
// "Presentations and Presentations",
// &["png", "jpeg", "mp4", "webm", "mkv", "jpg", "mpeg"],
// )
// .set_directory(dirs::home_dir().unwrap())
// .pick_file()
// .await
// .ok_or(PresentationError::BackgroundDialogClosed)
// .map(|file| file.path().to_owned())
}
#[derive(Debug, Clone)]
pub enum PresentationError {
DialogClosed,
IOError(io::ErrorKind),
}

File diff suppressed because it is too large Load diff

View file

@ -1,354 +0,0 @@
use cosmic::iced::Size;
use cosmic::iced_core::widget::tree;
use cosmic::{
Element,
iced::{
Event, Length, Point, Rectangle, Vector,
clipboard::dnd::{DndEvent, SourceEvent},
event, mouse,
},
iced_core::{
self, Clipboard, Shell, layout, renderer, widget::Tree,
},
widget::Widget,
};
use tracing::debug;
use crate::core::service_items::ServiceItem;
#[must_use]
pub const fn service<Message: Clone + 'static>(
service: &Vec<ServiceItem>,
) -> Service<'_, Message> {
Service::new(service)
}
pub struct Service<'a, Message> {
items: &'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> {
#[must_use]
pub const fn new(service_items: &'a Vec<ServiceItem>) -> Self {
Self {
items: service_items,
drag_threshold: 8.0,
on_start: None,
on_cancelled: None,
on_finish: None,
width: Length::Fill,
height: Length::Fill,
}
}
#[must_use]
pub const 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,
// );
// }
#[must_use]
pub fn on_start(mut self, on_start: Option<Message>) -> Self {
self.on_start = on_start;
self
}
#[must_use]
pub fn on_cancel(
mut self,
on_cancelled: Option<Message>,
) -> Self {
self.on_cancelled = on_cancelled;
self
}
#[must_use]
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
&& 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.items {}
}
// 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>) -> Self {
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,25 +1,24 @@
use std::{io, path::PathBuf}; use std::{io, path::PathBuf};
use cosmic::{ use iced::{
Renderer,
iced::{Color, Font, Length, Size},
widget::{ widget::{
self, self,
canvas::{self, Program, Stroke}, canvas::{self, Program, Stroke},
container, container, Canvas,
}, },
Color, Font, Length, Renderer, Size,
}; };
use tracing::debug; use tracing::debug;
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct State { struct State {
_cache: canvas::Cache, cache: canvas::Cache,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct SlideEditor { pub struct SlideEditor {
_state: State, state: State,
_font: Font, font: Font,
program: EditorProgram, program: EditorProgram,
} }
@ -35,16 +34,11 @@ pub enum Message {
} }
pub struct Text { pub struct Text {
_text: String, text: String,
}
pub struct Image {
_source: PathBuf,
} }
pub enum SlideWidget { pub enum SlideWidget {
Text(Text), Text(Text),
Image(Image),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -55,14 +49,14 @@ pub enum SlideError {
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct EditorProgram { struct EditorProgram {
_mouse_button_pressed: Option<cosmic::iced::mouse::Button>, mouse_button_pressed: Option<iced::mouse::Button>,
} }
impl SlideEditor { impl SlideEditor {
pub fn view( pub fn view<'a>(
&self, &'a self,
_font: Font, font: Font,
) -> cosmic::Element<'_, SlideWidget> { ) -> iced::Element<'a, SlideWidget> {
container( container(
widget::canvas(&self.program) widget::canvas(&self.program)
.height(Length::Fill) .height(Length::Fill)
@ -72,21 +66,20 @@ impl SlideEditor {
} }
} }
/// Ensure to use the `cosmic::Theme and cosmic::Renderer` here /// Ensure to use the `iced::Theme and iced::Renderer` here
/// or else it will not compile /// or else it will not compile
#[allow(clippy::extra_unused_lifetimes)] impl<'a> Program<SlideWidget, iced::Theme, iced::Renderer>
impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
for EditorProgram for EditorProgram
{ {
type State = (); type State = ();
fn draw( fn draw(
&self, &self,
_state: &Self::State, state: &Self::State,
renderer: &Renderer, renderer: &Renderer,
_theme: &cosmic::Theme, theme: &iced::Theme,
bounds: cosmic::iced::Rectangle, bounds: iced::Rectangle,
_cursor: cosmic::iced_core::mouse::Cursor, cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry<Renderer>> { ) -> Vec<canvas::Geometry<Renderer>> {
// We prepare a new `Frame` // We prepare a new `Frame`
let mut frame = canvas::Frame::new(renderer, bounds.size()); let mut frame = canvas::Frame::new(renderer, bounds.size());
@ -95,7 +88,7 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
// We create a `Path` representing a simple circle // We create a `Path` representing a simple circle
let circle = canvas::Path::circle(frame.center(), 50.0); let circle = canvas::Path::circle(frame.center(), 50.0);
let border = canvas::Path::rectangle( let border = canvas::Path::rectangle(
cosmic::iced::Point { x: 10.0, y: 10.0 }, iced::Point { x: 10.0, y: 10.0 },
Size::new(frame_rect.width, frame_rect.height), Size::new(frame_rect.width, frame_rect.height),
); );
@ -121,21 +114,19 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
fn update( fn update(
&self, &self,
_state: &mut Self::State, _state: &mut Self::State,
event: canvas::Event, event: &iced::Event,
bounds: cosmic::iced::Rectangle, bounds: iced::Rectangle,
_cursor: cosmic::iced_core::mouse::Cursor, _cursor: iced::mouse::Cursor,
) -> (canvas::event::Status, Option<SlideWidget>) { ) -> std::option::Option<iced::widget::Action<SlideWidget>> {
match event { match event {
canvas::Event::Mouse(event) => match event { iced::Event::Mouse(event) => match event {
cosmic::iced::mouse::Event::CursorEntered => { iced::mouse::Event::CursorEntered => {
debug!("cursor entered"); debug!("cursor entered")
} }
cosmic::iced::mouse::Event::CursorLeft => { iced::mouse::Event::CursorLeft => {
debug!("cursor left"); debug!("cursor left")
} }
cosmic::iced::mouse::Event::CursorMoved { iced::mouse::Event::CursorMoved { position } => {
position,
} => {
if bounds.x < position.x if bounds.x < position.x
&& bounds.y < position.y && bounds.y < position.y
&& (bounds.width + bounds.x) > position.x && (bounds.width + bounds.x) > position.x
@ -144,29 +135,34 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
debug!(?position, "cursor moved"); debug!(?position, "cursor moved");
} }
} }
cosmic::iced::mouse::Event::ButtonPressed(button) => { iced::mouse::Event::ButtonPressed(button) => {
// self.mouse_button_pressed = Some(button); // self.mouse_button_pressed = Some(button);
debug!(?button, "mouse button pressed"); debug!(?button, "mouse button pressed")
}
iced::mouse::Event::ButtonReleased(button) => {
debug!(?button, "mouse button released")
}
iced::mouse::Event::WheelScrolled { delta } => {
debug!(?delta, "scroll wheel")
} }
cosmic::iced::mouse::Event::ButtonReleased(
button,
) => debug!(?button, "mouse button released"),
cosmic::iced::mouse::Event::WheelScrolled {
delta,
} => debug!(?delta, "scroll wheel"),
}, },
canvas::Event::Touch(_event) => debug!("test"), iced::Event::Touch(event) => debug!("test"),
canvas::Event::Keyboard(_event) => debug!("test"), iced::Event::Keyboard(event) => debug!("test"),
iced::Event::Keyboard(event) => todo!(),
iced::Event::Mouse(event) => todo!(),
iced::Event::Window(event) => todo!(),
iced::Event::Touch(event) => todo!(),
iced::Event::InputMethod(event) => todo!(),
} }
(canvas::event::Status::Ignored, None) None
} }
fn mouse_interaction( fn mouse_interaction(
&self, &self,
_state: &Self::State, _state: &Self::State,
_bounds: cosmic::iced::Rectangle, _bounds: iced::Rectangle,
_cursor: cosmic::iced_core::mouse::Cursor, _cursor: iced::mouse::Cursor,
) -> cosmic::iced_core::mouse::Interaction { ) -> iced::mouse::Interaction {
cosmic::iced_core::mouse::Interaction::default() iced::mouse::Interaction::default()
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,33 +1,19 @@
use std::{ use std::{
fmt::{Display, Write}, fmt::Display,
fs,
hash::{Hash, Hasher}, hash::{Hash, Hasher},
path::PathBuf,
sync::Arc,
}; };
use cosmic::{ use colors_transform::Rgb;
cosmic_theme::palette::{IntoColor, Srgb, rgb::Rgba}, use iced::{
iced::{ font::{Style, Weight},
ContentFit, Length, Size, widget::{container, svg::Handle, Svg},
font::{Style, Weight}, Element, Length, Size,
},
prelude::*,
widget::{Image, Space, image::Handle},
}; };
use derive_more::Debug;
use miette::{IntoDiagnostic, Result, miette};
use rapidhash::v3::rapidhash_v3;
use resvg::{
tiny_skia::{self, Pixmap},
usvg::{Tree, fontdb},
};
use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use crate::{TextAlignment, core::slide::Slide}; use crate::TextAlignment;
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct TextSvg { pub struct TextSvg {
text: String, text: String,
font: Font, font: Font,
@ -35,25 +21,7 @@ pub struct TextSvg {
stroke: Option<Stroke>, stroke: Option<Stroke>,
fill: Color, fill: Color,
alignment: TextAlignment, alignment: TextAlignment,
pub path: Option<PathBuf>, handle: Option<Handle>,
#[serde(skip)]
pub handle: Option<Handle>,
#[serde(skip)]
#[debug(skip)]
fontdb: Arc<resvg::usvg::fontdb::Database>,
}
impl PartialEq for TextSvg {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.font == other.font
&& self.shadow == other.shadow
&& self.stroke == other.stroke
&& self.fill == other.fill
&& self.alignment == other.alignment
&& self.handle == other.handle
&& self.path == other.path
}
} }
impl Hash for TextSvg { impl Hash for TextSvg {
@ -64,13 +32,10 @@ impl Hash for TextSvg {
self.stroke.hash(state); self.stroke.hash(state);
self.fill.hash(state); self.fill.hash(state);
self.alignment.hash(state); self.alignment.hash(state);
self.path.hash(state);
} }
} }
#[derive( #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct Font { pub struct Font {
name: String, name: String,
weight: Weight, weight: Weight,
@ -78,38 +43,11 @@ pub struct Font {
size: u8, size: u8,
} }
#[derive( impl From<iced::font::Font> for Font {
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize, fn from(value: iced::font::Font) -> Self {
)]
pub struct Shadow {
pub offset_x: i16,
pub offset_y: i16,
pub spread: u16,
pub color: Color,
}
#[derive(
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize,
)]
pub struct Stroke {
size: u16,
color: Color,
}
pub enum Message {
None,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Color(Srgb);
impl From<cosmic::font::Font> for Font {
fn from(value: cosmic::font::Font) -> Self {
Self { Self {
name: match value.family { name: match value.family {
cosmic::iced::font::Family::Name(name) => { iced::font::Family::Name(name) => name.to_string(),
name.to_string()
}
_ => "Quicksand Bold".into(), _ => "Quicksand Bold".into(),
}, },
size: 20, size: 20,
@ -137,100 +75,72 @@ impl From<&str> for Font {
} }
impl Font { impl Font {
#[must_use]
pub fn get_name(&self) -> String { pub fn get_name(&self) -> String {
self.name.clone() self.name.clone()
} }
#[must_use] pub fn get_weight(&self) -> Weight {
pub const fn get_weight(&self) -> Weight {
self.weight self.weight
} }
#[must_use] pub fn get_style(&self) -> Style {
pub const fn get_style(&self) -> Style {
self.style self.style
} }
#[must_use]
pub fn weight(mut self, weight: impl Into<Weight>) -> Self { pub fn weight(mut self, weight: impl Into<Weight>) -> Self {
self.weight = weight.into(); self.weight = weight.into();
self self
} }
#[must_use]
pub fn style(mut self, style: impl Into<Style>) -> Self { pub fn style(mut self, style: impl Into<Style>) -> Self {
self.style = style.into(); self.style = style.into();
self self
} }
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self { pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = name.into(); self.name = name.into();
self self
} }
#[must_use] pub fn size(mut self, size: u8) -> Self {
pub const fn size(mut self, size: u8) -> Self {
self.size = size; self.size = size;
self self
} }
} }
#[derive(Clone, Debug, PartialEq)]
pub struct Color(Rgb);
impl Hash for Color { impl Hash for Color {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.to_css_hex_string().hash(state); self.0.to_css_hex_string().hash(state);
} }
} }
impl Color { impl Color {
#[must_use] pub fn from_hex_str(color: impl AsRef<str>) -> Color {
pub fn to_css_hex_string(&self) -> String { match Rgb::from_hex_str(color.as_ref()) {
format!("#{:x}", self.0.into_format::<u8>()) Ok(rgb) => Color(rgb),
}
#[must_use]
pub fn from_hex_str(color: impl AsRef<str>) -> Self {
let color = color.as_ref();
let color: Result<Srgb<u8>> = color.parse().into_diagnostic();
match color {
Ok(srgb) => Self(srgb.into()),
Err(e) => { Err(e) => {
error!("error in making color from hex_str: {:?}", e); error!("error in making color from hex_str: {:?}", e);
Self::default() Color::default()
} }
} }
} }
} }
impl From<Rgba> for Color {
fn from(value: Rgba) -> Self {
let rgba: Srgb = value.into_color();
Self(rgba)
}
}
impl From<Srgb> for Color {
fn from(value: Srgb) -> Self {
Self(value)
}
}
impl From<&str> for Color { impl From<&str> for Color {
fn from(value: &str) -> Self { fn from(value: &str) -> Self {
Self::from_hex_str(value) Self::from_hex_str(value)
} }
} }
impl From<String> for Color {
fn from(value: String) -> Self {
Self::from_hex_str(value)
}
}
impl Default for Color { impl Default for Color {
fn default() -> Self { fn default() -> Self {
Self(Srgb::new(0.0, 0.0, 0.0)) Self(
Rgb::from_hex_str("#000")
.expect("This is not a hex color"),
)
} }
} }
@ -239,12 +149,29 @@ impl Display for Color {
&self, &self,
f: &mut std::fmt::Formatter<'_>, f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result { ) -> std::fmt::Result {
write!(f, "{}", self.to_css_hex_string()) write!(f, "{}", self.0.to_css_hex_string())
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Hash)]
pub struct Shadow {
pub offset_x: i16,
pub offset_y: i16,
pub spread: u16,
pub color: Color,
}
#[derive(Clone, Debug, Default, PartialEq, Hash)]
pub struct Stroke {
size: u16,
color: Color,
}
pub enum Message {
None,
}
impl TextSvg { impl TextSvg {
#[must_use]
pub fn new(text: impl Into<String>) -> Self { pub fn new(text: impl Into<String>) -> Self {
Self { Self {
text: text.into(), text: text.into(),
@ -254,279 +181,114 @@ impl TextSvg {
// pub fn build(self) // pub fn build(self)
#[must_use]
pub fn fill(mut self, color: impl Into<Color>) -> Self { pub fn fill(mut self, color: impl Into<Color>) -> Self {
self.fill = color.into(); self.fill = color.into();
self self
} }
#[must_use]
pub fn shadow(mut self, shadow: impl Into<Shadow>) -> Self { pub fn shadow(mut self, shadow: impl Into<Shadow>) -> Self {
self.shadow = Some(shadow.into()); self.shadow = Some(shadow.into());
self self
} }
#[must_use]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self { pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = Some(stroke.into()); self.stroke = Some(stroke.into());
self self
} }
#[must_use]
pub fn font(mut self, font: impl Into<Font>) -> Self { pub fn font(mut self, font: impl Into<Font>) -> Self {
self.font = font.into(); self.font = font.into();
self self
} }
#[must_use]
pub fn text(mut self, text: impl AsRef<str>) -> Self { pub fn text(mut self, text: impl AsRef<str>) -> Self {
self.text = text.as_ref().to_string(); self.text = text.as_ref().to_string();
self self
} }
#[must_use] pub fn alignment(mut self, alignment: TextAlignment) -> Self {
pub fn fontdb(mut self, fontdb: Arc<fontdb::Database>) -> Self {
self.fontdb = fontdb;
self
}
#[must_use]
pub const fn alignment(
mut self,
alignment: TextAlignment,
) -> Self {
self.alignment = alignment; self.alignment = alignment;
self self
} }
#[must_use] pub fn build(mut self) -> Self {
#[allow(clippy::cast_possible_truncation)] let shadow = if let Some(shadow) = &self.shadow {
#[allow(clippy::cast_precision_loss)] format!("<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
#[allow(clippy::too_many_lines)]
pub fn build(
mut self,
size: Size,
mut cache: Option<PathBuf>,
) -> Self {
// debug!("starting...");
let mut final_svg = String::with_capacity(1024);
let font_scale = size.height / 1080.0;
let font_size = f32::from(self.font.size) * font_scale;
let total_lines = self.text.lines().count();
let half_lines = (total_lines / 2) as f32;
let line_spacing = 10.0;
let text_and_line_spacing = font_size + line_spacing;
let center_y = (size.width / 2.0).to_string();
let x_width_padded = (size.width - 10.0).to_string();
let (text_anchor, starting_y_position, text_x_position) =
match self.alignment {
TextAlignment::TopLeft => ("start", font_size, "10"),
TextAlignment::TopCenter => {
("middle", font_size, center_y.as_str())
}
TextAlignment::TopRight => {
("end", font_size, x_width_padded.as_str())
}
TextAlignment::MiddleLeft => {
let middle_position = size.height / 2.0;
let position = half_lines.mul_add(
-text_and_line_spacing,
middle_position,
);
("start", position, "10")
}
TextAlignment::MiddleCenter => {
let middle_position = size.height / 2.0;
let position = half_lines.mul_add(
-text_and_line_spacing,
middle_position,
);
("middle", position, center_y.as_str())
}
TextAlignment::MiddleRight => {
let middle_position = size.height / 2.0;
let position = half_lines.mul_add(
-text_and_line_spacing,
middle_position,
);
("end", position, x_width_padded.as_str())
}
TextAlignment::BottomLeft => {
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("start", position, "10")
}
TextAlignment::BottomCenter => {
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("middle", position, center_y.as_str())
}
TextAlignment::BottomRight => {
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("end", position, x_width_padded.as_str())
}
};
let font_style = match self.font.style {
Style::Normal => "normal",
Style::Italic => "italic",
Style::Oblique => "oblique",
};
let font_weight = match self.font.weight {
Weight::Thin | Weight::ExtraLight | Weight::Light => {
"lighter"
}
Weight::Normal | Weight::Medium => "normal",
Weight::Semibold | Weight::Bold => "bold",
Weight::ExtraBold | Weight::Black => "bolder",
};
let _ = write!(
final_svg,
"<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\"><defs>",
size.width, size.height, size.width, size.height
);
if let Some(shadow) = &self.shadow {
let _ = write!(
final_svg,
"<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
shadow.offset_x, shadow.offset_x,
shadow.offset_y, shadow.offset_y,
shadow.spread, shadow.spread,
shadow.color shadow.color)
); } else {
} "".into()
final_svg.push_str("</defs>"); };
let stroke = if let Some(stroke) = &self.stroke {
// This would be how to apply kerning format!(
// final_svg.push_str(
// "<style> text { letter-spacing: 0em; } </style>",
// );
let _ = write!(
final_svg,
"<text x=\"0\" y=\"50%\" transform=\"translate({}, 0)\" dominant-baseline=\"middle\" text-anchor=\"{}\" font-style=\"{}\" font-weight=\"{}\" font-family=\"{}\" font-size=\"{}\" fill=\"{}\" ",
text_x_position,
text_anchor,
font_style,
font_weight,
self.font.name,
font_size,
self.fill
);
if let Some(stroke) = &self.stroke {
let _ = write!(
final_svg,
"stroke=\"{}\" stroke-width=\"{}px\" stroke-linejoin=\"arcs\" paint-order=\"stroke\"", "stroke=\"{}\" stroke-width=\"{}px\" stroke-linejoin=\"arcs\" paint-order=\"stroke\"",
stroke.color, stroke.size stroke.color, stroke.size
); )
} } else {
"".into()
};
let size = Size::new(640.0, 360.0);
let total_lines = self.text.lines().count();
let half_lines = (total_lines / 2) as f32;
let middle_position = size.height / 2.0;
let line_spacing = 10.0;
let text_and_line_spacing =
self.font.size as f32 + line_spacing;
let starting_y_position =
middle_position - (half_lines * text_and_line_spacing);
if self.shadow.is_some() { let text_pieces: Vec<String> = self
final_svg.push_str(" style=\"filter:url(#shadow);\""); .text
} .lines()
final_svg.push('>'); .enumerate()
.map(|(index, text)| {
for (index, text) in self.text.lines().enumerate() { format!(
let _ = write!( "<tspan x=\"50%\" y=\"{}\">{}</tspan>",
final_svg,
"<tspan x=\"0\" y=\"{}\">{}</tspan>",
(index as f32).mul_add(
text_and_line_spacing,
starting_y_position starting_y_position
), + (index as f32 * text_and_line_spacing),
text text
); )
} })
.collect();
let text: String = text_pieces.join("\n");
final_svg.push_str("</text></svg>"); 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,
// final_svg.push_str(&format!( size.height,
// "<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>", shadow,
// size.width, self.font.name,
// size.height, self.font.size,
// shadow, self.fill, stroke, text);
// self.font.name, let handle = Handle::from_memory(
// font_size, Box::leak(
// self.fill, <std::string::String as Clone>::clone(&final_svg)
// stroke, .into_boxed_str(),
// text )
// )); .as_bytes(),
);
if let Some(path) = cache.as_mut() {
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");
let handle = Handle::from_path(&path);
self.path = Some(path.clone());
self.handle = Some(handle);
return self;
}
}
// debug!("text string built...");
let Ok(resvg_tree) = Tree::from_data(
final_svg.as_bytes(),
&resvg::usvg::Options {
fontdb: Arc::clone(&self.fontdb),
..Default::default()
},
) else {
error!("Couldn't parse the svg into a tree");
return self;
};
// debug!("parsed");
let transform = tiny_skia::Transform::default();
#[allow(clippy::cast_sign_loss)]
let (size_width, size_height) =
(size.width as u32, size.height as u32);
let Some(mut pixmap) = Pixmap::new(size_width, size_height)
else {
error!("Couldn't create a new pixmap from size");
return self;
};
resvg::render(&resvg_tree, transform, &mut pixmap.as_mut());
// debug!("rendered");
if let Some(path) = cache.as_ref()
&& let Err(e) = pixmap.save_png(path)
{
error!(?e, "Couldn't save a copy of the text");
}
self.path = cache;
// debug!("saved");
// let handle = Handle::from_path(path);
let handle =
Handle::from_rgba(size_width, size_height, pixmap.take());
self.handle = Some(handle); self.handle = Some(handle);
// debug!("stored");
self self
} }
pub fn view<'a>(&self) -> Element<'a, Message> { pub fn view<'a>(&self) -> Element<'a, Message> {
self.handle.clone().map_or_else( container(
|| Element::from(Space::new(Length::Fill, Length::Fill)), Svg::new(self.handle.clone().unwrap())
|handle| { .width(Length::Fill)
Image::new(handle) .height(Length::Fill),
.content_fit(ContentFit::Cover)
.width(Length::Fill)
.height(Length::Fill)
.into()
},
) )
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn text_spans(&self) -> Vec<String> {
self.text
.lines()
.enumerate()
.map(|(i, t)| format!("<tspan x=\"50%\">{}</tspan>", t))
.collect()
} }
} }
@ -555,92 +317,26 @@ pub fn color(color: impl AsRef<str>) -> Color {
Color::from_hex_str(color) Color::from_hex_str(color)
} }
pub fn text_svg_generator(
slide: crate::core::slide::Slide,
fontdb: &Arc<fontdb::Database>,
) -> Result<Slide> {
let Some(mut path) = dirs::cache_dir() else {
error!("Cannot find the cache dir");
return Err(miette!("Cannot find the cache dir"));
};
path.push("lumina");
path.push("text_svg_cache");
let _ = fs::create_dir_all(&path);
text_svg_generator_with_cache(slide, fontdb, Some(path))
}
pub fn text_svg_generator_with_cache(
mut slide: crate::core::slide::Slide,
fontdb: &Arc<fontdb::Database>,
cache: Option<PathBuf>,
) -> Result<Slide> {
if slide.text().is_empty() {
Err(miette!("There is no slide text"))
} else {
let font = slide.font().unwrap_or_default();
let text_svg = TextSvg::new(slide.text())
.alignment(slide.text_alignment())
.fill(
slide.text_color().unwrap_or_else(|| "#fff".into()),
);
let text_svg = if let Some(stroke) = slide.stroke() {
text_svg.stroke(stroke)
} else {
text_svg
};
let text_svg = if let Some(shadow) = slide.shadow() {
text_svg.shadow(shadow)
} else {
text_svg
};
let text_svg = text_svg.font(font).fontdb(Arc::clone(fontdb));
// debug!(fill = ?text_svg.fill, font = ?text_svg.font, stroke = ?text_svg.stroke, shadow = ?text_svg.shadow, text = ?text_svg.text);
let text_svg =
text_svg.build(Size::new(1280.0, 720.0), cache);
slide.text_svg = Some(text_svg);
Ok(slide)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod test {
use crate::core::slide::Slide; use pretty_assertions::assert_eq;
use super::*; use super::TextSvg;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use resvg::usvg::fontdb::Database;
use tracing::debug;
#[test] #[test]
fn test_generator() { fn test_text_spans() {
let slide = Slide::default(); let mut text = TextSvg::new("yes");
debug!("test"); text.text = "This is
let mut fontdb = Database::new(); multiline
fontdb.load_system_fonts(); text."
let fontdb = Arc::new(fontdb); .into();
(0..400).into_par_iter().for_each(|_| { assert_eq!(
let slide = slide vec![
.clone() String::from("<tspan>This is</tspan>"),
.set_font_size(120) String::from("<tspan>multiline</tspan>"),
.set_font("") String::from("<tspan>text.</tspan>"),
.set_shadow(shadow(5, 5, 5, "#000")) ],
.set_stroke(stroke(9, "#000")) text.text_spans()
.set_text("This is the first slide of text\nAnd we are singing\nTo save the world!"); )
match text_svg_generator_with_cache(
slide,
&fontdb,
None,
) {
Ok(slide) => {
assert!(
slide
.text_svg
.is_some_and(|svg| svg.handle.is_some())
)
},
Err(e) => assert!(false, "There was an issue creating the TextSvg: {e}"),
};
});
} }
} }

View file

@ -1,237 +0,0 @@
use std::{io, path::PathBuf};
use cosmic::{
Element, Task,
dialog::file_chooser::{FileFilter, open::Dialog},
iced::{Length, alignment::Vertical},
iced_widget::{column, row},
theme,
widget::{
Space, button, container, horizontal_space, icon,
progress_bar, text, text_input,
},
};
use iced_video_player::{Video, VideoPlayer};
use tracing::{debug, error, warn};
use url::Url;
use crate::core::videos;
#[derive(Debug)]
pub struct VideoEditor {
pub video: Option<Video>,
core_video: Option<videos::Video>,
title: String,
editing: bool,
}
pub enum Action {
Task(Task<Message>),
UpdateVideo(videos::Video),
None,
}
#[derive(Debug, Clone)]
pub enum Message {
ChangeVideo(videos::Video),
Update(videos::Video),
ChangeTitle(String),
PickVideo,
Edit(bool),
None,
PauseVideo,
UpdateVideoFile(videos::Video),
}
impl VideoEditor {
#[must_use]
pub fn new() -> Self {
Self {
video: None,
core_video: None,
title: "Death was Arrested".to_string(),
editing: false,
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangeVideo(video) => {
self.update_entire_video(&video);
}
Message::ChangeTitle(title) => {
self.title.clone_from(&title);
if let Some(video) = &self.core_video {
let mut video = video.clone();
video.title = title;
return self.update(Message::Update(video));
}
}
Message::Edit(edit) => {
debug!(edit);
self.editing = edit;
}
Message::PauseVideo => {
if let Some(video) = &mut self.video {
let paused = video.paused();
video.set_paused(!paused);
}
}
Message::Update(video) => {
warn!(?video);
return Action::UpdateVideo(video);
}
Message::PickVideo => {
let video_id = self
.core_video
.as_ref()
.map(|v| v.id)
.unwrap_or_default();
let task = Task::perform(
pick_video(),
move |video_result| {
video_result.map_or(Message::None, |video| {
let mut video =
videos::Video::from(video);
video.id = video_id;
Message::UpdateVideoFile(video)
})
},
);
return Action::Task(task);
}
Message::UpdateVideoFile(video) => {
self.update_entire_video(&video);
return Action::UpdateVideo(video);
}
Message::None => (),
}
Action::None
}
pub fn view(&self) -> Element<Message> {
let video_elements = self.video.as_ref().map_or_else(
|| container(horizontal_space()),
|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))
},
);
let video_player = self.video.as_ref().map_or_else(
|| Element::from(Space::new(0, 0)),
|video| Element::from(VideoPlayer::new(video)),
);
let video_section = column![video_player, video_elements]
.spacing(cosmic::theme::spacing().space_s);
let column = column![
self.toolbar(),
container(video_section).center_x(Length::FillPortion(2))
]
.spacing(theme::active().cosmic().space_l());
column.into()
}
fn toolbar(&self) -> Element<Message> {
let title_box = text_input("Title...", &self.title)
.on_input(Message::ChangeTitle);
let video_selector = button::icon(
icon::from_name("folder-videos-symbolic").scale(2),
)
.label("Video")
.tooltip("Select a video")
.on_press(Message::PickVideo)
.padding(10);
row![
text::body("Title:"),
title_box,
horizontal_space(),
video_selector
]
.align_y(Vertical::Center)
.spacing(10)
.into()
}
pub const fn editing(&self) -> bool {
self.editing
}
fn update_entire_video(&mut self, video: &videos::Video) {
let Ok(mut player_video) =
Url::from_file_path(video.path.clone())
.map(|url| Video::new(&url).expect("Should be here"))
else {
self.video = None;
self.title.clone_from(&video.title);
self.core_video = Some(video.clone());
return;
};
player_video.set_paused(true);
self.video = Some(player_video);
self.title.clone_from(&video.title);
self.core_video = Some(video.clone());
}
}
impl Default for VideoEditor {
fn default() -> Self {
Self::new()
}
}
async fn pick_video() -> Result<PathBuf, VideoError> {
let dialog = Dialog::new().title("Choose a video...");
let bg_filter = FileFilter::new("Videos")
.extension("mp4")
.extension("webm")
.extension("mkv");
dialog
.filter(bg_filter)
.directory(dirs::home_dir().expect("oops"))
.open_file()
.await
.map_err(|e| {
error!(?e);
VideoError::DialogClosed
})
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// 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(VideoError::BackgroundDialogClosed)
// .map(|file| file.path().to_owned())
}
#[derive(Debug, Clone)]
pub enum VideoError {
DialogClosed,
IOError(io::ErrorKind),
}

View file

@ -1,914 +0,0 @@
//! 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::Theme;
use cosmic::iced::advanced::layout::{self, Layout};
use cosmic::iced::advanced::widget::{Operation, Tree, Widget, tree};
use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer};
use cosmic::iced::alignment::{self, Alignment};
use cosmic::iced::event::{self, Event};
use cosmic::iced::{self, Transformation, mouse};
use cosmic::iced::{
Background, Border, Color, Element, Length, Padding, Pixels,
Point, Rectangle, Size, Vector,
};
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`].
#[must_use]
pub fn new() -> Self {
Self::from_vec(Vec::new())
}
/// Creates a [`Column`] with the given capacity.
#[must_use]
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.
#[must_use]
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 const fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Sets the drag deadband zone of the [`Column`].
pub const 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);
}
// 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<Message, Renderer> Default
for Column<'_, 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<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Column<'_, 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()
&& 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)
}
}
#[must_use]
pub fn default(theme: &Theme) -> Style {
Style {
scale: 1.05,
moved_item_overlay: Color::from(theme.cosmic().primary.base)
.scale_alpha(0.2),
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

@ -1,42 +0,0 @@
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

@ -0,0 +1,115 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use super::{Icon, Named};
use iced::widget::{image, svg};
use std::borrow::Cow;
use std::ffi::OsStr;
use std::hash::Hash;
use std::path::PathBuf;
#[must_use]
#[derive(Clone, Debug, derive_setters::Setters)]
pub struct Handle {
pub symbolic: bool,
#[setters(skip)]
pub data: Data,
}
impl Handle {
#[inline]
pub fn icon(self) -> Icon {
super::icon(self)
}
}
#[must_use]
#[derive(Clone, Debug)]
pub enum Data {
Name(Named),
Image(image::Handle),
Svg(svg::Handle),
}
/// Create an icon handle from its path.
pub fn from_path(path: PathBuf) -> Handle {
Handle {
symbolic: path
.file_stem()
.and_then(OsStr::to_str)
.is_some_and(|name| name.ends_with("-symbolic")),
data: if path
.extension()
.is_some_and(|ext| ext == OsStr::new("svg"))
{
Data::Svg(svg::Handle::from_path(path))
} else {
Data::Image(image::Handle::from_path(path))
},
}
}
/// Create an image handle from memory.
pub fn from_raster_bytes(
bytes: impl Into<Cow<'static, [u8]>>
+ std::convert::AsRef<[u8]>
+ std::marker::Send
+ std::marker::Sync
+ 'static,
) -> Handle {
fn inner(bytes: Cow<'static, [u8]>) -> Handle {
Handle {
symbolic: false,
data: match bytes {
Cow::Owned(b) => {
Data::Image(image::Handle::from_bytes(b))
}
Cow::Borrowed(b) => {
Data::Image(image::Handle::from_bytes(b))
}
},
}
}
inner(bytes.into())
}
/// Create an image handle from RGBA data, where you must define the width and height.
pub fn from_raster_pixels(
width: u32,
height: u32,
pixels: impl Into<Cow<'static, [u8]>>
+ std::convert::AsRef<[u8]>
+ std::marker::Send
+ std::marker::Sync,
) -> Handle {
fn inner(
width: u32,
height: u32,
pixels: Cow<'static, [u8]>,
) -> Handle {
Handle {
symbolic: false,
data: match pixels {
Cow::Owned(pixels) => Data::Image(
image::Handle::from_rgba(width, height, pixels),
),
Cow::Borrowed(pixels) => Data::Image(
image::Handle::from_rgba(width, height, pixels),
),
},
}
}
inner(width, height, pixels.into())
}
/// Create a SVG handle from memory.
pub fn from_svg_bytes(
bytes: impl Into<Cow<'static, [u8]>>,
) -> Handle {
Handle {
symbolic: false,
data: Data::Svg(svg::Handle::from_memory(bytes)),
}
}

187
src/ui/widgets/icon/mod.rs Normal file
View file

@ -0,0 +1,187 @@
// Copyright 2022 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
//! Lazily-generated SVG icon widget for Iced.
mod named;
use std::ffi::OsStr;
use std::sync::Arc;
pub use named::{IconFallback, Named};
mod handle;
pub use handle::{
from_path, from_raster_bytes, from_raster_pixels, from_svg_bytes,
Data, Handle,
};
use derive_setters::Setters;
use iced::advanced::{image, svg};
use iced::widget::{Image, Svg};
use iced::Element;
use iced::Rotation;
use iced::{ContentFit, Length, Rectangle};
/// Create an [`Icon`] from a pre-existing [`Handle`]
pub fn icon(handle: Handle) -> Icon {
Icon {
content_fit: ContentFit::Fill,
handle,
height: None,
size: 16,
rotation: None,
width: None,
}
}
/// Create an icon handle from its XDG icon name.
pub fn from_name(name: impl Into<Arc<str>>) -> Named {
Named::new(name)
}
/// An image which may be an SVG or PNG.
#[must_use]
#[derive(Clone, Setters)]
pub struct Icon {
#[setters(skip)]
handle: Handle,
pub(super) size: u16,
content_fit: ContentFit,
#[setters(strip_option)]
width: Option<Length>,
#[setters(strip_option)]
height: Option<Length>,
#[setters(strip_option)]
rotation: Option<Rotation>,
}
impl Icon {
#[must_use]
pub fn into_svg_handle(
self,
) -> Option<iced::widget::svg::Handle> {
match self.handle.data {
Data::Name(named) => {
if let Some(path) = named.path() {
if path
.extension()
.is_some_and(|ext| ext == OsStr::new("svg"))
{
return Some(
iced::advanced::svg::Handle::from_path(
path,
),
);
}
}
}
Data::Image(_) => (),
Data::Svg(handle) => return Some(handle),
}
None
}
#[must_use]
fn view<'a, Message: 'a>(self) -> Element<'a, Message> {
let from_image = |handle| {
Image::new(handle)
.width(self.width.unwrap_or_else(|| {
Length::Fixed(f32::from(self.size))
}))
.height(self.height.unwrap_or_else(|| {
Length::Fixed(f32::from(self.size))
}))
.rotation(self.rotation.unwrap_or_default())
.content_fit(self.content_fit)
.into()
};
let from_svg = |handle| {
Svg::<crate::Theme>::new(handle)
.width(self.width.unwrap_or_else(|| {
Length::Fixed(f32::from(self.size))
}))
.height(self.height.unwrap_or_else(|| {
Length::Fixed(f32::from(self.size))
}))
.rotation(self.rotation.unwrap_or_default())
.content_fit(self.content_fit)
.into()
};
match self.handle.data {
Data::Name(named) => {
if let Some(path) = named.path() {
if path
.extension()
.is_some_and(|ext| ext == OsStr::new("svg"))
{
from_svg(svg::Handle::from_path(path))
} else {
from_image(image::Handle::from_path(path))
}
} else {
let bytes: &'static [u8] = &[];
from_svg(svg::Handle::from_memory(bytes))
}
}
Data::Image(handle) => from_image(handle),
Data::Svg(handle) => from_svg(handle),
}
}
}
impl<'a, Message: 'a> From<Icon> for Element<'a, Message> {
fn from(icon: Icon) -> Self {
icon.view::<Message>()
}
}
/// Draw an icon in the given bounds via the runtime's renderer.
pub fn draw(
renderer: &mut iced::Renderer,
handle: &Handle,
icon_bounds: Rectangle,
) {
enum IcedHandle {
Svg(svg::Handle),
Image(image::Handle),
}
let iced_handle = match handle.clone().data {
Data::Name(named) => named.path().map(|path| {
if path
.extension()
.is_some_and(|ext| ext == OsStr::new("svg"))
{
IcedHandle::Svg(svg::Handle::from_path(path))
} else {
IcedHandle::Image(image::Handle::from_path(path))
}
}),
Data::Image(handle) => Some(IcedHandle::Image(handle)),
Data::Svg(handle) => Some(IcedHandle::Svg(handle)),
};
match iced_handle {
Some(IcedHandle::Svg(handle)) => svg::Renderer::draw_svg(
renderer,
svg::Svg::new(handle),
icon_bounds,
),
Some(IcedHandle::Image(handle)) => {
image::Renderer::draw_image(
renderer,
(&handle).into(),
icon_bounds,
);
}
None => {}
}
}

View file

@ -0,0 +1,165 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use super::{Handle, Icon};
use std::{borrow::Cow, path::PathBuf, sync::Arc};
#[derive(Debug, Clone, Default, Hash)]
/// Fallback icon to use if the icon was not found.
pub enum IconFallback {
#[default]
/// Default fallback using the icon name.
Default,
/// Fallback to specific icon names.
Names(Vec<Cow<'static, str>>),
}
#[must_use]
#[derive(derive_setters::Setters, Clone, Debug, Hash)]
pub struct Named {
/// Name of icon to locate in an XDG icon path.
pub(super) name: Arc<str>,
/// Checks for a fallback if the icon was not found.
pub fallback: Option<IconFallback>,
/// Restrict the lookup to a given scale.
#[setters(strip_option)]
pub scale: Option<u16>,
/// Restrict the lookup to a given size.
#[setters(strip_option)]
pub size: Option<u16>,
/// Whether the icon is symbolic or not.
pub symbolic: bool,
/// Prioritizes SVG over PNG
pub prefer_svg: bool,
}
impl Named {
pub fn new(name: impl Into<Arc<str>>) -> Self {
let name = name.into();
Self {
symbolic: name.ends_with("-symbolic"),
name,
fallback: Some(IconFallback::Default),
size: None,
scale: None,
prefer_svg: false,
}
}
#[cfg(not(windows))]
#[must_use]
pub fn path(self) -> Option<PathBuf> {
let name = &*self.name;
let fallback = &self.fallback;
let locate = |theme: &str, name| {
let mut lookup = freedesktop_icons::lookup(name)
.with_theme(theme.as_ref())
.with_cache();
if let Some(scale) = self.scale {
lookup = lookup.with_scale(scale);
}
if let Some(size) = self.size {
lookup = lookup.with_size(size);
}
if self.prefer_svg {
lookup = lookup.force_svg();
}
lookup.find()
};
let theme = "Papirus-Dark";
let themes = if theme.as_ref() == "Cosmic" {
vec![theme.as_ref()]
} else {
vec![theme.as_ref(), "Cosmic"]
};
let mut result = themes.iter().find_map(|t| locate(t, name));
// On failure, attempt to locate fallback icon.
if result.is_none() {
if matches!(fallback, Some(IconFallback::Default)) {
for new_name in name
.rmatch_indices('-')
.map(|(pos, _)| &name[..pos])
{
result = themes
.iter()
.find_map(|t| locate(t, new_name));
if result.is_some() {
break;
}
}
} else if let Some(IconFallback::Names(fallbacks)) =
fallback
{
for fallback in fallbacks {
result = themes
.iter()
.find_map(|t| locate(t, fallback));
if result.is_some() {
break;
}
}
}
}
result
}
#[cfg(windows)]
#[must_use]
pub fn path(self) -> Option<PathBuf> {
//TODO: implement icon lookup for Windows
None
}
#[inline]
pub fn handle(self) -> Handle {
Handle {
symbolic: self.symbolic,
data: super::Data::Name(self),
}
}
#[inline]
pub fn icon(self) -> Icon {
let size = self.size;
let icon = super::icon(self.handle());
match size {
Some(size) => icon.size(size),
None => icon,
}
}
}
impl From<Named> for Handle {
#[inline]
fn from(builder: Named) -> Self {
builder.handle()
}
}
impl From<Named> for Icon {
#[inline]
fn from(builder: Named) -> Self {
builder.icon()
}
}
impl<Message: 'static> From<Named> for crate::Element<'_, Message> {
#[inline]
fn from(builder: Named) -> Self {
builder.icon().into()
}
}

View file

@ -1,6 +1,2 @@
#[allow(clippy::unwrap_used)] // pub mod slide_text;
#[allow(clippy::nursery)] pub mod icon;
#[allow(clippy::pedantic)]
pub mod draggable;
pub mod slide_text;
pub mod verse_editor;

View file

@ -1,36 +1,69 @@
use cosmic::iced::advanced::layout::{self, Layout}; use femtovg::renderer::WGPURenderer;
use cosmic::iced::advanced::renderer; use femtovg::{Canvas, TextContext};
use cosmic::iced::advanced::widget::{self, Widget}; use iced::iced::advanced::layout::{self, Layout};
use cosmic::iced::border; use iced::iced::advanced::renderer;
use cosmic::iced::mouse; use iced::iced::advanced::widget::{self, Widget};
use cosmic::iced::{Color, Element, Length, Rectangle, Size}; use iced::iced::border;
use cosmic::iced_wgpu::Primitive; use iced::iced::mouse;
use cosmic::iced_wgpu::primitive::Renderer as PrimitiveRenderer; use iced::iced::{Color, Element, Length, Rectangle, Size};
pub struct SlideText { pub struct SlideText {
_text: String, text: String,
font_size: f32, font_size: f32,
canvas: Canvas<WGPURenderer>,
} }
impl SlideText { impl SlideText {
pub fn new(text: impl AsRef<str>) -> Self { pub async fn new(text: &str) -> Self {
let text = text.as_ref(); let backends = wgpu::Backends::PRIMARY;
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
..Default::default()
});
let surface =
instance.create_surface(window.clone()).unwrap();
let adapter = iced::iced::wgpu::util::initialize_adapter_from_env_or_default(&instance, Some(&surface))
.await
.expect("Failed to find an appropriate adapter");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: None,
required_features: adapter.features(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.expect("failed to device it");
let renderer = WGPURenderer::new(device, queue);
let canvas =
Canvas::new_with_text_context(renderer, text_context)
.expect("oops femtovg");
Self { Self {
_text: text.to_string(), text: text.to_owned(),
font_size: 50.0, font_size: 50.0,
canvas,
} }
} }
} }
pub fn slide_text(text: impl AsRef<str>) -> SlideText { fn get_canvas(text_context: TextContext) -> Canvas {
let renderer = WGPURenderer::new(device, queue);
Canvas::new_with_text_context(renderer, text_context)
.expect("oops femtovg")
}
pub fn slide_text(text: &str) -> SlideText {
SlideText::new(text) SlideText::new(text)
} }
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for SlideText for SlideText
where where
Message: Clone, Renderer: renderer::Renderer,
Renderer: PrimitiveRenderer,
{ {
fn size(&self) -> Size<Length> { fn size(&self) -> Size<Length> {
Size { Size {
@ -61,8 +94,6 @@ where
_cursor: mouse::Cursor, _cursor: mouse::Cursor,
_viewport: &Rectangle, _viewport: &Rectangle,
) { ) {
renderer
.draw_primitive(layout.bounds(), TextPrimitive::new());
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: layout.bounds(), bounds: layout.bounds(),
@ -74,50 +105,12 @@ where
} }
} }
impl<'a, Message, Theme, Renderer> From<SlideText> impl<Message, Theme, Renderer> From<SlideText>
for Element<'a, Message, Theme, Renderer> for Element<'_, Message, Theme, Renderer>
where where
Message: 'a + Clone, Renderer: renderer::Renderer,
Theme: 'a,
Renderer: 'a + PrimitiveRenderer,
{ {
fn from(slide_text: SlideText) -> Self { fn from(circle: SlideText) -> Self {
Self::new(slide_text) Self::new(circle)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TextPrimitive {
_text_id: u64,
_size: (u32, u32),
}
impl TextPrimitive {
pub fn new() -> Self {
todo!()
}
}
impl Primitive for TextPrimitive {
fn prepare(
&self,
_device: &cosmic::iced::wgpu::Device,
_queue: &cosmic::iced::wgpu::Queue,
_format: cosmic::iced::wgpu::TextureFormat,
_storage: &mut cosmic::iced_widget::shader::Storage,
_bounds: &Rectangle,
_viewport: &cosmic::iced_wgpu::graphics::Viewport,
) {
todo!()
}
fn render(
&self,
_encoder: &mut cosmic::iced::wgpu::CommandEncoder,
_storage: &cosmic::iced_widget::shader::Storage,
_target: &cosmic::iced::wgpu::TextureView,
_clip_bounds: &Rectangle<u32>,
) {
todo!()
} }
} }

View file

@ -1,175 +0,0 @@
use cosmic::{
Element, Task,
cosmic_theme::palette::WithAlpha,
iced::{Background, Border},
iced_widget::{column, row},
theme,
widget::{
button, combo_box, container, horizontal_space, icon,
text_editor,
},
};
use crate::core::songs::VerseName;
#[derive(Debug)]
pub struct VerseEditor {
pub verse_name: VerseName,
pub lyric: String,
content: text_editor::Content,
editing_verse_name: bool,
verse_name_combo: combo_box::State<String>,
}
#[derive(Debug, Clone)]
pub enum Message {
UpdateLyric(text_editor::Action),
UpdateVerseName(String),
EditVerseName,
DeleteVerse(VerseName),
None,
}
pub enum Action {
Task(Task<Message>),
UpdateVerse((VerseName, String)),
UpdateVerseName(String),
DeleteVerse(VerseName),
ScrollVerses(f32),
None,
}
impl VerseEditor {
#[must_use]
pub fn new(verse: VerseName, lyric: &str) -> Self {
Self {
verse_name: verse,
lyric: lyric.to_string(),
content: text_editor::Content::with_text(lyric),
editing_verse_name: false,
verse_name_combo: combo_box::State::new(
VerseName::all_names(),
),
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::UpdateLyric(action) => match action {
text_editor::Action::Edit(ref _edit) => {
self.content.perform(action);
let lyrics = self.content.text();
self.lyric.clone_from(&lyrics);
let verse = self.verse_name;
Action::UpdateVerse((verse, lyrics))
}
text_editor::Action::Scroll { pixels } => {
if self.content.line_count() > 6 {
self.content.perform(action);
Action::None
} else {
Action::ScrollVerses(pixels)
}
}
_ => {
self.content.perform(action);
Action::None
}
},
Message::UpdateVerseName(verse_name) => {
Action::UpdateVerseName(verse_name)
}
Message::EditVerseName => {
self.editing_verse_name = !self.editing_verse_name;
Action::None
}
Message::DeleteVerse(verse) => Action::DeleteVerse(verse),
Message::None => Action::None,
}
}
pub fn view(&self) -> Element<Message> {
let cosmic::cosmic_theme::Spacing {
space_xxs: _,
space_s,
space_m,
..
} = theme::spacing();
let delete_button = button::text("Delete")
.trailing_icon(
icon::from_name("view-close").symbolic(true),
)
.class(theme::Button::Destructive)
.on_press(Message::DeleteVerse(self.verse_name));
let combo = combo_box(
&self.verse_name_combo,
"Verse 1",
Some(&self.verse_name.get_name()),
Message::UpdateVerseName,
);
let verse_title =
row![combo, horizontal_space(), delete_button];
let lyric: Element<Message> = if self.verse_name
== VerseName::Blank
{
horizontal_space().into()
} else {
text_editor(&self.content)
.on_action(Message::UpdateLyric)
.padding(space_m)
.class(theme::iced::TextEditor::Custom(Box::new(
move |t, s| {
let neutral = t.cosmic().palette.neutral_9;
let mut base_style = text_editor::Style {
background: Background::Color(
t.cosmic()
.background
.small_widget
.with_alpha(0.25)
.into(),
),
border: Border::default()
.rounded(space_s)
.width(2)
.color(
t.cosmic().bg_component_divider(),
),
icon: t
.cosmic()
.primary_component_color()
.into(),
placeholder: neutral
.with_alpha(0.7)
.into(),
value: neutral.into(),
selection: t.cosmic().accent.base.into(),
};
let hovered_border = Border::default()
.rounded(space_s)
.width(3)
.color(t.cosmic().accent.hover);
match s {
text_editor::Status::Active => base_style,
text_editor::Status::Hovered
| text_editor::Status::Focused => {
base_style.border = hovered_border;
base_style
}
text_editor::Status::Disabled => {
base_style
}
}
},
)))
.height(150)
.into()
};
container(column![verse_title, lyric].spacing(space_s))
.padding(space_s)
.class(theme::Container::Card)
.into()
}
}

BIN
test.db

Binary file not shown.

View file

@ -1,11 +1,10 @@
(slide :background (image :source "~/pics/frodo.jpg" :fit fill) (slide :background (image :source "~/pics/frodo.jpg" :fit fill)
(text "This is frodo" :font-size 140)) (text "This is frodo" :font-size 90))
(slide (video :source "~/vids/test/camprules2024.mp4" :fit contain)) (slide (video :source "~/vids/test/camprules2024.mp4" :fit contain))
;; (slide (video :source "~/vids/never give up.mkv" :fit contain)) (slide (video :source "~/vids/never give up.mkv" :fit contain))
;; (slide (video :source "~/vids/The promise of Rust.mkv" :fit contain)) (slide (video :source "~/vids/The promise of Rust.mkv" :fit contain))
(slide :background (presentation :source "~/docs/description-of-a-discipled-person-assessment-2016.pdf" :fit contain))
(song :id 7 :author "North Point Worship" (song :id 7 :author "North Point Worship"
:font "Quicksand" :font-size 120 :font "Quicksand Bold" :font-size 60
:shadow "" :stroke "" :shadow "" :stroke ""
:title "Death Was Arrested" :title "Death Was Arrested"
:background (image :source "file:///home/chris/nc/tfc/openlp/CMG - Bright Mountains 01.jpg" :fit cover) :background (image :source "file:///home/chris/nc/tfc/openlp/CMG - Bright Mountains 01.jpg" :fit cover)

View file

@ -6,20 +6,12 @@
kind: Image kind: Image
), ),
text: "This is Frodo", text: "This is Frodo",
font: Some(Font( font: "Quicksand",
name: "Quicksand", font_size: 50,
weight: Normal,
style: Normal,
size: 130,
)),
font_size: 130,
stroke: None,
shadow: None,
text_alignment: MiddleCenter, text_alignment: MiddleCenter,
video_loop: false, video_loop: false,
video_start_time: 0.0, video_start_time: 0.0,
video_end_time: 0.0, video_end_time: 0.0,
pdf_index: 0,
), ),
( (
@ -29,19 +21,11 @@
kind: Video kind: Video
), ),
text: "This is Frodo", text: "This is Frodo",
font: Some(Font( font: "Quicksand",
name: "Quicksand", font_size: 50,
weight: Normal,
style: Normal,
size: 130,
)),
font_size: 130,
stroke: None,
shadow: None,
text_alignment: MiddleCenter, text_alignment: MiddleCenter,
video_loop: false, video_loop: false,
video_start_time: 0.0, video_start_time: 0.0,
video_end_time: 0.0, video_end_time: 0.0,
pdf_index: 0,
) )
] ]

View file

@ -1,5 +1,5 @@
(song :id 7 :author "North Point Worship" (song :id 7 :author "North Point Worship"
:font "Quicksand" :font-size 140 :font "Quicksand Bold" :font-size 60
:title "Death Was Arrested" :title "Death Was Arrested"
:background (image :source "~/nc/tfc/openlp/CMG - Bright Mountains 01.jpg" :fit cover) :background (image :source "~/nc/tfc/openlp/CMG - Bright Mountains 01.jpg" :fit cover)
:text-alignment center :text-alignment center

63
todo.org Normal file
View file

@ -0,0 +1,63 @@
#+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
* TODO [#A] Need to fix tests now that the basic app is working
* TODO Check into =mupdf-rs= for loading PDF's.
* TODO [#A] Text could be built by using SVG instead of the text element. Maybe I could construct my own text element even
This does almost work. There is a clear amount of lag or rather hang up since switching to the =text_svg= element. I think I may only keep it till I can figure out how to do strokes and shadows in iced's normal text element.
Actually, what if we just made the svg at load/creation time and stored it in the file system for later, then load the entire songs svg's into memory during the presentation to speed things up? Would that be faster than creating them at on the fly? Is it the creation of them that is slow or the rendering?
** SVG performs badly
Since SVG's apparently run poorly in iced, instead I'll need to see about either creating a new text element, or teaching Iced to render strokes and shadows on text.
* 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 [#B] Build editors for each possible item
** TODO Develop ui for editors
* TODO [#B] Develop ui for settings
* TODO [#B] Develop library system for slides that are more than images or video i.e. content
* 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
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();
let family = Family::Name(Box::leak(font));
let weight = Weight::Normal;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
family,
weight,
stretch,
style,
};
#+end_src
This code creates a font by leaking the Box to a ='static &str=. I just am not sure if the &str stays around in memory after the view function. If it does, then it's not on the stack anymore and should be fine, but if it isn't cleaned up then we will have a memory leak.
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 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 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]
** DONE Library
** DONE SongEditor
** DONE Presenter