Compare commits

..

6 commits

Author SHA1 Message Date
54b681de7c A plan for updating the db and model better
Some checks failed
/ clippy (push) Failing after 4m46s
/ test (push) Failing after 5m40s
2026-04-06 16:50:44 -05:00
2e5fe439b0 fix some issues 2026-04-06 14:43:27 -05:00
69410e3b6e fixing lint issues and style issues
Some checks failed
/ clippy (push) Failing after 5m7s
/ test (push) Failing after 5m43s
2026-04-06 11:31:07 -05:00
896eda5b9d a working build of the new update
Some checks failed
/ clippy (push) Failing after 5m8s
/ test (push) Failing after 12m14s
2026-04-03 15:14:07 -05:00
b05f29d7b5 getting closer
Some checks failed
/ clippy (push) Failing after 13m8s
/ test (push) Failing after 5m59s
2026-04-03 11:23:13 -05:00
11cca05de4 working on updating
Some checks failed
/ clippy (push) Failing after 14m41s
/ test (push) Failing after 7m36s
2026-04-02 15:27:53 -05:00
92 changed files with 7101 additions and 23318 deletions

9
.gitignore vendored
View file

@ -13,11 +13,4 @@ test.db-shm
test.db-wal test.db-wal
test.lum test.lum
test.pres test.pres
profile.json.gz profile.json.gz
result
flatpak-cargo-generator.py
.flatpak-builder/
flatpak-out/
cosmic-flatpak-runtime/
flatpak-builder-tools/

2001
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,17 +17,20 @@ tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "chrono", "
strum = "0.26.3" 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.9", features = ["sqlite", "sqlite-deserialize", "runtime-tokio", "chrono"] } sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio"] }
dirs = "6.0.0" dirs = "6.0.0"
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"] }
gstreamer = "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 = { version = "2", features = ["serde"] } url = "2"
# colors-transform = "0.2.11" # colors-transform = "0.2.11"
rayon = "1.11.0" rayon = "1.11.0"
resvg_exposed = "0.47.0" resvg = "0.47.0"
image = "0.25.8" image = "0.25.8"
rapidhash = "4.0.0" rapidhash = "4.0.0"
rapidfuzz = "0.5.0" rapidfuzz = "0.5.0"
@ -35,7 +38,7 @@ rapidfuzz = "0.5.0"
# 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.6.0", git = "https://github.com/messense/mupdf-rs", features = ["serde"] } mupdf = { version = "0.5.0", git = "https://github.com/messense/mupdf-rs", rev="2425c1405b326165b06834dcc1ca859015f92787"}
tar = "0.4.44" tar = "0.4.44"
zstd = "0.13.3" zstd = "0.13.3"
fastrand = "2.3.0" fastrand = "2.3.0"
@ -45,21 +48,13 @@ reqwest = "0.13.1"
scraper = "0.25.0" scraper = "0.25.0"
itertools = "0.14.0" itertools = "0.14.0"
serde_json = "1.0.149" serde_json = "1.0.149"
nom = "8.0.0"
tokio-stream = "0.1.18"
fontdb = "0.23.0"
youtube_dl = { version = "0.10.0", features = ["downloader-native-tls", "tokio"] }
# rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] } # rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] }
[dependencies.rodio]
git = "https://github.com/RustAudio/rodio"
features = ["symphonia-all", "tracing", "playback", "symphonia", "symphonia-libopus"]
[dependencies.libcosmic] [dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic" git = "https://github.com/pop-os/libcosmic"
default-features = false default-features = false
features = ["debug", "winit", "tokio", "rfd", "wgpu", "multi-window",] features = ["debug", "winit", "desktop", "wayland", "tokio", "rfd", "dbus-config", "a11y", "wgpu", "multi-window", "process"]
[dependencies.iced_video_player] [dependencies.iced_video_player]
git = "https://github.com/wash2/iced_video_player.git" git = "https://github.com/wash2/iced_video_player.git"
@ -69,75 +64,9 @@ features = ["wgpu"]
# [profile.dev] # [profile.dev]
# opt-level = 3 # opt-level = 3
[package.metadata.packager]
version = "0.1.0"
identifier = "xyz.cochrun.lumina"
icons = ["res/icons/lumina.ico", "res/icons/lumina.icns", "res/icons/lumina.svg"]
resources = ["res"]
category = "Video"
[package.metadata.packager.windows]
allow_downgrades = true
sign_command = "./signtool.exe sign /debug /a /fd SHA256 %1"
[package.metadata.packager.macos]
frameworks = ["GStreamer"]
[package.metadata.packager.nsis]
installer_icon = "res/icons/lumina.ico"
installer_mode = "perMachine"
preinstall_section = """
Section PreInstall
; Check if GStreamer is already installed and skip this section
ReadRegStr $4 HKLM "SOFTWARE\\GStreamer1.0\\x86_64" "Version"
StrCmp $4 "" 0 gstreamer_done
Delete "$TEMP\\gstreamer1.0.exe"
DetailPrint "Downloading GStreamer"
nsis_tauri_utils::download "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.2/msvc/gstreamer-1.0-msvc-x86_64-1.28.2.exe" "$TEMP\\gstreamer-1.0-msvc-x86_64-1.28.2.exe"
Pop $0
${If} $0 == 0
DetailPrint "Successfully downloaded GStreamer"
${Else}
DetailPrint "Error downloading GStreamer"
Abort "Canceling GStreamer install due to download error"
${EndIf}
StrCpy $6 "$TEMP\\gstreamer-1.0-msvc-x86_64-1.28.2.exe"
DetailPrint "Installing GStreamer"
; $6 holds the path to the gstreamer installer
ExecWait "$6" $1
${If} $1 == 0
DetailPrint "GStreamer successfully installed"
${Else}
DetailPrint "Error installing GStreamer"
Abort "Cancelling GStreamer install due to installation error"
${EndIf}
gstreamer_done:
SectionEnd
"""
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
debug = true debug = true
# [profile.production]
# opt-level = 3
# lto = true
# codegen-units = 1
# panic = 'abort'
# strip = "symbols"
[lints.rust] [lints.rust]
mismatched_lifetime_syntaxes = "allow" mismatched_lifetime_syntaxes = "allow"
unsafe_code = "deny"
[lints.clippy]
cast_possible_truncation = { level = "allow", priority = 1 }
excessive_nesting = { level = "warn", priority = 1 }
pedantic = "warn"
nursery = "warn"
unwrap_used = "warn"
perf = "warn"
enum_glob_use = "warn"

View file

@ -3,63 +3,33 @@
#+CATEGORY: dev #+CATEGORY: dev
* TODO [#A] Deployment pipeline and get a MVP going * 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.
* TODO [#B] Add a title/info slide system for songs 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.
This can include title, author, and ccli info so that it will be compliant and helpful. Basically some slides should be generated that show the song info and can be displayed as the song is starting.
* TODO Add an access time to the database so that we can sort library items by last used or edited.
* TODO Fix song imports so that they actually get rid of extra cruft
Sometimes a song imported from Genius will have extra junk that was in the middle of the lyrics on the page. Sometimes the lyrics themselves seem to still carry the styling from the webpage and effect the look through the SVG.
* TODO Find a way to check if an item is in the library on load so that we can import it into the library * TODO [#A] Make sure that adding, deleting and editing items in each model is working correctly
* TODO Make loading not block the UI
* TODO Loading and saving need to have a progress indicator of some sort Let's build some tests that ensure that these functions are working for the models. Make sure the models are built in such a way as to make sure that they are testable and work fast for the user.
* DONE Preview mode needs to allow for a larger preview of the slide if the library is closed
CLOSED: [2026-05-30 Sat 15:15] By making the db functions take the vector of items in the model, we can drain the model, pass an owned version of those items to the async db function(adding, updating, deleting, etc) and then return an updated list of the items back in the Result.
* DONE Grid mode needs to use the actual aspect ratio correctly for the slide preview
CLOSED: [2026-05-31 Sun 07:02] We should probably return a tuple with the original vector of items in case the db function fails somehow.
* TODO Make audio is song editor able to increase speed so the user can create the song a little faster if they desire.
* TODO Song editor audio slider not working
* TODO Good keyboard shortcuts for the song editor so that making songs is faster and more intuitive
* DONE When editing songs, we should ensure that you can't effect the presentation without certain shortcuts
CLOSED: [2026-05-30 Sat 15:17]
* DONE Fix the right click context menu in service list and library
CLOSED: [2026-05-30 Sat 15:15]
Remake this just like the one in the preview and grid view probably so that it can work regardless of scroll and things
* TODO Fix the scrolling when switching slides for preview and grid
They both need to be adjusted when changing the size of the slides that are there
* TODO [#B] Font in the song editor doesn't always use the original version * 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. 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] Build an Animation type that will hold all the info for what a slide animation is.
The animation type that comes with Iced is basically a way to say how long animations take and at what easing to do them, but they do not at all tell you WHAT to animate, that is all in where you put the animation's interpolate function in the view.
So what I think I'll do is either, build a custom widget for slides (might need to do this anyway eventually since we are doing a lot of custom stuff with slides) or build my own Animation type to hold all of the correct info and based on that Animation, place the Iced animation interpolate function where it needs to go.
* TODO [#B] Find a way to use auth-token in tests for ci * 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 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 * 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. 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] Video downloading system
We need to create a way for users to download youtube or other videos by URL.
* DONE [#B] Songs should have a place to store the audio file and then play it during editing so you can ensure the order of verses
CLOSED: [2026-05-19 Tue 06:01]
* TODO [#B] Songs should have a way of storing a lyric video or other videos so they can be helpful for the editor
* TODO [#B] Develop ui for settings * 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] Develop library system for slides that are more than images or video i.e. content
* TODO [#C] Self signed cert for windows
This was created in the VM on May 10 2026. It is valid for 2 years. Maybe this self signed cert will be ok till we get some reputation, then maybe consider buying a cert or similar.
* 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 [#C] Use orgize as a file parser and allow for orgdown files to represent a presentation. * 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. 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.
@ -94,26 +64,7 @@ Since strings are allocated on the heap, I've changed how to construct the svg s
* TODO [#C] Make the presenter more modular so things are easier to change. This is vague... * TODO [#C] Make the presenter more modular so things are easier to change. This is vague...
* DONE [#A] Make sure that adding, deleting and editing items in each model is working correctly [0/0] * TODO [#C] Figure out why the Video element seems to have problems when moving the mouse around
CLOSED: [2026-04-24 Fri 13:17]
Let's build some tests that ensure that these functions are working for the models. Make sure the models are built in such a way as to make sure that they are testable and work fast for the user.
By making the db functions take the vector of items in the model, we can drain the model, pass an owned version of those items to the async db function(adding, updating, deleting, etc) and then return an updated list of the items back in the Result.
We should probably return a tuple with the original vector of items in case the db function fails somehow. This would be extremely important if we eventually create a server/client architecture and for whatever reason the server fails to respond with an answer, we'd lose all our items.
** DONE [#A] Need to test the library
CLOSED: [2026-04-15 Wed 15:58]
Instead of testing the library itself, I think I'll just create a fake library in each core model and then test it in that
** DONE Move to new design
CLOSED: [2026-04-07 Tue 11:42]
* DONE [#A] Add Action system
CLOSED: [2026-04-15 Wed 15:57]
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.
* DONE [#A] Create a view of all slides in a PDF presenation * DONE [#A] Create a view of all slides in a PDF presenation
@ -151,14 +102,6 @@ There is likely some work that still needs to be done here, I believe I am someh
* DONE [#A] Need to fixup how songs are edited in the editors * 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. 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 Presenter module needs 2 videos
CLOSED: [2026-04-16 Thu 13:49]
This will allow for us to have different parameters in the framerate and even ensure that we can modify them separately.
* DONE Song Editor has some sort of performance issue.
CLOSED: [2026-04-10 Fri 13:08]
=core::songs= logs in line 294 whenever even mousing over the song editor.
* DONE [#B] Functions for text alignments * DONE [#B] Functions for text alignments
This will need to be matched on for the =TextAlignment= from the user This will need to be matched on for the =TextAlignment= from the user
@ -202,7 +145,3 @@ This will make it so that we can add styling to the text like borders and backgr
* DONE Build Menu * DONE Build Menu
* DONE Find a way for text to pass through a service item to a slide i.e. content piece * 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=. This proved easier by just creating the =Slide= first and inserting it into the =ServiceItem=.
* DONE [#C] Figure out why the Video element seems to have problems when moving the mouse around
CLOSED: [2026-04-15 Wed 15:59]
I think this got fixed in a recent update

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
excessive-nesting-threshold = 7

52
flake.lock generated
View file

@ -1,31 +1,16 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1778106249,
"narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=",
"owner": "ipetkov",
"repo": "crane",
"rev": "6d015ea29630b7ad2402841386da2cb617a470a7",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": { "fenix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1778662605, "lastModified": 1770794449,
"narHash": "sha256-nGPpWsLZ1dX1Dirf98GsCsFDE/diXkUP0PaAqZlTpkA=", "narHash": "sha256-1nFkhcZx9+Sdw5OXwJqp5TxvGncqRqLeK781v0XV3WI=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "5c80141c6215ed0a1cdc06ddb68e9bb55e9edfca", "rev": "b19d93fdf9761e6101f8cb5765d638bacebd9a1b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -80,11 +65,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1778151388, "lastModified": 1769799857,
"narHash": "sha256-lldMJPUeouEjO8/7aLuwhcsIw29vVihm2ZALzjiqfec=", "narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=",
"owner": "nix-community", "owner": "nix-community",
"repo": "naersk", "repo": "naersk",
"rev": "efdddff9ff4d8e7d0056d57ec67dac50f75ab8f6", "rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -95,11 +80,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1778443072, "lastModified": 1770562336,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -127,11 +112,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1778443072, "lastModified": 1770562336,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -159,7 +144,6 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"fenix": "fenix", "fenix": "fenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"naersk": "naersk", "naersk": "naersk",
@ -170,11 +154,11 @@
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1778611623, "lastModified": 1770702974,
"narHash": "sha256-oNgaKN3iKM1Cud3bKhEXFHXNRRc+j/JDl05d2jYa2Sg=", "narHash": "sha256-CbvWu72rpGHK5QynoXwuOnVzxX7njF2LYgk8wRSiAQ0=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "7c28934677b1e7a1c6ef952422e6ef730540f85f", "rev": "07a594815f7c1d6e7e39f21ddeeedb75b21795f4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -206,11 +190,11 @@
"nixpkgs": "nixpkgs_4" "nixpkgs": "nixpkgs_4"
}, },
"locked": { "locked": {
"lastModified": 1778642276, "lastModified": 1770779462,
"narHash": "sha256-bhk4lawR4ZnFhPtamB5WkCyvfgyZmsEUbWfT/3FRxFY=", "narHash": "sha256-ykcXTKtV+dOaKlOidAj6dpewBHjni9/oy/6VKcqfzfY=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "77265d2dc1e61b2abfd3b1d6609dbb66fe75e0a5", "rev": "8a53b3ade61914cdb10387db991b90a3a6f3c441",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -7,7 +7,6 @@
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"; rust-overlay.url = "github:oxalica/rust-overlay";
crane.url = "github:ipetkov/crane";
}; };
outputs = outputs =
@ -22,21 +21,10 @@
# overlays = [ rust-overlay.overlays.default ]; # overlays = [ rust-overlay.overlays.default ];
# overlays = [cargo2nix.overlays.default]; # overlays = [cargo2nix.overlays.default];
}; };
inherit (pkgs) lib;
craneLib = crane.mkLib pkgs;
naersk' = pkgs.callPackage naersk { }; naersk' = pkgs.callPackage naersk { };
# toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]); # toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]);
unfilteredRoot = ./.; # The original, unfiltered source
src = lib.fileset.toSource {
root = unfilteredRoot;
fileset = lib.fileset.unions [
# Default files from crane (Rust and cargo files)
(craneLib.fileset.commonCargoSources unfilteredRoot)
# Include all the .sql migrations as well
./migrations
];
};
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
# Rust tools # Rust tools
@ -49,9 +37,9 @@
# "rustc" # "rustc"
# "rustfmt" # "rustfmt"
# ]) # ])
(rust-bin.stable.latest.default.override { (rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" ]; extensions = [ "rust-src" "rust-analyzer" "clippy" ];
}) }))
cargo-nextest cargo-nextest
cargo-criterion cargo-criterion
# rust-analyzer-nightly # rust-analyzer-nightly
@ -61,21 +49,6 @@
libxkbcommon libxkbcommon
pkg-config pkg-config
sccache sccache
just
sqlx-cli
cargo-watch
samply
flatpak-builder
flatpak-xdg-utils
python3
python313Packages.aiohttp
python313Packages.tomlkit
python313Packages.pip
unzip
dbus
appstream
appstream-glib
libcosmicAppHook
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
@ -87,17 +60,16 @@
cmake cmake
clang clang
libclang libclang
makeWrapper
vulkan-headers vulkan-headers
vulkan-loader vulkan-loader
vulkan-tools vulkan-tools
libGL libGL
libinput
cargo-flamegraph cargo-flamegraph
bacon bacon
openssl openssl
freetype
fontconfig fontconfig
libglvnd
glib glib
alsa-lib alsa-lib
gst_all_1.gst-libav gst_all_1.gst-libav
@ -111,6 +83,11 @@
ffmpeg-full ffmpeg-full
mupdf mupdf
# yt-dlp # yt-dlp
just
sqlx-cli
cargo-watch
samply
]; ];
LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${ LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
@ -135,28 +112,6 @@
pkgs.libclang pkgs.libclang
] ]
}"; }";
commonArgs = {
strictDeps = false;
inherit src buildInputs nativeBuildInputs LD_LIBRARY_PATH;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
lumina = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts buildInputs nativeBuildInputs LD_LIBRARY_PATH;
preBuild = ''
export DATABASE_URL=sqlite:./db.sqlite3
sqlx database create
sqlx migrate run
'';
cargoTestCommand = "";
cargoExtraArgs = "";
}
);
in in
rec { rec {
devShell = devShell =
@ -170,12 +125,15 @@
DATABASE_URL = "sqlite://./test.db"; DATABASE_URL = "sqlite://./test.db";
# RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library"; # RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library";
}; };
defaultPackage = lumina; defaultPackage = naersk'.buildPackage {
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.;
};
packages = { packages = {
postInstall = '' default = naersk'.buildPackage {
libcosmicAppWrapperArgs+=(--prefix GST_PLUGIN_SYSTEM_PATH_1_0 : "$GST_PLUGIN_SYSTEM_PATH_1_0") inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
''; src = ./.;
default = lumina; };
}; };
} }
); );

View file

@ -1,8 +1,8 @@
ui := "-i"
verbose := "-v" verbose := "-v"
file := "~/dev/lumina-iced/test_presentation.lisp" file := "~/dev/lumina-iced/test_presentation.lisp"
sdk-version := "25.08"
# export RUSTC_WRAPPER := "sccache" export RUSTC_WRAPPER := "sccache"
# export RUST_LOG := "debug" # export RUST_LOG := "debug"
default: default:
@ -11,29 +11,23 @@ build:
cargo build cargo build
build-release: build-release:
cargo build --release cargo build --release
build-offline:
cargo build --release --offline
run: run:
cargo run -- {{verbose}} cargo run -- {{verbose}} {{ui}}
run-release: run-release:
cargo run --release -- {{verbose}} cargo run --release -- {{verbose}} {{ui}}
run-file: run-file:
cargo run -- {{verbose}} cli {{file}} cargo run -- {{verbose}} {{ui}} {{file}}
fix:
cargo clippy --fix --bin "lumina" -p lumina -- -W clippy::pedantic -W clippy::perf -W clippy::nursery -W clippy::unwrap_used
clean: clean:
cargo clean cargo clean
watch-clippy:
cargo watch --why -x "clippy --all-targets --all-features"
test: test:
cargo nextest run cargo nextest run
ci-test: 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 --skip song_search 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: bench:
export NEXTEST_EXPERIMENTAL_BENCHMARKS=1 export NEXTEST_EXPERIMENTAL_BENCHMARKS=1
cargo nextest bench cargo nextest bench
profile: profile:
samply record cargo run --release -- {{verbose}} samply record cargo run --release -- {{verbose}} {{ui}}
alias b := build alias b := build
alias r := run alias r := run
@ -41,54 +35,3 @@ alias br := build-release
alias rr := run-release alias rr := run-release
alias rf := run-file alias rf := run-file
alias c := clean alias c := clean
##### Sets up and builds the exe installer with nsis
windows-packager:
cargo install cargo-packager --locked
cargo build --release
cargo packager --release -f nsis
##### Sets up and builds the macos bundle and dmg
mac-packager:
cargo install cargo-packager --locked
export PKG_CONFIG_PATH=/Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig
export PATH=/Library/Frameworks/GStreamer.framework/Versions/1.0/bin:$PATH
cargo build --release
install_name_tool -add_rpath @executable_path/../Frameworks/GStreamer.framework/Libraries target/release/lumina
cargo packager --release -f dmg
##### Sets up flatpak to be able to build the lumina flatpak using all the latest pieces
flatpak-setup: flatpak-install-sdk install-flatpak-builder-tools
git -C "cosmic-flatpak-runtime" pull || git clone https://github.com/pop-os/cosmic-flatpak-runtime.git "cosmic-flatpak-runtime"
cd cosmic-flatpak-runtime
flatpak-builder --install --user --force-clean build-dir cosmic-flatpak-runtime/com.system76.Cosmic.Sdk.json
flatpak-builder --install --user --force-clean build-dir cosmic-flatpak-runtime/com.system76.Cosmic.BaseApp.json
flatpak-install-sdk:
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install --noninteractive --user flathub \
org.freedesktop.Platform//{{ sdk-version }} \
org.freedesktop.Platform.Locale//{{ sdk-version }} \
org.freedesktop.Sdk//{{ sdk-version }} \
org.freedesktop.Sdk.Locale//{{ sdk-version }} \
org.freedesktop.Sdk.Docs//{{ sdk-version }} \
org.freedesktop.Sdk.Debug//{{ sdk-version }} \
org.freedesktop.Sdk.Extension.rust-nightly//{{ sdk-version }} \
org.freedesktop.Sdk.Extension.llvm22//{{ sdk-version }}
install-flatpak-builder-tools:
rm -rf flatpak-builder-tools
git clone https://github.com/flatpak/flatpak-builder-tools --branch master --depth 1
# pip install aiohttp tomlkit # Would be needed without nix
flatpak-gen-manifest: install-flatpak-builder-tools
python3 flatpak-builder-tools/cargo/flatpak-cargo-generator.py Cargo.lock -o cargo-sources.json
flatpak-build:
flatpak-builder --install-deps-from=flathub --keep-build-dirs --install --user --force-clean build-dir xyz.cochrun.lumina.yml
flatpak-shell:
flatpak-builder --run build-dir xyz.cochrun.lumina.yml sh
alias fb := flatpak-build
alias fs := flatpak-setup

View file

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

View file

@ -1,20 +0,0 @@
-- Add migration script here
ALTER TABLE songs
ADD COLUMN created_at INTEGER;
ALTER TABLE songs
ADD COLUMN accessed_at INTEGER;
ALTER TABLE images
ADD COLUMN created_at INTEGER;
ALTER TABLE images
ADD COLUMN accessed_at INTEGER;
ALTER TABLE videos
ADD COLUMN created_at INTEGER;
ALTER TABLE videos
ADD COLUMN accessed_at INTEGER;
ALTER TABLE presentations
ADD COLUMN created_at INTEGER;
ALTER TABLE presentations
ADD COLUMN accessed_at INTEGER;

View file

@ -3,12 +3,6 @@
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.
[[file:./res/images/screenshot_2026-05-03_08-23-08.png]]
[[file:./res/images/screenshot_2026-05-03_08-23-23.png]]
[[file:./res/images/screenshot_2026-05-03_08-16-59.png]]
* Why build this? * Why build this?
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.

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 5.7226562 4 C 4.7682642 4 4 4.892 4 6 L 5 6 C 5 5.3542968 5.3913485 5 5.7226562 5 L 6 5 L 6 4 L 5.7226562 4 z M 8 4 L 8 5 L 11 5 L 11 4 L 8 4 z M 13 4 L 13 5 L 16 5 L 16 4 L 13 4 z M 18 4 L 18 5 L 18.277344 5 C 18.608652 5 19 5.3542968 19 6 L 20 6 C 20 4.892 19.231736 4 18.277344 4 L 18 4 z M 4 8 L 4 11 L 5 11 L 5 8 L 4 8 z M 7 8 L 7 16 L 17 16 L 17 8 L 7 8 z M 19 8 L 19 11 L 20 11 L 20 8 L 19 8 z M 4 13 L 4 16 L 5 16 L 5 13 L 4 13 z M 19 13 L 19 16 L 20 16 L 20 13 L 19 13 z M 4 18 C 4 19.108 4.7682642 20 5.7226562 20 L 6 20 L 6 19 L 5.7226562 19 C 5.3913486 19 5 18.645703 5 18 L 4 18 z M 19 18 C 19 18.645703 18.608652 19 18.277344 19 L 18 19 L 18 20 L 18.277344 20 C 19.231736 20 20 19.108 20 18 L 19 18 z M 8 19 L 8 20 L 11 20 L 11 19 L 8 19 z M 13 19 L 13 20 L 16 20 L 16 19 L 13 19 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 14,13 A 5,5 0 0 1 9,18 5,5 0 0 1 4,13 5,5 0 0 1 9,8 5,5 0 0 1 14,13 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 9,8 A 5,5 0 0 0 4,13 5,5 0 0 0 9,18 5,5 0 0 0 14,13 5,5 0 0 0 9,8 Z M 9,9 A 4,4 0 0 1 13,13 4,4 0 0 1 9,17 4,4 0 0 1 5,13 4,4 0 0 1 9,9 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 0,12 H 18 V 14 H 0 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 832 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 10,13 A 5,5 0 0 1 5,18 5,5 0 0 1 0,13 5,5 0 0 1 5,8 5,5 0 0 1 10,13 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 5,8 A 5,5 0 0 0 0,13 5,5 0 0 0 5,18 5,5 0 0 0 10,13 5,5 0 0 0 5,8 Z M 5,9 A 4,4 0 0 1 9,13 4,4 0 0 1 5,17 4,4 0 0 1 1,13 4,4 0 0 1 5,9 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 4,0 V 14 H 18 V 12 H 6 V 0 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 839 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 18,13 A 5,5 0 0 1 13,18 5,5 0 0 1 8,13 5,5 0 0 1 13,8 5,5 0 0 1 18,13 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 13,8 A 5,5 0 0 0 8,13 5,5 0 0 0 13,18 5,5 0 0 0 18,13 5,5 0 0 0 13,8 Z M 13,9 A 4,4 0 0 1 17,13 4,4 0 0 1 13,17 4,4 0 0 1 9,13 4,4 0 0 1 13,9 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 14,0 V 14 H 0 V 12 H 12 V 0 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 849 B

View file

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 9,4 A 5,5 0 0 0 4,9 5,5 0 0 0 9,14 5,5 0 0 0 14,9 5,5 0 0 0 9,4 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 9,4 A 5,5 0 0 0 4,9 5,5 0 0 0 9,14 5,5 0 0 0 14,9 5,5 0 0 0 9,4 Z M 9,5 A 4,4 0 0 1 13,9 4,4 0 0 1 9,13 4,4 0 0 1 5,9 4,4 0 0 1 9,5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 737 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 10,9 A 5,5 0 0 1 5,14 5,5 0 0 1 0,9 5,5 0 0 1 5,4 5,5 0 0 1 10,9 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 5,4 A 5,5 0 0 0 0,9 5,5 0 0 0 5,14 5,5 0 0 0 10,9 5,5 0 0 0 5,4 Z M 5,5 A 4,4 0 0 1 9,9 4,4 0 0 1 5,13 4,4 0 0 1 1,9 4,4 0 0 1 5,5 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 4,0 H 6 V 18 H 4 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 822 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 18,9 A 5,5 0 0 1 13,14 5,5 0 0 1 8,9 5,5 0 0 1 13,4 5,5 0 0 1 18,9 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 13,4 A 5,5 0 0 0 8,9 5,5 0 0 0 13,14 5,5 0 0 0 18,9 5,5 0 0 0 13,4 Z M 13,5 A 4,4 0 0 1 17,9 4,4 0 0 1 13,13 4,4 0 0 1 9,9 4,4 0 0 1 13,5 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 12,0 H 14 V 18 H 12 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 834 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 14,5 A 5,5 0 0 1 9,10 5,5 0 0 1 4,5 5,5 0 0 1 9,0 5,5 0 0 1 14,5 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 9,0 A 5,5 0 0 0 4,5 5,5 0 0 0 9,10 5,5 0 0 0 14,5 5,5 0 0 0 9,0 Z M 9,1 A 4,4 0 0 1 13,5 4,4 0 0 1 9,9 4,4 0 0 1 5,5 4,4 0 0 1 9,1 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 0,4 H 18 V 6 H 0 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 822 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 10,5 A 5,5 0 0 0 5,0 5,5 0 0 0 0,5 5,5 0 0 0 5,10 5,5 0 0 0 10,5 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 5,0 A 5,5 0 0 0 0,5 5,5 0 0 0 5,10 5,5 0 0 0 10,5 5,5 0 0 0 5,0 Z M 5,1 A 4,4 0 0 1 9,5 4,4 0 0 1 5,9 4,4 0 0 1 1,5 4,4 0 0 1 5,1 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 4,18 V 4 H 18 V 6 H 6 V 18 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 831 B

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" version="1.1">
<defs>
<style id="current-color-scheme" type="text/css">
.ColorScheme-Text { color:#dfdfdf; } .ColorScheme-Highlight { color:#4285f4; } .ColorScheme-NeutralText { color:#ff9800; } .ColorScheme-PositiveText { color:#4caf50; } .ColorScheme-NegativeText { color:#f44336; }
</style>
</defs>
<path style="opacity:.35;fill:currentColor" class="ColorScheme-Text" d="M 18,5 A 5,5 0 0 1 13,10 5,5 0 0 1 8,5 5,5 0 0 1 13,0 5,5 0 0 1 18,5 Z"/>
<path style="opacity:0.7;fill:currentColor" class="ColorScheme-Text" d="M 13,0 A 5,5 0 0 0 8,5 5,5 0 0 0 13,10 5,5 0 0 0 18,5 5,5 0 0 0 13,0 Z M 13,1 A 4,4 0 0 1 17,5 4,4 0 0 1 13,9 4,4 0 0 1 9,5 4,4 0 0 1 13,1 Z"/>
<path style="fill:currentColor" class="ColorScheme-Text" d="M 14,18 V 4 H 0 V 6 H 12 V 18 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 841 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-caret-left"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13.883 5.007l.058 -.005h.118l.058 .005l.06 .009l.052 .01l.108 .032l.067 .027l.132 .07l.09 .065l.081 .073l.083 .094l.054 .077l.054 .096l.017 .036l.027 .067l.032 .108l.01 .053l.01 .06l.004 .057l.002 .059v12c0 .852 -.986 1.297 -1.623 .783l-.084 -.076l-6 -6a1 1 0 0 1 -.083 -1.32l.083 -.094l6 -6l.094 -.083l.077 -.054l.096 -.054l.036 -.017l.067 -.027l.108 -.032l.053 -.01l.06 -.01z" /></svg>

Before

Width:  |  Height:  |  Size: 621 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-caret-right"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 6c0 -.852 .986 -1.297 1.623 -.783l.084 .076l6 6a1 1 0 0 1 .083 1.32l-.083 .094l-6 6l-.094 .083l-.077 .054l-.096 .054l-.036 .017l-.067 .027l-.108 .032l-.053 .01l-.06 .01l-.057 .004l-.059 .002l-.059 -.002l-.058 -.005l-.06 -.009l-.052 -.01l-.108 -.032l-.067 -.027l-.132 -.07l-.09 -.065l-.081 -.073l-.083 -.094l-.054 -.077l-.054 -.096l-.017 -.036l-.027 -.067l-.032 -.108l-.01 -.053l-.01 -.06l-.004 -.057l-.002 -12.059z" /></svg>

Before

Width:  |  Height:  |  Size: 660 B

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!-- <svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 19h2c0 1.103.897 2 2 2h8c1.103 0 2-.897 2-2h2c1.103 0 2-.897 2-2V7c0-1.103-.897-2-2-2h-2c0-1.103-.897-2-2-2H8c-1.103 0-2 .897-2 2H4c-1.103 0-2 .897-2 2v10c0 1.103.897 2 2 2zM20 7v10h-2V7h2zM8 5h8l.001 14H8V5zM4 7h2v10H4V7z"/></svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-carousel-horizontal"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M16 4h-8a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2z" /><path d="M22 6a1 1 0 0 1 .117 1.993l-.117 .007h-1v8h1a1 1 0 0 1 .117 1.993l-.117 .007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-8a2 2 0 0 1 1.85 -1.995l.15 -.005h1z" /><path d="M3 6a2 2 0 0 1 1.995 1.85l.005 .15v8a2 2 0 0 1 -1.85 1.995l-.15 .005h-1a1 1 0 0 1 -.117 -1.993l.117 -.007h1v-8h-1a1 1 0 0 1 -.117 -1.993l.117 -.007h1z" /></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-circle-plus"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4.929 4.929a10 10 0 1 1 14.141 14.141a10 10 0 0 1 -14.14 -14.14m8.071 4.071a1 1 0 1 0 -2 0v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0 -2h-2v-2z" /></svg>

Before

Width:  |  Height:  |  Size: 398 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-edit"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a1 1 0 0 1 -1 1h-1a1 1 0 0 0 -1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1 -1v-1a1 1 0 0 1 2 0v1a3 3 0 0 1 -3 3h-9a3 3 0 0 1 -3 -3v-9a3 3 0 0 1 3 -3h1a1 1 0 0 1 1 1" /><path d="M14.596 5.011l4.392 4.392l-6.28 6.303a1 1 0 0 1 -.708 .294h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 .294 -.708zm6.496 -2.103a3.097 3.097 0 0 1 .165 4.203l-.164 .18l-.693 .694l-4.387 -4.387l.695 -.69a3.1 3.1 0 0 1 4.384 0" /></svg>

Before

Width:  |  Height:  |  Size: 617 B

View file

@ -1,3 +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">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 100"><path fill="#000000" d="M685 35h30v30h-30zM465 15h70v70h-70zM585 25h50v50h-50zM365 25h50v50h-50zM285 35h30v30h-30z"></path></svg>

Before

Width:  |  Height:  |  Size: 331 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-layout-grid"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 3a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-4a2 2 0 0 1 -2 -2v-4a2 2 0 0 1 2 -2z" /><path d="M19 3a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-4a2 2 0 0 1 -2 -2v-4a2 2 0 0 1 2 -2z" /><path d="M9 13a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-4a2 2 0 0 1 -2 -2v-4a2 2 0 0 1 2 -2z" /><path d="M19 13a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-4a2 2 0 0 1 -2 -2v-4a2 2 0 0 1 2 -2z" /></svg>

Before

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View file

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="300"
height="300"
viewBox="0 0 79.375 79.375"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="app.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><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:document-units="mm"
inkscape:zoom="1.1436372"
inkscape:cx="50.715386"
inkscape:cy="218.1636"
inkscape:window-width="1504"
inkscape:window-height="950"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1"><linearGradient
id="linearGradient14"
inkscape:collect="always"><stop
style="stop-color:#ff9f43;stop-opacity:1;"
offset="0"
id="stop14" /><stop
style="stop-color:#ff5c57;stop-opacity:1;"
offset="1"
id="stop15" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient14"
id="linearGradient15"
x1="5259.6104"
y1="956.60291"
x2="5639.8418"
y2="11845.003"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient14"
id="linearGradient20"
gradientUnits="userSpaceOnUse"
x1="5259.6104"
y1="956.60291"
x2="5639.8418"
y2="11845.003" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient14"
id="linearGradient21"
gradientUnits="userSpaceOnUse"
x1="5259.6104"
y1="956.60291"
x2="5639.8418"
y2="11845.003" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g21"
transform="matrix(0.45359205,0,0,0.45359205,-8.9236096,-19.93096)"><rect
style="fill:none;stroke:#000000;stroke-width:6;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect1"
width="154.52307"
height="91.384621"
x="29.907692"
y="63.553844"
rx="14.7" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.00001;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
id="rect2"
height="40.292309"
x="99.276917"
y="155.35384"
rx="3"
width="15.784616" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.04319;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
id="rect3"
width="66.877388"
height="8.9343357"
x="73.730537"
y="193.38441"
rx="1.2697047" /><g
transform="matrix(0.00516606,0,0,-0.00516606,79.015614,142.63061)"
fill="#000000"
stroke="none"
id="g2"
style="fill:url(#linearGradient15)"><path
d="m 3983,12758 c 550,-942 733,-1327 886,-1863 187,-652 256,-1456 222,-2600 -28,-968 -74,-1213 -436,-2320 -279,-853 -342,-1094 -387,-1470 -21,-180 -15,-672 16,-1230 15,-269 31,-557 35,-640 5,-82 7,-152 5,-154 -2,-2 -8,7 -13,20 -5,13 -72,166 -148,339 -194,437 -229,537 -283,799 -59,295 -80,574 -80,1101 0,649 28,1184 164,3150 l 53,765 -233,470 c -265,534 -323,641 -509,920 -396,597 -878,1127 -977,1074 -23,-13 -22,-57 7,-179 13,-59 16,-102 12,-200 -15,-341 -183,-817 -537,-1528 C 1601,8854 1498,8663 1200,8140 904,7620 799,7426 626,7080 464,6754 411,6640 320,6410 78,5800 -12,5297 4,4655 14,4233 50,3930 131,3576 396,2423 1085,1372 2116,546 2458,272 2733,110 2985,34 3069,9 3090,7 3305,3 3432,0 3551,0 3570,2 l 34,3 -284,310 c -157,171 -317,346 -356,390 -659,746 -1077,1679 -1198,2675 -30,249 -40,420 -41,710 0,303 5,348 42,385 22,23 39,18 84,-22 76,-69 111,-156 128,-323 18,-181 102,-410 290,-795 287,-587 653,-1174 974,-1565 89,-108 471,-489 647,-646 C 4238,815 4577,549 4925,315 5256,93 5427,34 5811,9 c 209,-13 815,-7 999,11 193,18 330,48 330,72 0,4 -67,142 -149,306 -647,1294 -691,1461 -678,2542 6,473 13,579 52,811 57,340 160,612 558,1480 197,429 314,695 406,920 375,924 501,1542 501,2449 0,360 -21,590 -81,903 -109,571 -366,1133 -725,1582 -119,150 -420,448 -582,577 -382,304 -752,511 -1447,807 -396,170 -561,226 -830,285 -88,20 -171,38 -184,41 l -23,6 z"
id="path1"
style="fill:url(#linearGradient20);fill-opacity:1" /><path
d="M 9451,8098 C 9338,7444 9273,7190 9129,6846 8989,6513 8820,6213 8360,5480 7868,4697 7545,4130 7439,3863 c -61,-155 -103,-386 -121,-665 -38,-621 109,-1454 367,-2079 54,-131 466,-979 475,-979 3,0 38,116 79,258 137,477 183,589 330,808 174,260 353,461 790,888 768,751 961,962 1156,1261 198,302 298,591 349,1010 39,321 45,970 11,1280 -54,498 -168,900 -379,1340 -146,304 -295,536 -696,1086 -150,206 -276,377 -279,382 -4,4 -35,-156 -70,-355 z"
id="path2"
style="fill:url(#linearGradient21);fill-opacity:1" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-plus"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>

Before

Width:  |  Height:  |  Size: 348 B

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48" id="Presentation--Streamline-Plump-Remix" height="48" width="48">
<desc>
Presentation Streamline Icon: https://streamlinehq.com
</desc>
<g id="presentation">
<path id="Union" fill="#000000" fill-rule="evenodd" d="M7.68448 4.93327C10.5616 4.72445 15.6087 4.5 24 4.5s13.4384 0.22445 16.3155 0.43327c1.2874 0.09344 2.159 0.94259 2.2891 2.11111C42.8112 8.89951 43 11.649 43 15.4999c0 3.851 -0.1888 6.6005 -0.3954 8.4557 -0.13 1.1676 -1.0025 2.0178 -2.2908 2.1113 -0.1441 0.0104 -0.2937 0.0209 -0.4489 0.0314 -1.1021 0.0746 -1.9349 1.0285 -1.8603 2.1306 0.0746 1.102 1.0285 1.9349 2.1305 1.8603 0.1615 -0.011 0.3175 -0.0219 0.4682 -0.0328 3.0935 -0.2245 5.6206 -2.4595 5.9768 -5.6582 0.2248 -2.0194 0.4199 -4.9179 0.4199 -8.8983 0 -3.9803 -0.1951 -6.87878 -0.4199 -8.89816 -0.3561 -3.19776 -2.8807 -5.43339 -5.975 -5.657976C37.6048 0.726004 32.4567 0.5 24 0.5S10.3952 0.726004 7.39492 0.943764C4.30058 1.16835 1.77599 3.40398 1.41994 6.60174 1.19509 8.62112 1 11.5196 1 15.4999c0 3.9804 0.19509 6.8789 0.41994 8.8983 0.35615 3.1987 2.88331 5.4337 5.97679 5.6582 0.15063 0.0109 0.30664 0.0218 0.46816 0.0328 1.10205 0.0746 2.05592 -0.7583 2.13054 -1.8603 0.07467 -1.1021 -0.75827 -2.056 -1.86032 -2.1306 -0.15521 -0.0105 -0.3048 -0.021 -0.44891 -0.0314 -1.28831 -0.0935 -2.16082 -0.9437 -2.29083 -2.1113C5.18881 22.1004 5 19.3509 5 15.4999c0 -3.8509 0.18881 -6.60039 0.39537 -8.45552 0.13011 -1.16852 1.00171 -2.01767 2.28911 -2.11111Zm0.93583 27.68253C10.7965 32.5742 15.8266 32.5 24 32.5s13.2035 0.0742 15.3797 0.1158c1.1393 0.0218 2.3245 0.7513 2.5097 2.11C41.9536 35.197 42 35.785 42 36.5c0 0.715 -0.0464 1.303 -0.1106 1.7742 -0.1852 1.3587 -1.3704 2.0882 -2.5097 2.11 -0.2919 0.0056 -0.6352 0.0117 -1.0306 0.0182l-1.3047 5.8165c-0.1025 0.4566 -0.5078 0.7811 -0.9758 0.7811H11.931c-0.468 0 -0.8734 -0.3245 -0.9758 -0.7811l-1.30477 -5.8165c-0.39516 -0.0065 -0.7383 -0.0126 -1.03012 -0.0182 -1.13927 -0.0218 -2.3245 -0.7513 -2.50968 -2.11C6.0464 37.803 6 37.215 6 36.5c0 -0.715 0.0464 -1.303 0.11063 -1.7742 0.18518 -1.3587 1.37041 -2.0882 2.50968 -2.11ZM17.5 16.5c0 -3.5899 2.9102 -6.5 6.5 -6.5 3.5899 0 6.5 2.9101 6.5 6.5 0 2.4056 -1.3068 4.5059 -3.2492 5.6299 1.2406 0.1048 2.2521 0.2613 3.0527 0.425 1.72 0.3516 2.9398 1.6803 3.3813 3.322 0.2504 0.9311 0.5364 2.12 0.8063 3.5291 0.028 0.1464 -0.0107 0.2977 -0.1057 0.4126S34.1491 30 34 30h-8.25v-3.5c0 -0.9665 -0.7835 -1.75 -1.75 -1.75s-1.75 0.7835 -1.75 1.75V30H14c-0.1491 0 -0.2904 -0.0665 -0.3854 -0.1814 -0.095 -0.1149 -0.1337 -0.2662 -0.1057 -0.4126 0.2698 -1.4089 0.5559 -2.5977 0.8062 -3.5287 0.4416 -1.642 1.6616 -2.9708 3.3818 -3.3224 0.8006 -0.1637 1.812 -0.3201 3.0524 -0.4249C18.8068 21.006 17.5 18.9056 17.5 16.5Z" clip-rule="evenodd" stroke-width="1"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" id="icon" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<circle cx="16" cy="19" r="2"/>
<path d="M23.7769,18.4785A8.64,8.64,0,0,0,16,13a8.64,8.64,0,0,0-7.7769,5.4785L8,19l.2231.5215A8.64,8.64,0,0,0,16,25a8.64,8.64,0,0,0,7.7769-5.4785L24,19ZM16,23a4,4,0,1,1,4-4A4.0045,4.0045,0,0,1,16,23Z"/>
<path d="M27,3H5A2,2,0,0,0,3,5V27a2,2,0,0,0,2,2H27a2,2,0,0,0,2-2V5A2,2,0,0,0,27,3ZM5,5H27V9H5ZM5,27V11H27V27Z"/>
<rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/>
</svg>

Before

Width:  |  Height:  |  Size: 799 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-search"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" /></svg>

Before

Width:  |  Height:  |  Size: 378 B

View file

@ -1,8 +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 20 20" xmlns="http://www.w3.org/2000/svg">
<rect x="0" fill="none" width="20" height="20"/>
<g>
<path d="M5 14V6h10v8H5zm-3-1V7h2v6H2zm4-6v6h8V7H6zm10 0h2v6h-2V7zm-3 2V8H7v1h6zm0 3v-2H7v2h6z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>

Before

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

View file

Before

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

After

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

Before After
Before After

View file

Before

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

After

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

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 573 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 976 B

Before After
Before After

View file

@ -1,11 +0,0 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Lumina
Comment=A church presentation app that is built to be simple to use.
Categories=Graphics;
Icon=lumina
Exec=lumina
Terminal=false

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>xyz.cochrun.lumina</id>
<name>Lumina</name>
<summary>A church presentation app that is built to be simple to use.</summary>
<url type="homepage">https://git.tfcconnection.org/chris/lumina/</url>
<metadata_license>FSFAP</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<description>
<p>
A church presentation app that is built to be simple to use.
</p>
</description>
<developer id="xyz.cochrun">
<name>Chris Cochrun</name>
</developer>
<launchable type="desktop-id">xyz.cochrun.lumina.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://git.tfcconnection.org/chris/lumina/raw/branch/master/res/images/screenshot_2026-05-03_08-16-59.png</image>
</screenshot>
<screenshot>
<image>https://git.tfcconnection.org/chris/lumina/raw/branch/master/res/images/screenshot_2026-05-03_08-23-08.png</image>
</screenshot>
<screenshot>
<image>https://git.tfcconnection.org/chris/lumina/raw/branch/master/res/images/screenshot_2026-05-03_08-23-23.png</image>
</screenshot>
</screenshots>
<categories>
<category>Graphics</category>
</categories>
<keywords>
<keyword>presentation</keyword>
<keyword>photo</keyword>
<keyword>video</keyword>
<keyword>cosmic</keyword>
</keywords>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>touch</control>
</supports>
</component>

View file

@ -1,12 +0,0 @@
# [lru]
# capacity = 64
[semanticHighlighting]
operator.enable = false
punctuation.enable = false
strings.enable = false
nonStandardTokens = false
[files]
exclude = ["flatpak-builder-tools", "cosmic-flatpak-runtime",
"flatpak-out", "mupdf", "build-dir", ".zed", "mupdf-cargo-sources.json", "cargo-sources.json"]

View file

@ -1,4 +1,3 @@
max_width = 90 max_width = 70
style_edition = "2024" style_edition = "2024"
# version = "Two" # version = "Two"
imports_granularity = "Module"

View file

@ -1,7 +1,6 @@
use crate::Background; use crate::Background;
use super::kinds::ServiceItemKind; use super::{kinds::ServiceItemKind, service_items::ServiceItem};
use super::service_items::ServiceItem;
pub trait Content { pub trait Content {
fn title(&self) -> String; fn title(&self) -> String;

View file

@ -1,20 +1,22 @@
use crate::core::kinds::ServiceItemKind; use crate::core::{
use crate::core::service_items::ServiceItem; kinds::ServiceItemKind, service_items::ServiceItem,
use crate::core::slide::Background; slide::Background,
};
use cosmic::widget::image::Handle; use cosmic::widget::image::Handle;
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use std::fs::{self, File}; use std::{
use std::io::Write; fs::{self, File},
use std::iter; io::Write,
use std::path::{Path, PathBuf}; iter,
use std::sync::Arc; path::{Path, PathBuf},
};
use tar::{Archive, Builder}; use tar::{Archive, Builder};
use tracing::{debug, error}; use tracing::{debug, error};
use zstd::{Decoder, Encoder}; use zstd::{Decoder, Encoder};
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn save( pub fn save(
list: &Arc<Vec<ServiceItem>>, list: Vec<ServiceItem>,
path: impl AsRef<Path>, path: impl AsRef<Path>,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<()> {
@ -24,7 +26,8 @@ pub fn save(
} }
let save_file = File::create(path).into_diagnostic()?; let save_file = File::create(path).into_diagnostic()?;
let ron_pretty = ron::ser::PrettyConfig::default(); let ron_pretty = ron::ser::PrettyConfig::default();
let ron = ron::ser::to_string_pretty(&list, ron_pretty).into_diagnostic()?; let ron = ron::ser::to_string_pretty(&list, ron_pretty)
.into_diagnostic()?;
let encoder = Encoder::new(save_file, 3) let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail") .expect("file encoder shouldn't fail")
@ -34,7 +37,8 @@ pub fn save(
"there should be a data directory, ~/.local/share/ for linux, but couldn't find it", "there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
); );
temp_dir.push("lumina"); temp_dir.push("lumina");
let mut s: String = iter::repeat_with(fastrand::alphanumeric).take(5).collect(); let mut s: String =
iter::repeat_with(fastrand::alphanumeric).take(5).collect();
s.insert_str(0, "temp_"); s.insert_str(0, "temp_");
temp_dir.push(s); temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?; fs::create_dir_all(&temp_dir).into_diagnostic()?;
@ -58,7 +62,9 @@ pub fn save(
} }
match tar.append_file("serviceitems.ron", &mut f) { match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => { Ok(()) => {
debug!("should have added serviceitems.ron to the file"); debug!(
"should have added serviceitems.ron to the file"
);
} }
Err(e) => { Err(e) => {
error!(?e); error!(?e);
@ -79,7 +85,7 @@ pub fn save(
Ok(()) Ok(())
}; };
for item in list.iter() { for item in list {
let background; let background;
let audio: Option<PathBuf>; let audio: Option<PathBuf>;
match &item.kind { match &item.kind {
@ -88,18 +94,23 @@ pub fn save(
audio = song.audio.clone(); audio = song.audio.clone();
} }
ServiceItemKind::Image(image) => { ServiceItemKind::Image(image) => {
background = background = Some(
Some(Background::try_from(image.path.clone()).into_diagnostic()?); Background::try_from(image.path.clone())
.into_diagnostic()?,
);
audio = None; audio = None;
} }
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
background = background = Some(
Some(Background::try_from(video.path.clone()).into_diagnostic()?); Background::try_from(video.path.clone())
.into_diagnostic()?,
);
audio = None; audio = None;
} }
ServiceItemKind::Presentation(presentation) => { ServiceItemKind::Presentation(presentation) => {
background = Some( background = Some(
Background::try_from(presentation.path.clone()).into_diagnostic()?, Background::try_from(presentation.path.clone())
.into_diagnostic()?,
); );
audio = None; audio = None;
} }
@ -120,11 +131,11 @@ pub fn save(
debug!(?path); debug!(?path);
append_file(path)?; append_file(path)?;
} }
for slide in &item.slides { for slide in item.slides {
if let Some(svg) = &slide.text_svg if let Some(svg) = slide.text_svg
&& let Some(path) = &svg.path && let Some(path) = svg.path
{ {
append_file(path.clone())?; append_file(path)?;
} }
} }
} }
@ -142,10 +153,12 @@ pub fn save(
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> { pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let decoder = let decoder =
Decoder::new(fs::File::open(&path).into_diagnostic()?).into_diagnostic()?; Decoder::new(fs::File::open(&path).into_diagnostic()?)
.into_diagnostic()?;
let mut tar = Archive::new(decoder); let mut tar = Archive::new(decoder);
let mut cache_dir = dirs::cache_dir().expect("Should be a cache dir"); let mut cache_dir =
dirs::cache_dir().expect("Should be a cache dir");
cache_dir.push("lumina"); cache_dir.push("lumina");
cache_dir.push("cached_save_files"); cache_dir.push("cached_save_files");
@ -162,7 +175,8 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.to_os_string() .to_os_string()
.into_string() .into_string()
.expect("Should be fine"); .expect("Should be fine");
let save_name = save_name_string.trim_end_matches(&format!(".{save_name_ext}")); let save_name = save_name_string
.trim_end_matches(&format!(".{save_name_ext}"));
cache_dir.push(save_name); cache_dir.push(save_name);
if let Err(e) = fs::remove_dir_all(&cache_dir) { if let Err(e) = fs::remove_dir_all(&cache_dir) {
@ -178,7 +192,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?; let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?;
let ron_file = dir let ron_file = dir
.find_map(|file| { .find_map(|file| {
if file.as_ref().ok()?.path().extension()?.to_str()? == "ron" { if file.as_ref().ok()?.path().extension()?.to_str()?
== "ron"
{
Some(file.ok()?.path()) Some(file.ok()?.path())
} else { } else {
None None
@ -186,10 +202,12 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
}) })
.expect("Should have a ron file"); .expect("Should have a ron file");
let ron_string = fs::read_to_string(ron_file).into_diagnostic()?; let ron_string =
fs::read_to_string(ron_file).into_diagnostic()?;
let mut items = let mut items =
ron::de::from_str::<Vec<ServiceItem>>(&ron_string).into_diagnostic()?; ron::de::from_str::<Vec<ServiceItem>>(&ron_string)
.into_diagnostic()?;
for item in &mut items { for item in &mut items {
let dir = fs::read_dir(&cache_dir).into_diagnostic()?; let dir = fs::read_dir(&cache_dir).into_diagnostic()?;
@ -197,20 +215,33 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
for slide in &mut item.slides { for slide in &mut item.slides {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
let audio_path = slide.audio().clone().unwrap_or_default(); let audio_path =
let text_path = slide.audio().clone().unwrap_or_default();
slide.text_svg.as_ref().and_then(|svg| svg.path.clone()); let text_path = slide
if Some(file_name.as_os_str()) == slide.background.path.file_name() { .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(); slide.background.path = file.path();
} else if Some(file_name.as_os_str()) == audio_path.file_name() { } else if Some(file_name.as_os_str())
let new_slide = slide.clone().set_audio(Some(file.path())); == audio_path.file_name()
{
let new_slide = slide
.clone()
.set_audio(Some(file.path()));
*slide = new_slide; *slide = new_slide;
} else if Some(file_name.as_os_str()) } else if Some(file_name.as_os_str())
== text_path.clone().unwrap_or_default().file_name() == text_path
.clone()
.unwrap_or_default()
.file_name()
&& let Some(svg) = slide.text_svg.as_mut() && let Some(svg) = slide.text_svg.as_mut()
{ {
svg.path = Some(file.path()); svg.path = Some(file.path());
svg.handle = Some(Handle::from_path(file.path())); svg.handle =
Some(Handle::from_path(file.path()));
} }
} }
} }
@ -219,7 +250,8 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Song(song) => { ServiceItemKind::Song(song) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
let audio_path = song.audio.clone().unwrap_or_default(); let audio_path =
song.audio.clone().unwrap_or_default();
if Some(file_name.as_os_str()) if Some(file_name.as_os_str())
== song == song
.background .background
@ -229,11 +261,14 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.file_name() .file_name()
{ {
let background = song.background.clone(); let background = song.background.clone();
song.background = background.map(|mut background| { song.background =
background.path = file.path(); background.map(|mut background| {
background background.path = file.path();
}); background
} else if Some(file_name.as_os_str()) == audio_path.file_name() { });
} else if Some(file_name.as_os_str())
== audio_path.file_name()
{
song.audio = Some(file.path()); song.audio = Some(file.path());
} }
} }
@ -241,7 +276,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) == video.path.file_name() { if Some(file_name.as_os_str())
== video.path.file_name()
{
video.path = file.path(); video.path = file.path();
} }
} }
@ -249,7 +286,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Image(image) => { ServiceItemKind::Image(image) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) == image.path.file_name() { if Some(file_name.as_os_str())
== image.path.file_name()
{
image.path = file.path(); image.path = file.path();
} }
} }
@ -257,7 +296,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Presentation(presentation) => { ServiceItemKind::Presentation(presentation) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) == presentation.path.file_name() { if Some(file_name.as_os_str())
== presentation.path.file_name()
{
presentation.path = file.path(); presentation.path = file.path();
} }
} }
@ -275,18 +316,20 @@ mod test {
use resvg::usvg::fontdb; use resvg::usvg::fontdb;
use super::*; use super::*;
use crate::core::service_items::ServiceTrait; use crate::{
use crate::core::slide::{Slide, TextAlignment}; core::{
use crate::core::songs::{Song, VerseName}; service_items::ServiceTrait,
use crate::ui::text_svg::text_svg_generator; slide::{Slide, TextAlignment},
use std::collections::HashMap; songs::{Song, VerseName},
use std::path::PathBuf; },
use std::sync::Arc; ui::text_svg::text_svg_generator,
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
fn test_song() -> Song { fn test_song() -> Song {
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 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 verse_map: Option<HashMap<VerseName, String>> = let verse_map: Option<HashMap<VerseName, String>> =
ron::from_str(&lyrics).expect(""); ron::from_str(&lyrics).unwrap();
Song { Song {
id: 7, id: 7,
title: "Death Was Arrested".to_string(), title: "Death Was Arrested".to_string(),
@ -297,7 +340,7 @@ mod test {
ccli: None, ccli: None,
audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()), audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()),
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()]), 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()]),
background: Some(Background::try_from("/home/chris/nc/tfc/presentations/mb/Geo Square.mp4").expect("")), background: Some(Background::try_from("/home/chris/nc/tfc/openlp/Flood/motions/Ocean_Floor_HD.mp4").unwrap()),
text_alignment: Some(TextAlignment::MiddleCenter), text_alignment: Some(TextAlignment::MiddleCenter),
font: None, font: None,
font_size: Some(120), font_size: Some(120),
@ -319,12 +362,20 @@ mod test {
let fontdb = Arc::new(fontdb); let fontdb = Arc::new(fontdb);
let slides = song let slides = song
.to_slides() .to_slides()
.expect("") .unwrap()
.into_par_iter() .into_par_iter()
.map(|slide| { .map(|slide| {
text_svg_generator(slide, &Arc::clone(&fontdb)).unwrap_or_else(|e| { text_svg_generator(
panic!("Couldn't create svg: {e}"); slide.clone(),
}) &Arc::clone(&fontdb),
)
.map_or_else(
|e| {
assert!(false, "Couldn't create svg: {e}");
slide
},
|slide| slide,
)
}) })
.collect::<Vec<Slide>>(); .collect::<Vec<Slide>>();
let items = vec![ let items = vec![
@ -340,7 +391,7 @@ mod test {
kind: ServiceItemKind::Song(song), kind: ServiceItemKind::Song(song),
id: 1, id: 1,
title: "Death was Arrested".into(), title: "Death was Arrested".into(),
slides, slides: slides,
}, },
]; ];
items items
@ -353,7 +404,7 @@ mod test {
let result = load(&path); let result = load(&path);
match result { match result {
Ok(items) => { Ok(items) => {
assert!(!items.is_empty()); assert!(items.len() > 0);
// assert_eq!(items, get_items()); // assert_eq!(items, get_items());
let cache_dir = cache_dir(); let cache_dir = cache_dir();
assert!(fs::read_dir(&cache_dir).is_ok()); assert!(fs::read_dir(&cache_dir).is_ok());
@ -364,58 +415,37 @@ mod test {
find_svgs(&items)?; find_svgs(&items)?;
Ok(()) Ok(())
} }
Err(e) => Err(format!("Error in the loading process: {e}")), Err(e) => Err(e.to_string()),
} }
} }
fn test_size_and_cache(mut path: PathBuf) -> Result<(), String> { fn find_svgs(items: &Vec<ServiceItem>) -> Result<(), String> {
let cache_dir = cache_dir(); let cache_dir = cache_dir();
if path.metadata().expect("").len() < 15000 {
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",
))
}
}
fn find_svgs(items: &[ServiceItem]) -> Result<(), String> {
items.iter().try_for_each(|item| { items.iter().try_for_each(|item| {
if let ServiceItemKind::Song(..) = item.kind { if let ServiceItemKind::Song(..) = item.kind {
item.slides.iter().try_for_each(|slide| { item.slides.iter().try_for_each(|slide| {
slide.text_svg.as_ref().map_or_else( slide.text_svg.as_ref().map_or(Err(String::from("There is no TextSvg for this song")), |text_svg| {
|| Err(String::from("There is no TextSvg for this song")),
|text_svg| {
if text_svg.handle.is_none() {
return Err(String::from(
"There is no handle in this song's TextSvg",
));
}
text_svg.path.as_ref().map_or_else( if text_svg.handle.is_none() {
|| { return Err(String::from("There is no handle in this song's TextSvg"));
Err(String::from( };
"There is no path in this song's TextSvg",
)) text_svg.path.as_ref().map_or(Err(String::from("There is no path in this song's TextSvg")), |path| {
}, if path.exists() {
|path| { let mut path = path.clone();
if path.exists() { if path.metadata().unwrap().len() < 20000 {
test_size_and_cache(path.clone()) return Err(String::from("SVG text is too small, maybe the svg didn't generate properly"))
} else { }
Err(String::from( if path.pop() && path == cache_dir {
"The path in this TextSvg doesn't exist", 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 { } else {
Ok(()) Ok(())
@ -424,20 +454,20 @@ mod test {
} }
// checks to make sure all paths in slides and items point to cache_dir // checks to make sure all paths in slides and items point to cache_dir
fn find_paths(items: &[ServiceItem]) -> bool { fn find_paths(items: &Vec<ServiceItem>) -> bool {
let cache_dir = cache_dir(); let cache_dir = cache_dir();
items.iter().all(|item| { items.iter().all(|item| {
match &item.kind { match &item.kind {
ServiceItemKind::Song(song) => { ServiceItemKind::Song(song) => {
if let Some(bg) = &song.background if let Some(bg) = &song.background {
&& !bg.path.starts_with(&cache_dir) if !bg.path.starts_with(&cache_dir) {
{ return false;
return false; }
} }
if let Some(audio) = &song.audio if let Some(audio) = &song.audio {
&& !audio.starts_with(&cache_dir) if !audio.starts_with(&cache_dir) {
{ return false;
return false; }
} }
} }
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
@ -461,10 +491,9 @@ mod test {
if !slide.background().path.starts_with(&cache_dir) { if !slide.background().path.starts_with(&cache_dir) {
return false; return false;
} }
if !slide if !slide.audio().map_or(true, |audio| {
.audio() audio.starts_with(&cache_dir)
.is_none_or(|audio| audio.starts_with(&cache_dir)) }) {
{
return false; return false;
} }
} }
@ -473,7 +502,7 @@ mod test {
} }
fn cache_dir() -> PathBuf { fn cache_dir() -> PathBuf {
let mut cache_dir = dirs::cache_dir().expect(""); let mut cache_dir = dirs::cache_dir().unwrap();
cache_dir.push("lumina"); cache_dir.push("lumina");
cache_dir.push("cached_save_files"); cache_dir.push("cached_save_files");
cache_dir.push("test"); cache_dir.push("test");
@ -484,18 +513,22 @@ mod test {
fn test_save() { fn test_save() {
let path = PathBuf::from("./test.pres"); let path = PathBuf::from("./test.pres");
let list = get_items(); let list = get_items();
match save(&Arc::new(list), &path, true) { match save(list, &path, true) {
Ok(()) => { Ok(_) => {
assert!(path.is_file()); assert!(path.is_file());
let Ok(file) = fs::File::open(path) else { let Ok(file) = fs::File::open(path) else {
panic!("couldn't open file"); return assert!(false, "couldn't open file");
}; };
let Ok(size) = file.metadata().map(|data| data.len()) else { let Ok(size) = file.metadata().map(|data| data.len())
panic!("couldn't get file metadata"); else {
return assert!(
false,
"couldn't get file metadata"
);
}; };
assert!(size > 0); assert!(size > 0);
} }
Err(e) => panic!("{e}"), Err(e) => assert!(false, "{e}"),
} }
} }
} }

View file

@ -1,30 +1,28 @@
use crate::core::model::{Sort, SortDirection};
use crate::{Background, Slide, SlideBuilder, TextAlignment}; use crate::{Background, Slide, SlideBuilder, TextAlignment};
use super::content::Content; use super::{
use super::kinds::ServiceItemKind; content::Content,
use super::model::{LibraryKind, Model}; kinds::ServiceItemKind,
use super::service_items::ServiceTrait; model::{LibraryKind, Model},
service_items::ServiceTrait,
};
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use itertools::Itertools; use miette::{IntoDiagnostic, Result};
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::chrono::{DateTime, Local}; use sqlx::{
use sqlx::{AssertSqlSafe, SqliteConnection, SqlitePool, query, query_as}; Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection,
use std::mem::replace; query, query_as,
};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use tracing::{debug, error};
use tracing::error;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub struct Image { pub struct Image {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
pub path: PathBuf, pub path: PathBuf,
#[serde(skip)]
pub created_at: DateTime<Local>,
#[serde(skip)]
pub accessed_at: DateTime<Local>,
} }
impl From<PathBuf> for Image { impl From<PathBuf> for Image {
@ -39,8 +37,6 @@ impl From<PathBuf> for Image {
id: 0, id: 0,
title, title,
path: value.canonicalize().unwrap_or(value), path: value.canonicalize().unwrap_or(value),
created_at: Local::now(),
accessed_at: Local::now(),
} }
} }
} }
@ -97,19 +93,22 @@ impl From<&Value> for Image {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
Value::List(list) => { Value::List(list) => {
let path = if let Some(path_pos) = 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"))
{ }) {
let pos = path_pos + 1; let pos = path_pos + 1;
list.get(pos).map(|p| PathBuf::from(String::from(p))) list.get(pos)
.map(|p| PathBuf::from(String::from(p)))
} else { } else {
None None
}; };
let title = path.clone().map(|p| { let title = path.clone().map(|p| {
let path = p.to_str().unwrap_or_default().to_string(); let path =
let title = path.rsplit_once('/').unwrap_or_default().1; p.to_str().unwrap_or_default().to_string();
let title =
path.rsplit_once('/').unwrap_or_default().1;
title.to_string() title.to_string()
}); });
Self { Self {
@ -134,7 +133,10 @@ impl ServiceTrait for Image {
fn to_slides(&self) -> Result<Vec<Slide>> { fn to_slides(&self) -> Result<Vec<Slide>> {
let slide = SlideBuilder::new() let slide = SlideBuilder::new()
.background(Background::try_from(self.path.clone()).into_diagnostic()?) .background(
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("") .text("")
.audio("") .audio("")
.font("") .font("")
@ -154,11 +156,10 @@ impl ServiceTrait for Image {
} }
impl Model<Image> { impl Model<Image> {
pub async fn new_image_model(db: Arc<SqlitePool>) -> Self { pub async fn new_image_model(db: &mut SqlitePool) -> Self {
let mut model = Self { let mut model = Self {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let mut db = db.acquire().await.expect("probs"); let mut db = db.acquire().await.expect("probs");
@ -170,7 +171,7 @@ impl Model<Image> {
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_as!( let result = query_as!(
Image, Image,
r#"SELECT title as "title!", file_path as "path!", id as "id: i32", accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" from images"# r#"SELECT title as "title!", file_path as "path!", id as "id: i32" from images"#
) )
.fetch_all(db) .fetch_all(db)
.await; .await;
@ -181,150 +182,83 @@ impl Model<Image> {
} }
} }
Err(e) => { Err(e) => {
error!("There was an error in converting images: {e}"); error!(
"There was an error in converting images: {e}"
);
} }
} }
} }
pub fn sort(&mut self) {
match self.sorting_method {
Sort::AccessTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.accessed_at.cmp(&a.accessed_at))
}
Sort::AccessTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.accessed_at.cmp(&b.accessed_at))
}
Sort::Title(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.title.cmp(&a.title))
}
Sort::Title(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.title.cmp(&b.title))
}
Sort::CreatedTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.created_at.cmp(&a.created_at))
}
Sort::CreatedTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.created_at.cmp(&b.created_at))
}
Sort::Secondary(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.path.cmp(&a.path))
}
Sort::Secondary(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.path.cmp(&b.path))
}
}
}
pub fn set_sort(mut self, method: Sort) -> Self {
self.sorting_method = method;
self.sort();
self
}
} }
pub async fn remove_images( pub async fn remove_from_db(
db: Arc<SqlitePool>, db: PoolConnection<Sqlite>,
images: Vec<Image>,
ids: Vec<i32>,
) -> Result<Vec<Image>> {
let images = images
.into_iter()
.filter(|current_image| !ids.contains(&current_image.id))
.collect();
let delete = format!(
"DELETE FROM images WHERE id IN ({:})",
ids.iter().map(ToString::to_string).join(", ")
);
query(AssertSqlSafe(delete))
.execute(&*db)
.await
.into_diagnostic()
.map(|_| images)
}
pub async fn remove_image(
db: Arc<SqlitePool>,
mut images: Vec<Image>,
id: i32, id: i32,
) -> Result<Vec<Image>> { ) -> Result<()> {
query!("DELETE FROM images WHERE id = $1", id) query!("DELETE FROM images WHERE id = $1", id)
.execute(&*db) .execute(&mut db.detach())
.await .await
.into_diagnostic() .into_diagnostic()
.map(|_| ())?; .map(|_| ())
let index = images
.iter()
.position(|current_image| current_image.id == id)
.ok_or_else(|| miette!("Could not find image in model"))?;
images.remove(index);
Ok(images)
} }
pub async fn add_image( pub async fn add_image_to_db(
new_images: Vec<Image>,
mut current_images: Vec<Image>,
db: Arc<SqlitePool>,
) -> Result<Vec<Image>> {
for image in new_images {
let path = image
.path
.to_str()
.map(ToString::to_string)
.unwrap_or_default();
query!(
r#"INSERT INTO images (title, file_path) VALUES ($1, $2)"#,
image.title,
path,
)
.execute(&*db)
.await
.into_diagnostic()?;
current_images.push(image);
}
Ok(current_images)
}
pub async fn update_image(
image: Image, image: Image,
mut images: Vec<Image>, db: PoolConnection<Sqlite>,
db: Arc<SqlitePool>, ) -> Result<()> {
) -> Result<Vec<Image>> {
let path = image let path = image
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(std::string::ToString::to_string)
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach();
query!( 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(
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();
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(&*db) .execute(&mut db)
.await .await.into_diagnostic();
.into_diagnostic()?;
let current_image = images match result {
.iter() Ok(_) => {
.position(|current_image| current_image.id == image.id) debug!("should have been updated");
.ok_or_else(|| miette!("Could not find image in model")) Ok(())
.map(|index| { }
images Err(e) => {
.get_mut(index) error! {?e};
.expect("We should have this image already") Err(e)
})?; }
}
let _ = replace(current_image, image);
Ok(images)
} }
pub async fn get_from_db(database_id: i32, db: &mut SqliteConnection) -> Result<Image> { pub async fn get_image_from_db(
query_as!(Image, r#"SELECT title as "title!", file_path as "path!", id as "id: i32", accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" from images where id = ?"#, database_id).fetch_one(db).await.into_diagnostic() database_id: i32,
db: &mut SqliteConnection,
) -> Result<Image> {
query_as!(Image, r#"SELECT title as "title!", file_path as "path!", id as "id: i32" from images where id = ?"#, database_id).fetch_one(db).await.into_diagnostic()
} }
#[cfg(test)] #[cfg(test)]
@ -335,7 +269,9 @@ mod test {
fn test_image(title: String) -> Image { fn test_image(title: String) -> Image {
Image { Image {
title, title,
path: PathBuf::from("/home/chris/pics/memes/no-i-dont-think.gif"), path: PathBuf::from(
"/home/chris/pics/memes/no-i-dont-think.gif",
),
..Default::default() ..Default::default()
} }
} }
@ -345,20 +281,14 @@ mod test {
let mut image_model: Model<Image> = Model { let mut image_model: Model<Image> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let mut db = add_db() let mut db = add_db().await.unwrap().acquire().await.unwrap();
.await
.expect("Error getting db")
.acquire()
.await
.expect("");
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 == 23) {
let test_image = test_image("no-i-dont-think.gif".into()); let test_image = test_image("no-i-dont-think.gif".into());
assert_eq!(test_image.title, image.title); assert_eq!(test_image.title, image.title);
} else { } else {
panic!(); assert!(false);
} }
} }
@ -368,18 +298,25 @@ mod test {
let mut image_model: Model<Image> = Model { let mut image_model: Model<Image> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let result = image_model.add_item(image.clone()); let result = image_model.add_item(image.clone());
let new_image = test_image("A newer image".into()); let new_image = test_image("A newer image".into());
match result { match result {
Ok(()) => { Ok(_) => {
assert_eq!(&image, image_model.find(|i| i.id == 0).expect("")); assert_eq!(
assert_ne!(&new_image, image_model.find(|i| i.id == 0).expect("")); &image,
} image_model.find(|i| i.id == 0).unwrap()
Err(e) => { );
panic!("There was an error adding the image: {e:?}",) assert_ne!(
&new_image,
image_model.find(|i| i.id == 0).unwrap()
);
} }
Err(e) => assert!(
false,
"There was an error adding the image: {:?}",
e
),
} }
} }

View file

@ -1,17 +1,16 @@
use std::error::Error; use std::{error::Error, fmt::Display, path::PathBuf};
use std::fmt::Display;
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Slide; use crate::{
use crate::core::content::Content; Slide,
use crate::core::service_items::ServiceItem; core::{content::Content, service_items::ServiceItem},
};
use super::images::Image; use super::{
use super::presentations::Presentation; images::Image, presentations::Presentation, songs::Song,
use super::songs::Song; videos::Video,
use super::videos::Video; };
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServiceItemKind { pub enum ServiceItemKind {
@ -29,10 +28,18 @@ impl TryFrom<PathBuf> for ServiceItemKind {
let ext = path let ext = path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.ok_or_else(|| miette::miette!("There isn't an extension on this file"))?; .ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext { match ext {
"png" | "jpg" | "jpeg" => Ok(Self::Image(Image::from(path))), "png" | "jpg" | "jpeg" => {
"mp4" | "mkv" | "webm" => Ok(Self::Video(Video::from(path))), Ok(Self::Image(Image::from(path)))
}
"mp4" | "mkv" | "webm" => {
Ok(Self::Video(Video::from(path)))
}
"pdf" => Ok(Self::Presentation(Presentation::from(path))), "pdf" => Ok(Self::Presentation(Presentation::from(path))),
_ => Err(miette::miette!("Unknown item")), _ => Err(miette::miette!("Unknown item")),
} }
@ -45,7 +52,9 @@ impl ServiceItemKind {
Self::Song(song) => song.title.clone(), Self::Song(song) => song.title.clone(),
Self::Video(video) => video.title.clone(), Self::Video(video) => video.title.clone(),
Self::Image(image) => image.title.clone(), Self::Image(image) => image.title.clone(),
Self::Presentation(presentation) => presentation.title.clone(), Self::Presentation(presentation) => {
presentation.title.clone()
}
Self::Content(_slide) => todo!(), Self::Content(_slide) => todo!(),
} }
} }
@ -55,7 +64,9 @@ impl ServiceItemKind {
Self::Song(song) => song.to_service_item(), Self::Song(song) => song.to_service_item(),
Self::Video(video) => video.to_service_item(), Self::Video(video) => video.to_service_item(),
Self::Image(image) => image.to_service_item(), Self::Image(image) => image.to_service_item(),
Self::Presentation(presentation) => presentation.to_service_item(), Self::Presentation(presentation) => {
presentation.to_service_item()
}
Self::Content(_slide) => { Self::Content(_slide) => {
todo!() todo!()
} }
@ -100,7 +111,9 @@ impl From<ServiceItemKind> for String {
ServiceItemKind::Song(_) => "song".to_owned(), ServiceItemKind::Song(_) => "song".to_owned(),
ServiceItemKind::Video(_) => "video".to_owned(), ServiceItemKind::Video(_) => "video".to_owned(),
ServiceItemKind::Image(_) => "image".to_owned(), ServiceItemKind::Image(_) => "image".to_owned(),
ServiceItemKind::Presentation(_) => "presentation".to_owned(), ServiceItemKind::Presentation(_) => {
"presentation".to_owned()
}
ServiceItemKind::Content(_) => "content".to_owned(), ServiceItemKind::Content(_) => "content".to_owned(),
} }
} }
@ -114,7 +127,10 @@ pub enum ParseError {
impl Error for ParseError {} impl Error for ParseError {}
impl Display for ParseError { impl Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> 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'"
@ -128,6 +144,6 @@ impl Display for ParseError {
mod test { mod test {
#[test] #[test]
pub fn test_kinds() { pub fn test_kinds() {
assert_eq!(true, true); assert_eq!(true, true)
} }
} }

View file

@ -12,4 +12,3 @@ pub mod song_search;
pub mod songs; pub mod songs;
pub mod thumbnail; pub mod thumbnail;
pub mod videos; pub mod videos;
pub mod ytdl;

View file

@ -1,7 +1,4 @@
use std::borrow::Cow; use std::{borrow::Cow, fs, mem::replace, path::PathBuf};
use std::fs;
use std::mem::replace;
use std::path::PathBuf;
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes}; use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
@ -13,10 +10,11 @@ use tracing::debug;
pub struct Model<T> { pub struct Model<T> {
pub items: Vec<T>, pub items: Vec<T>,
pub kind: LibraryKind, pub kind: LibraryKind,
pub sorting_method: Sort,
} }
#[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)] #[derive(
Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize,
)]
pub enum LibraryKind { pub enum LibraryKind {
Song, Song,
Video, Video,
@ -24,21 +22,9 @@ pub enum LibraryKind {
Presentation, Presentation,
} }
#[derive(Debug, Clone, Eq, PartialEq, Copy, Serialize, Deserialize)] #[derive(
pub enum Sort { Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
AccessTime(SortDirection), )]
CreatedTime(SortDirection),
Title(SortDirection),
Secondary(SortDirection), // This can be author or file name
}
#[derive(Debug, Clone, Eq, PartialEq, Copy, Serialize, Deserialize)]
pub enum SortDirection {
Ascending,
Descending,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KindWrapper(pub (LibraryKind, i32)); pub struct KindWrapper(pub (LibraryKind, i32));
impl From<PathBuf> for LibraryKind { impl From<PathBuf> for LibraryKind {
@ -50,10 +36,14 @@ impl From<PathBuf> for LibraryKind {
impl TryFrom<(Vec<u8>, String)> for KindWrapper { impl TryFrom<(Vec<u8>, String)> for KindWrapper {
type Error = miette::Error; type Error = miette::Error;
fn try_from(value: (Vec<u8>, String)) -> std::result::Result<Self, Self::Error> { fn try_from(
value: (Vec<u8>, String),
) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value; let (data, mime) = value;
match mime.as_str() { match mime.as_str() {
"application/service-item" => ron::de::from_bytes(&data).into_diagnostic(), "application/service-item" => {
ron::de::from_bytes(&data).into_diagnostic()
}
_ => Err(miette!("Wrong mime type: {mime}")), _ => Err(miette!("Wrong mime type: {mime}")),
} }
} }
@ -71,7 +61,10 @@ impl AsMimeTypes for KindWrapper {
Cow::from(vec!["application/service-item".to_string()]) Cow::from(vec!["application/service-item".to_string()])
} }
fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> { fn as_bytes(
&self,
mime_type: &str,
) -> 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 ron = ron::ser::to_string(self).ok()?;
@ -90,41 +83,37 @@ impl<T> Model<T> {
todo!() todo!()
} }
pub fn update_item<P>(&mut self, item: T, predicate: P) -> Result<()> pub fn update_item(&mut self, item: T, index: i32) -> Result<()> {
where
P: Fn(&T) -> bool,
{
self.items self.items
.iter() .get_mut(
.position(predicate) usize::try_from(index)
.ok_or_else(|| miette!("Item cannot be found")) .expect("Shouldn't be negative"),
.map(|index| { )
self.items .map_or_else(
.get_mut(index) || {
.expect("Since we found position this should always exist") Err(miette!(
}) "Item doesn't exist in model. Id was {index}"
.map(|current_item| { ))
let _old_item = replace(current_item, item); },
}) |current_item| {
let _old_item = replace(current_item, item);
Ok(())
},
)
} }
pub fn remove_item<P>(&mut self, predicate: P) -> Result<()> pub fn remove_item(&mut self, index: i32) -> Result<()> {
where self.items.remove(
P: Fn(&T) -> bool, usize::try_from(index).expect("Shouldn't be negative"),
{ );
self.items Ok(())
.iter()
.position(predicate)
.ok_or_else(|| miette!("Item cannot be found"))
.map(|index| {
self.items.remove(index);
})
} }
#[must_use] #[must_use]
pub fn get_item(&self, index: i32) -> Option<&T> { pub fn get_item(&self, index: i32) -> Option<&T> {
self.items self.items.get(
.get(usize::try_from(index).expect("shouldn't be negative")) 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>
@ -134,8 +123,11 @@ impl<T> Model<T> {
self.items.iter().find(f) self.items.iter().find(f)
} }
pub fn insert_item(&mut self, item: T, index: usize) -> Result<()> { pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> {
self.items.insert(index, item); self.items.insert(
usize::try_from(index).expect("Shouldn't be negative"),
item,
);
Ok(()) Ok(())
} }
} }
@ -152,7 +144,8 @@ impl<T> Model<T> {
// } // }
pub async fn get_db() -> SqliteConnection { pub async fn get_db() -> SqliteConnection {
let mut data = dirs::data_local_dir().expect("Should be able to find a data dir"); let mut data = dirs::data_local_dir()
.expect("Should be able to find a data dir");
data.push("lumina"); data.push("lumina");
let _ = fs::create_dir_all(&data); let _ = fs::create_dir_all(&data);
data.push("library-db.sqlite3"); data.push("library-db.sqlite3");

View file

@ -1,27 +1,27 @@
use cosmic::widget::image::Handle; use cosmic::widget::image::Handle;
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use itertools::Itertools; use miette::{IntoDiagnostic, Result};
use miette::{IntoDiagnostic, Result, miette};
use mupdf::{Colorspace, Document, Matrix}; use mupdf::{Colorspace, Document, Matrix};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow; use sqlx::{
use sqlx::sqlite::SqliteRow; Row, Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection,
use sqlx::types::chrono::{DateTime, Local}; prelude::FromRow, query, sqlite::SqliteRow,
use sqlx::{AssertSqlSafe, Row, SqliteConnection, SqlitePool, query}; };
use std::mem::replace;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, error}; use tracing::{debug, error};
use crate::core::model::{Sort, SortDirection};
use crate::{Background, Slide, SlideBuilder, TextAlignment}; use crate::{Background, Slide, SlideBuilder, TextAlignment};
use super::content::Content; use super::{
use super::kinds::ServiceItemKind; content::Content,
use super::model::{LibraryKind, Model}; kinds::ServiceItemKind,
use super::service_items::ServiceTrait; model::{LibraryKind, Model},
service_items::ServiceTrait,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum PresKind { pub enum PresKind {
Html, Html,
Pdf { Pdf {
@ -38,10 +38,6 @@ pub struct Presentation {
pub title: String, pub title: String,
pub path: PathBuf, pub path: PathBuf,
pub kind: PresKind, pub kind: PresKind,
#[serde(skip)]
pub created_at: DateTime<Local>,
#[serde(skip)]
pub accessed_at: DateTime<Local>,
} }
impl Eq for Presentation {} impl Eq for Presentation {}
@ -69,7 +65,7 @@ impl From<PathBuf> for Presentation {
.to_str() .to_str()
.unwrap_or_default() .unwrap_or_default()
{ {
"pdf" => Document::open(&value.to_str().unwrap_or_default()).map_or( "pdf" => Document::open(&value.as_path()).map_or(
PresKind::Pdf { PresKind::Pdf {
starting_index: 0, starting_index: 0,
ending_index: 0, ending_index: 0,
@ -95,8 +91,6 @@ impl From<PathBuf> for Presentation {
title, title,
path: value.canonicalize().unwrap_or(value), path: value.canonicalize().unwrap_or(value),
kind, kind,
created_at: Local::now(),
accessed_at: Local::now(),
} }
} }
} }
@ -153,19 +147,20 @@ impl From<&Value> for Presentation {
fn from(value: &Value) -> Self { fn from(value: &Value) -> Self {
match value { match value {
Value::List(list) => { Value::List(list) => {
let path = if let Some(path_pos) = 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"))
{ }) {
let pos = path_pos + 1; let pos = path_pos + 1;
list.get(pos).map(|p| PathBuf::from(String::from(p))) list.get(pos)
.map(|p| PathBuf::from(String::from(p)))
} else { } else {
None None
}; };
let title = path let title = path.clone().map(|p| {
.clone() p.to_str().unwrap_or_default().to_string()
.map(|p| p.to_str().unwrap_or_default().to_string()); });
Self { Self {
title: title.unwrap_or_default(), title: title.unwrap_or_default(),
path: path.unwrap_or_default(), path: path.unwrap_or_default(),
@ -193,11 +188,14 @@ impl ServiceTrait for Presentation {
ending_index, ending_index,
} = self.kind } = self.kind
else { else {
return Err(miette::miette!("This is not a pdf presentation")); return Err(miette::miette!(
"This is not a pdf presentation"
));
}; };
let background = Background::try_from(self.path.clone()).into_diagnostic()?; let background = Background::try_from(self.path.clone())
.into_diagnostic()?;
debug!(?background); debug!(?background);
let document = Document::open(background.path.to_str().unwrap_or_default()) let document = Document::open(background.path.as_path())
.into_diagnostic()?; .into_diagnostic()?;
debug!(?document); debug!(?document);
let pages = document.pages().into_diagnostic()?; let pages = document.pages().into_diagnostic()?;
@ -205,7 +203,8 @@ impl ServiceTrait for Presentation {
let pages: Vec<Handle> = pages let pages: Vec<Handle> = pages
.enumerate() .enumerate()
.filter_map(|(index, page)| { .filter_map(|(index, page)| {
let index = i32::try_from(index).expect("Shouldn't be that high"); let index = i32::try_from(index)
.expect("Shouldn't be that high");
if index < starting_index || index > ending_index { if index < starting_index || index > ending_index {
return None; return None;
@ -233,7 +232,10 @@ impl ServiceTrait for Presentation {
let mut slides: Vec<Slide> = vec![]; let mut slides: Vec<Slide> = vec![];
for (index, page) in pages.into_iter().enumerate() { for (index, page) in pages.into_iter().enumerate() {
let slide = SlideBuilder::new() let slide = SlideBuilder::new()
.background(Background::try_from(self.path.clone()).into_diagnostic()?) .background(
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("") .text("")
.audio("") .audio("")
.font("") .font("")
@ -242,7 +244,10 @@ impl ServiceTrait for Presentation {
.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(u32::try_from(index).expect("Shouldn't get that high")) .pdf_index(
u32::try_from(index)
.expect("Shouldn't get that high"),
)
.pdf_page(page) .pdf_page(page)
.build()?; .build()?;
slides.push(slide); slides.push(slide);
@ -288,28 +293,27 @@ impl FromRow<'_, SqliteRow> for Presentation {
ending_index: row.try_get(5)?, ending_index: row.try_get(5)?,
} }
}, },
created_at: Local::now(),
accessed_at: Local::now(),
}) })
} }
} }
impl Model<Presentation> { impl Model<Presentation> {
pub async fn new_presentation_model(db: Arc<SqlitePool>) -> Self { pub async fn new_presentation_model(db: &mut SqlitePool) -> Self {
let mut model = Self { let mut model = Self {
items: vec![], items: vec![],
kind: LibraryKind::Presentation, kind: LibraryKind::Presentation,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
model.load_from_db(db).await; let mut db = db.acquire().await.expect("probs");
model.load_from_db(&mut db).await;
model model
} }
pub async fn load_from_db(&mut self, db: Arc<SqlitePool>) { 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, accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" from presentations"# r#"SELECT id as "id: i32", title, file_path as "path", html, starting_index, ending_index from presentations"#
) )
.fetch_all(&*db) .fetch_all(db)
.await; .await;
match result { match result {
@ -321,19 +325,28 @@ impl Model<Presentation> {
path: presentation.path.clone().into(), path: presentation.path.clone().into(),
kind: if presentation.html { kind: if presentation.html {
PresKind::Html PresKind::Html
} else if let (Some(starting_index), Some(ending_index)) = } else if let (
(presentation.starting_index, presentation.ending_index) Some(starting_index),
{ Some(ending_index),
) = (
presentation.starting_index,
presentation.ending_index,
) {
PresKind::Pdf { PresKind::Pdf {
starting_index: i32::try_from(starting_index) starting_index: i32::try_from(
.expect("Shouldn't get that high"), starting_index,
ending_index: i32::try_from(ending_index) )
.expect("Shouldn't get that high"), .expect("Shouldn't get that high"),
ending_index: i32::try_from(
ending_index,
)
.expect("Shouldn't get that high"),
} }
} else { } else {
let path = PathBuf::from(presentation.path); let path =
PathBuf::from(presentation.path);
Document::open(path.to_str().unwrap_or_default()).map_or( Document::open(path.as_path()).map_or(
PresKind::Generic, PresKind::Generic,
|document| { |document| {
document.page_count().map_or( document.page_count().map_or(
@ -342,7 +355,8 @@ impl Model<Presentation> {
ending_index: 0, ending_index: 0,
}, },
|count| { |count| {
let ending_index = count - 1; let ending_index =
count - 1;
PresKind::Pdf { PresKind::Pdf {
starting_index: 0, starting_index: 0,
ending_index, ending_index,
@ -352,153 +366,130 @@ impl Model<Presentation> {
}, },
) )
}, },
created_at: presentation.created_at,
accessed_at: presentation.accessed_at,
}); });
} }
} }
Err(e) => error!("There was an error in converting presentations: {e}"), Err(e) => error!(
"There was an error in converting presentations: {e}"
),
} }
} }
pub fn sort(&mut self) {
match self.sorting_method {
Sort::AccessTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.accessed_at.cmp(&a.accessed_at))
}
Sort::AccessTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.accessed_at.cmp(&b.accessed_at))
}
Sort::Title(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.title.cmp(&a.title))
}
Sort::Title(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.title.cmp(&b.title))
}
Sort::CreatedTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.created_at.cmp(&a.created_at))
}
Sort::CreatedTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.created_at.cmp(&b.created_at))
}
Sort::Secondary(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.path.cmp(&a.path))
}
Sort::Secondary(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.path.cmp(&b.path))
}
}
}
pub fn set_sort(mut self, method: Sort) -> Self {
self.sorting_method = method;
self.sort();
self
}
} }
pub async fn remove_presentations( pub async fn remove_from_db(
db: Arc<SqlitePool>, db: PoolConnection<Sqlite>,
presentations: Vec<Presentation>,
ids: Vec<i32>,
) -> Result<Vec<Presentation>> {
let presentations = presentations
.into_iter()
.filter(|current_presentation| !ids.contains(&current_presentation.id))
.collect();
let delete = format!(
"DELETE FROM presentations WHERE id IN ({:})",
ids.iter().map(ToString::to_string).join(", ")
);
query(AssertSqlSafe(delete))
.execute(&*db)
.await
.into_diagnostic()
.map(|_| presentations)
}
pub async fn remove_presentation(
db: Arc<SqlitePool>,
mut presentations: Vec<Presentation>,
id: i32, id: i32,
) -> Result<Vec<Presentation>> { ) -> Result<()> {
query!("DELETE FROM presentations WHERE id = $1", id) query!("DELETE FROM presentations WHERE id = $1", id)
.execute(&*db) .execute(&mut db.detach())
.await .await
.into_diagnostic() .into_diagnostic()
.map(|_| ())?; .map(|_| ())
let index = presentations
.iter()
.position(|current_presentation| current_presentation.id == id)
.ok_or_else(|| miette!("Could not find presentation in model"))?;
presentations.remove(index);
Ok(presentations)
} }
pub async fn add_presentation( pub async fn add_presentation_to_db(
new_presentations: Vec<Presentation>,
mut current_presentations: Vec<Presentation>,
db: Arc<SqlitePool>,
) -> Result<Vec<Presentation>> {
for presentation in new_presentations {
let path = presentation
.path
.to_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
let html = presentation.kind == PresKind::Html;
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(&*db)
.await
.into_diagnostic()?;
current_presentations.push(presentation);
}
Ok(current_presentations)
}
pub async fn update_presentation(
presentation: Presentation, presentation: Presentation,
mut presentations: Vec<Presentation>, db: PoolConnection<Sqlite>,
db: Arc<SqlitePool>, ) -> Result<()> {
) -> Result<Vec<Presentation>> {
let path = presentation let path = presentation
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(std::string::ToString::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();
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(
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 { let (starting_index, ending_index) = if let PresKind::Pdf {
starting_index: s_index, starting_index: s_index,
ending_index: e_index, ending_index: e_index,
} = presentation.get_kind() } =
presentation.get_kind()
{ {
(*s_index, *e_index) (*s_index, *e_index)
} else { } else {
(0, 0) (0, 0)
}; };
debug!(starting_index, ending_index); 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();
query!( 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"#, 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,
@ -507,28 +498,26 @@ pub async fn update_presentation(
starting_index, starting_index,
ending_index ending_index
) )
.execute(&*db) .execute(&mut db)
.await.into_diagnostic()?; .await.into_diagnostic();
let current_presentation = presentations match result {
.iter() Ok(_) => {
.position(|current_presentation| current_presentation.id == presentation.id) debug!("should have been updated");
.ok_or_else(|| miette!("Could not find presentation in model")) Ok(())
.map(|index| { }
presentations Err(e) => {
.get_mut(index) error! {?e};
.expect("We should have this presentation already") Err(e)
})?; }
}
let _ = replace(current_presentation, presentation);
Ok(presentations)
} }
pub async fn get_presentation_from_db( pub async fn get_presentation_from_db(
database_id: i32, database_id: i32,
db: &mut SqliteConnection, db: &mut SqliteConnection,
) -> Result<Presentation> { ) -> Result<Presentation> {
let row = query(r#"SELECT id as "id: i32", title, file_path as "path", html, accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" from presentations where id = $1"#).bind(database_id).fetch_one(db).await.into_diagnostic()?; let row = query(r#"SELECT id as "id: i32", title, file_path as "path", html from presentations where id = $1"#).bind(database_id).fetch_one(db).await.into_diagnostic()?;
Presentation::from_row(&row).into_diagnostic() Presentation::from_row(&row).into_diagnostic()
} }
@ -546,19 +535,17 @@ mod test {
starting_index: 0, starting_index: 0,
ending_index: 67, ending_index: 67,
}, },
created_at: Local::now(),
accessed_at: Local::now(),
} }
} }
#[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::Generic)
} }
async fn add_db() -> Result<SqlitePool> { async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db"); let mut db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic() SqlitePool::connect(&db_url).await.into_diagnostic()
} }
@ -567,15 +554,16 @@ mod test {
let mut presentation_model: Model<Presentation> = Model { let mut presentation_model: Model<Presentation> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Presentation, kind: LibraryKind::Presentation,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let db = Arc::new(add_db().await.expect("Getting db error")); let mut db = add_db().await.unwrap().acquire().await.unwrap();
presentation_model.load_from_db(db).await; presentation_model.load_from_db(&mut db).await;
if let Some(presentation) = presentation_model.find(|p| p.id == 4) { if let Some(presentation) =
presentation_model.find(|p| p.id == 4)
{
let test_presentation = test_presentation(); let test_presentation = test_presentation();
assert_eq!(&test_presentation, presentation); assert_eq!(&test_presentation, presentation);
} else { } else {
panic!(); assert!(false);
} }
} }
} }

View file

@ -45,7 +45,9 @@ impl Ord for ServiceItem {
impl TryFrom<(Vec<u8>, String)> for ServiceItem { impl TryFrom<(Vec<u8>, String)> for ServiceItem {
type Error = miette::Error; type Error = miette::Error;
fn try_from(value: (Vec<u8>, String)) -> std::result::Result<Self, Self::Error> { fn try_from(
value: (Vec<u8>, String),
) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value; let (data, mime) = value;
debug!(?mime); debug!(?mime);
ron::de::from_bytes(&data).into_diagnostic() ron::de::from_bytes(&data).into_diagnostic()
@ -68,7 +70,10 @@ impl AsMimeTypes for ServiceItem {
Cow::from(vec!["application/service-item".to_string()]) Cow::from(vec!["application/service-item".to_string()])
} }
fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> { fn as_bytes(
&self,
mime_type: &str,
) -> 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 ron = ron::ser::to_string(self).ok()?;
@ -84,10 +89,18 @@ impl TryFrom<PathBuf> for ServiceItem {
let ext = path let ext = path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.ok_or_else(|| miette::miette!("There isn't an extension on this file"))?; .ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext { match ext {
"png" | "jpg" | "jpeg" => Ok(Self::from(&Image::from(path))), "png" | "jpg" | "jpeg" => {
"mp4" | "mkv" | "webm" => Ok(Self::from(&Video::from(path))), Ok(Self::from(&Image::from(path)))
}
"mp4" | "mkv" | "webm" => {
Ok(Self::from(&Video::from(path)))
}
_ => Err(miette!("Unkown service item")), _ => Err(miette!("Unkown service item")),
} }
} }
@ -99,7 +112,9 @@ impl From<&ServiceItem> for Value {
ServiceItemKind::Song(song) => Self::from(song), ServiceItemKind::Song(song) => Self::from(song),
ServiceItemKind::Video(video) => Self::from(video), ServiceItemKind::Video(video) => Self::from(video),
ServiceItemKind::Image(image) => Self::from(image), ServiceItemKind::Image(image) => Self::from(image),
ServiceItemKind::Presentation(presentation) => Self::from(presentation), ServiceItemKind::Presentation(presentation) => {
Self::from(presentation)
}
ServiceItemKind::Content(slide) => Self::from(slide), ServiceItemKind::Content(slide) => Self::from(slide),
} }
} }
@ -115,8 +130,12 @@ impl ServiceItem {
ServiceItemKind::Song(song) => song.to_slides(), ServiceItemKind::Song(song) => song.to_slides(),
ServiceItemKind::Video(video) => video.to_slides(), ServiceItemKind::Video(video) => video.to_slides(),
ServiceItemKind::Image(image) => image.to_slides(), ServiceItemKind::Image(image) => image.to_slides(),
ServiceItemKind::Presentation(presentation) => presentation.to_slides(), ServiceItemKind::Presentation(presentation) => {
ServiceItemKind::Content(slide) => Ok(vec![slide.clone()]), presentation.to_slides()
}
ServiceItemKind::Content(slide) => {
Ok(vec![slide.clone()])
}
} }
} }
} }
@ -158,44 +177,70 @@ 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) = list.iter().position(|v| match v { if let Some(_content) =
Value::List(list) list.iter().position(|v| match v {
if list.iter().next() Value::List(list)
== Some(&Value::Symbol(Symbol("text".into()))) => if list.iter().next()
{ == Some(&Value::Symbol(
list.iter().next().is_some() Symbol("text".into()),
} )) =>
_ => false, {
}) { list.iter().next().is_some()
}
_ => false,
})
{
let slide = Slide::from(value); let slide = Slide::from(value);
let title = slide.text(); let title = slide.text();
Self { Self {
id: 0, id: 0,
title, title,
database_id: 0, database_id: 0,
kind: ServiceItemKind::Content(slide.clone()), kind: ServiceItemKind::Content(
slide.clone(),
),
slides: vec![slide], slides: vec![slide],
} }
} else if let Some(background) = list.get(background_pos) { } else if let Some(background) =
list.get(background_pos)
{
if let Value::List(item) = background { if let Value::List(item) = background {
match &item[0] { match &item[0] {
Value::Symbol(Symbol(s)) if s == "image" => { Value::Symbol(Symbol(s))
Self::from(&Image::from(background)) if s == "image" =>
{
Self::from(&Image::from(
background,
))
} }
Value::Symbol(Symbol(s)) if s == "video" => { Value::Symbol(Symbol(s))
Self::from(&Video::from(background)) if s == "video" =>
{
Self::from(&Video::from(
background,
))
} }
Value::Symbol(Symbol(s)) if s == "presentation" => { Value::Symbol(Symbol(s))
Self::from(&Presentation::from(background)) if s == "presentation" =>
{
Self::from(&Presentation::from(
background,
))
} }
_ => todo!(), _ => todo!(),
} }
} else { } else {
error!("There is no background here: {:?}", background); error!(
"There is no background here: {:?}",
background
);
Self::default() Self::default()
} }
} else { } else {
error!("There is no background here: {:?}", background_pos); error!(
"There is no background here: {:?}",
background_pos
);
Self::default() Self::default()
} }
} }
@ -301,7 +346,9 @@ impl From<&Presentation> for ServiceItem {
fn from(presentation: &Presentation) -> Self { fn from(presentation: &Presentation) -> Self {
match presentation.to_slides() { match presentation.to_slides() {
Ok(slides) => Self { Ok(slides) => Self {
kind: ServiceItemKind::Presentation(presentation.clone()), kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id, database_id: presentation.id,
title: presentation.title.clone(), title: presentation.title.clone(),
slides, slides,
@ -310,7 +357,9 @@ impl From<&Presentation> for ServiceItem {
Err(e) => { Err(e) => {
error!(?e); error!(?e);
Self { Self {
kind: ServiceItemKind::Presentation(presentation.clone()), kind: ServiceItemKind::Presentation(
presentation.clone(),
),
database_id: presentation.id, database_id: presentation.id,
title: presentation.title.clone(), title: presentation.title.clone(),
..Default::default() ..Default::default()
@ -361,7 +410,10 @@ impl Clone for Box<dyn ServiceTrait> {
} }
impl std::fmt::Debug for Box<dyn ServiceTrait> { impl std::fmt::Debug for Box<dyn ServiceTrait> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> Result<(), std::fmt::Error> {
write!(f, "{}: {}", self.id(), self.title()) write!(f, "{}: {}", self.id(), self.title())
} }
} }
@ -374,7 +426,6 @@ mod test {
use super::*; use super::*;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use sqlx::types::chrono::Local;
fn test_song() -> Song { fn test_song() -> Song {
Song { Song {
@ -391,8 +442,6 @@ mod test {
"~/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html", "~/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html",
), ),
kind: PresKind::Html, kind: PresKind::Html,
created_at: Local::now(),
accessed_at: Local::now(),
} }
} }
@ -404,8 +453,14 @@ mod test {
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); service_model.add_item(&song);
assert_eq!(ServiceItemKind::Song(song), service_model.items[0].kind); assert_eq!(
assert_eq!(ServiceItemKind::Presentation(pres), pres_item.kind); ServiceItemKind::Song(song),
service_model.items[0].kind
);
assert_eq!(
ServiceItemKind::Presentation(pres),
pres_item.kind
);
assert_eq!(service_item, service_model.items[0]); assert_eq!(service_item, service_model.items[0]);
} }
} }

View file

@ -1,17 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use cosmic::cosmic_config::cosmic_config_derive::CosmicConfigEntry; use cosmic::{
use cosmic::cosmic_config::{self, CosmicConfigEntry}; cosmic_config::{
use cosmic::theme; self, CosmicConfigEntry,
cosmic_config_derive::CosmicConfigEntry,
},
theme,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::VecDeque; use std::{collections::VecDeque, path::PathBuf};
use std::path::PathBuf;
use crate::core::model::Sort;
pub const SETTINGS_VERSION: u64 = 1; pub const SETTINGS_VERSION: u64 = 1;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(
Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
)]
pub enum AppTheme { pub enum AppTheme {
Dark, Dark,
Light, Light,
@ -28,16 +31,19 @@ impl AppTheme {
} }
} }
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(
Clone,
CosmicConfigEntry,
Debug,
Deserialize,
Eq,
PartialEq,
Serialize,
)]
#[serde(default)] #[serde(default)]
pub struct Settings { pub struct Settings {
pub app_theme: AppTheme, pub app_theme: AppTheme,
pub obs_url: Option<url::Url>, pub obs_url: Option<url::Url>,
pub genius_token: Option<String>,
pub song_sort: Option<Sort>,
pub image_sort: Option<Sort>,
pub video_sort: Option<Sort>,
pub presentation_sort: Option<Sort>,
} }
impl Default for Settings { impl Default for Settings {
@ -45,17 +51,19 @@ impl Default for Settings {
Self { Self {
app_theme: AppTheme::System, app_theme: AppTheme::System,
obs_url: None, obs_url: None,
genius_token: None,
song_sort: None,
image_sort: None,
video_sort: None,
presentation_sort: None,
} }
} }
} }
#[derive( #[derive(
Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize, Default, Clone,
CosmicConfigEntry,
Debug,
Deserialize,
Eq,
PartialEq,
Serialize,
Default,
)] )]
pub struct PersistentState { pub struct PersistentState {
pub recent_files: VecDeque<PathBuf>, pub recent_files: VecDeque<PathBuf>,

208
src/core/slide.rs Executable file → Normal file
View file

@ -1,28 +1,26 @@
#![allow(clippy::similar_names, unused)] #![allow(clippy::similar_names, unused)]
use cosmic::iced::Size;
use cosmic::iced::core::image::Allocation;
use cosmic::widget::image::Handle; use cosmic::widget::image::Handle;
// use cosmic::dialog::ashpd::url::Url; // 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 image::EncodableLayout;
use miette::{Result, miette}; use miette::{Result, miette};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Display; use std::{
use std::path::{Path, PathBuf}; fmt::Display,
path::{Path, PathBuf},
};
use tracing::error; use tracing::error;
use crate::ui::gst_video;
use crate::ui::text_svg::{Color, Font, Shadow, Stroke, TextSvg}; use crate::ui::text_svg::{Color, Font, Shadow, Stroke, TextSvg};
use super::songs::Song; use super::songs::Song;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Slide { pub struct Slide {
id: i32, id: i32,
pub(crate) background: Background, pub(crate) background: Background,
#[serde(skip)]
pub(crate) thumbnail: Option<Allocation>,
text: String, text: String,
font: Option<Font>, font: Option<Font>,
font_size: i32, font_size: i32,
@ -40,7 +38,9 @@ pub struct Slide {
pdf_page: Option<Handle>, pdf_page: Option<Handle>,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum BackgroundKind { pub enum BackgroundKind {
#[default] #[default]
Image, Image,
@ -49,7 +49,17 @@ pub enum BackgroundKind {
Html, Html,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Hash)] #[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
Hash,
)]
pub enum TextAlignment { pub enum TextAlignment {
TopLeft, TopLeft,
TopCenter, TopCenter,
@ -79,20 +89,20 @@ impl From<&Value> for TextAlignment {
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub struct Background { pub struct Background {
pub path: PathBuf, pub path: PathBuf,
pub kind: BackgroundKind, pub kind: BackgroundKind,
#[serde(skip)]
pub image_handle: Option<Handle>,
#[serde(skip)]
pub image_allocation: Option<Allocation>,
} }
impl TryFrom<&Background> for Video { impl TryFrom<&Background> for Video {
type Error = ParseError; type Error = ParseError;
fn try_from(value: &Background) -> std::result::Result<Self, Self::Error> { fn try_from(
value: &Background,
) -> std::result::Result<Self, Self::Error> {
Self::new( Self::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)?,
@ -104,17 +114,14 @@ impl TryFrom<&Background> for Video {
impl TryFrom<Background> for Video { impl TryFrom<Background> for Video {
type Error = ParseError; type Error = ParseError;
fn try_from(value: Background) -> std::result::Result<Self, Self::Error> { fn try_from(
let url = &url::Url::from_file_path(value.path) value: Background,
.map_err(|()| ParseError::BackgroundNotVideo)?; ) -> std::result::Result<Self, Self::Error> {
Self::new(
let settings = gst_video::VideoSettings { &url::Url::from_file_path(value.path)
mute: true, .map_err(|()| ParseError::BackgroundNotVideo)?,
framerate: 30, )
appsink_name: "lumina_video".to_string(), .map_err(|_| ParseError::BackgroundNotVideo)
};
gst_video::create_video(url, &settings)
.map_err(|_| ParseError::BackgroundNotVideo)
} }
} }
@ -129,7 +136,10 @@ 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.to_str().expect("Should have a string").to_string(); let path = path
.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") .expect("We should have a home directory")
@ -151,28 +161,20 @@ impl TryFrom<PathBuf> for Background {
.unwrap_or_default(); .unwrap_or_default();
match extension { match extension {
"jpeg" | "jpg" | "png" | "webp" => Ok(Self { "jpeg" | "jpg" | "png" | "webp" => Ok(Self {
path: value.clone(), path: value,
kind: BackgroundKind::Image, kind: BackgroundKind::Image,
image_handle: Some(value.into()),
image_allocation: None,
}), }),
"mp4" | "mkv" | "webm" => Ok(Self { "mp4" | "mkv" | "webm" => Ok(Self {
path: value, path: value,
kind: BackgroundKind::Video, kind: BackgroundKind::Video,
image_handle: None,
image_allocation: None,
}), }),
"pdf" => Ok(Self { "pdf" => Ok(Self {
path: value, path: value,
kind: BackgroundKind::Pdf, kind: BackgroundKind::Pdf,
image_handle: None,
image_allocation: None,
}), }),
"html" => Ok(Self { "html" => Ok(Self {
path: value, path: value,
kind: BackgroundKind::Html, kind: BackgroundKind::Html,
image_handle: None,
image_allocation: None,
}), }),
_ => Err(ParseError::NonBackgroundFile), _ => Err(ParseError::NonBackgroundFile),
} }
@ -228,12 +230,21 @@ pub enum ParseError {
impl std::error::Error for ParseError {} impl std::error::Error for ParseError {}
impl Display for ParseError { impl Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
let message = match self { let message = match self {
Self::NonBackgroundFile => "The file is not a recognized image or video type", Self::NonBackgroundFile => {
"The file is not a recognized image or video type"
}
Self::DoesNotExist => "This file doesn't exist", Self::DoesNotExist => "This file doesn't exist",
Self::CannotCanonicalize => "Could not canonicalize this file", Self::CannotCanonicalize => {
Self::BackgroundNotVideo => "This background isn't a video", "Could not canonicalize this file"
}
Self::BackgroundNotVideo => {
"This background isn't a video"
}
}; };
write!(f, "Error: {message}") write!(f, "Error: {message}")
} }
@ -380,7 +391,9 @@ impl Slide {
.background(song.background.unwrap_or_default()) .background(song.background.unwrap_or_default())
.font(song.font.unwrap_or_default()) .font(song.font.unwrap_or_default())
.font_size(song.font_size.unwrap_or_default()) .font_size(song.font_size.unwrap_or_default())
.text_alignment(song.text_alignment.unwrap_or_default()) .text_alignment(
song.text_alignment.unwrap_or_default(),
)
.audio(song.audio.unwrap_or_default()) .audio(song.audio.unwrap_or_default())
.video_loop(true) .video_loop(true)
.video_start_time(0.0) .video_start_time(0.0)
@ -424,10 +437,10 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
const DEFAULT_TEXT_LOCATION: usize = 0; const DEFAULT_TEXT_LOCATION: usize = 0;
let mut slide = SlideBuilder::new(); let mut slide = SlideBuilder::new();
let background_position = if let Some(background) = lisp let background_position = if let Some(background) =
.iter() lisp.iter().position(|v| {
.position(|v| v == &Value::Keyword(Keyword::from("background"))) v == &Value::Keyword(Keyword::from("background"))
{ }) {
background + 1 background + 1
} else { } else {
DEFAULT_BACKGROUND_LOCATION DEFAULT_BACKGROUND_LOCATION
@ -441,7 +454,8 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
let text_position = lisp.iter().position(|v| match v { let text_position = lisp.iter().position(|v| match v {
Value::List(vec) => { Value::List(vec) => {
vec[DEFAULT_TEXT_LOCATION] == Value::Symbol(Symbol::from("text")) vec[DEFAULT_TEXT_LOCATION]
== Value::Symbol(Symbol::from("text"))
} }
_ => false, _ => false,
}); });
@ -486,11 +500,14 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
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) => {
if let Some(font_size_position) = list if let Some(font_size_position) =
.iter() list.iter().position(|v| {
.position(|v| v == &Value::Keyword(Keyword::from("font-size"))) v == &Value::Keyword(Keyword::from("font-size"))
})
{ {
if let Some(font_size_value) = list.get(font_size_position + 1) { if let Some(font_size_value) =
list.get(font_size_position + 1)
{
font_size_value.into() font_size_value.into()
} else { } else {
50 50
@ -517,10 +534,9 @@ 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 if let Some(source) = list.iter().position(|v| {
.iter() v == &Value::Keyword(Keyword::from("source"))
.position(|v| v == &Value::Keyword(Keyword::from("source"))) }) {
{
let source = &list[source + 1]; let source = &list[source + 1];
match source { match source {
Value::String(s) => { Value::String(s) => {
@ -538,7 +554,9 @@ pub fn lisp_to_background(lisp: &Value) -> Background {
match Background::try_from(s.as_str()) { match Background::try_from(s.as_str()) {
Ok(background) => background, Ok(background) => background,
Err(e) => { Err(e) => {
error!("Couldn't load background: {e}"); error!(
"Couldn't load background: {e}"
);
Background::default() Background::default()
} }
} }
@ -546,7 +564,9 @@ pub fn lisp_to_background(lisp: &Value) -> Background {
match Background::try_from(s.as_str()) { match Background::try_from(s.as_str()) {
Ok(background) => background, Ok(background) => background,
Err(e) => { Err(e) => {
error!("Couldn't load background: {e}"); error!(
"Couldn't load background: {e}"
);
Background::default() Background::default()
} }
} }
@ -562,7 +582,9 @@ pub fn lisp_to_background(lisp: &Value) -> Background {
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct SlideBuilder { pub struct SlideBuilder {
background: Option<Background>, background: Option<Background>,
text: Option<String>, text: Option<String>,
@ -597,7 +619,10 @@ impl SlideBuilder {
Ok(self) Ok(self)
} }
pub(crate) fn background(mut self, background: Background) -> Self { pub(crate) fn background(
mut self,
background: Background,
) -> Self {
let _ = self.background.insert(background); let _ = self.background.insert(background);
self self
} }
@ -607,7 +632,10 @@ impl SlideBuilder {
self self
} }
pub(crate) fn text_color(mut self, text_color: impl Into<Color>) -> Self { pub(crate) fn text_color(
mut self,
text_color: impl Into<Color>,
) -> Self {
let _ = self.text_color.insert(text_color.into()); let _ = self.text_color.insert(text_color.into());
self self
} }
@ -632,17 +660,26 @@ impl SlideBuilder {
self self
} }
pub(crate) fn stroke(mut self, stroke: impl Into<Stroke>) -> Self { pub(crate) fn stroke(
mut self,
stroke: impl Into<Stroke>,
) -> Self {
let _ = self.stroke.insert(stroke.into()); let _ = self.stroke.insert(stroke.into());
self self
} }
pub(crate) fn shadow(mut self, shadow: impl Into<Shadow>) -> Self { pub(crate) fn shadow(
mut self,
shadow: impl Into<Shadow>,
) -> Self {
let _ = self.shadow.insert(shadow.into()); let _ = self.shadow.insert(shadow.into());
self self
} }
pub(crate) fn text_alignment(mut self, text_alignment: TextAlignment) -> Self { pub(crate) fn text_alignment(
mut self,
text_alignment: TextAlignment,
) -> Self {
let _ = self.text_alignment.insert(text_alignment); let _ = self.text_alignment.insert(text_alignment);
self self
} }
@ -652,17 +689,26 @@ impl SlideBuilder {
self self
} }
pub(crate) fn video_start_time(mut self, video_start_time: f32) -> Self { pub(crate) fn video_start_time(
mut self,
video_start_time: f32,
) -> Self {
let _ = self.video_start_time.insert(video_start_time); let _ = self.video_start_time.insert(video_start_time);
self self
} }
pub(crate) fn video_end_time(mut self, video_end_time: f32) -> Self { pub(crate) fn video_end_time(
mut self,
video_end_time: f32,
) -> Self {
let _ = self.video_end_time.insert(video_end_time); let _ = self.video_end_time.insert(video_end_time);
self self
} }
pub(crate) fn text_svg(mut self, text_svg: impl Into<TextSvg>) -> Self { pub(crate) fn text_svg(
mut self,
text_svg: impl Into<TextSvg>,
) -> Self {
let _ = self.text_svg.insert(text_svg.into()); let _ = self.text_svg.insert(text_svg.into());
self self
} }
@ -672,7 +718,10 @@ impl SlideBuilder {
self self
} }
pub(crate) fn pdf_index(mut self, pdf_index: impl Into<u32>) -> Self { pub(crate) fn pdf_index(
mut self,
pdf_index: impl Into<u32>,
) -> Self {
let _ = self.pdf_index.insert(pdf_index.into()); let _ = self.pdf_index.insert(pdf_index.into());
self self
} }
@ -730,7 +779,8 @@ mod test {
fn test_slide() -> Slide { fn test_slide() -> Slide {
Slide { Slide {
text: "This is frodo".to_string(), text: "This is frodo".to_string(),
background: Background::try_from("~/pics/frodo.jpg").expect(""), background: Background::try_from("~/pics/frodo.jpg")
.unwrap(),
font: Some("Quicksand".to_string().into()), font: Some("Quicksand".to_string().into()),
font_size: 140, font_size: 140,
..Default::default() ..Default::default()
@ -739,8 +789,11 @@ mod test {
fn test_second_slide() -> Slide { fn test_second_slide() -> Slide {
Slide { Slide {
text: String::new(), text: "".to_string(),
background: Background::try_from("~/vids/test/camprules2024.mp4").expect(""), background: Background::try_from(
"~/vids/test/camprules2024.mp4",
)
.unwrap(),
font: Some("Quicksand".to_string().into()), font: Some("Quicksand".to_string().into()),
..Default::default() ..Default::default()
} }
@ -748,10 +801,15 @@ mod test {
#[test] #[test]
fn test_ron_deserialize() { fn test_ron_deserialize() {
let slide = let slide = read_to_string("./test_presentation.ron")
read_to_string("./test_presentation.ron").expect("Problem getting file read"); .expect("Problem getting file read");
if let Err(e) = ron::from_str::<Vec<Slide>>(&slide) { match ron::from_str::<Vec<Slide>>(&slide) {
panic!("{e:?}") Ok(_s) => {
assert!(true)
}
Err(e) => {
assert!(false, "{:?}", e)
}
} }
} }
} }

View file

@ -2,8 +2,7 @@ use miette::{IntoDiagnostic, Result};
use std::sync::Arc; use std::sync::Arc;
use tracing::warn; use tracing::warn;
use obws::Client; use obws::{Client, responses::scenes::Scene};
use obws::responses::scenes::Scene;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -1,146 +1,37 @@
use crate::core::songs::{Song, VerseName};
use itertools::Itertools; use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use nom::branch::alt;
use nom::bytes::complete::{tag, take_till, take_till1, take_until};
use nom::character::complete::{digit0, space0};
use nom::combinator::rest;
use nom::multi::many1;
use nom::sequence::{delimited, pair};
use nom::{IResult, Parser};
use reqwest::header; use reqwest::header;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use std::fmt::Display;
use tracing::error;
#[derive( #[derive(
Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone,
Debug,
Default,
PartialEq,
PartialOrd,
Ord,
Eq,
Serialize,
Deserialize,
)] )]
pub struct OnlineSong { pub struct OnlineSong {
pub lyrics: String, pub lyrics: String,
pub title: String, pub title: String,
pub author: String, pub author: String,
pub provider: Provider, pub site: String,
pub link: String, pub link: String,
} }
#[derive( pub async fn search_genius_links(
Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, query: impl AsRef<str> + std::fmt::Display,
)] ) -> Result<Vec<OnlineSong>> {
pub enum Provider { let auth_token = env!("GENIUS_TOKEN");
Genius {
parsable: bool,
},
#[default]
LyricsCom,
}
impl Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Genius { .. } => f.write_str("Genius"),
Self::LyricsCom => f.write_str("Lyrics.com"),
}
}
}
impl From<OnlineSong> for Song {
fn from(online_song: OnlineSong) -> Self {
let verse_map = if online_song.provider == (Provider::Genius { parsable: true }) {
parse_genius_lyrics(&online_song.lyrics.replace("\\n", "\n")).ok()
} else {
let mut map = HashMap::new();
map.entry(VerseName::Verse { number: 1 })
.or_insert(online_song.lyrics);
Some(map)
};
let lyrics = ron::ser::to_string(&verse_map).ok();
let verse_order: Option<Vec<String>> = verse_map
.as_ref()
.map(|map| map.keys().map(VerseName::get_name).collect());
let verses: Option<Vec<VerseName>> = verse_map
.as_ref()
.map(|map| map.keys().map(ToOwned::to_owned).collect());
Self {
title: online_song.title,
author: Some(online_song.author),
verse_map,
lyrics,
verse_order,
verses,
..Default::default()
}
}
}
#[allow(clippy::redundant_closure_for_method_calls)]
fn parse_genius_lyrics(lyrics: &str) -> Result<HashMap<VerseName, String>> {
let (input, chunks) = many1(pair(parse_verse_name, alt((take_until("["), rest))))
.parse(lyrics)
.map_err(|e| e.to_owned())
.into_diagnostic()?;
dbg!(input);
dbg!(&chunks);
let mut map = HashMap::new();
for (mut name, lyric) in chunks {
while map.contains_key(&name) {
name = name.next();
}
map.entry(name).or_insert_with(|| lyric.trim().to_string());
}
Ok(map)
}
fn parse_verse_name(line: &str) -> IResult<&str, VerseName> {
let (input, (name, _, num, _, _)) = delimited(
(tag("["), space0),
(
take_till1(|c| c == ' ' || c == ']' || c == ':'),
space0,
digit0,
alt((tag(":"), space0)),
take_till(|c| c == ']'),
),
(space0, tag("]")),
)
.parse(line)?;
let num = num.parse::<usize>().unwrap_or(1);
dbg!(&name);
let verse_name = match name {
"Chorus" => VerseName::Chorus { number: num },
"Verse" => VerseName::Verse { number: num },
"Bridge" => VerseName::Bridge { number: num },
"Pre-Chorus" => VerseName::PreChorus { number: num },
"Post-Chorus" => VerseName::PostChorus { number: num },
"Outro" => VerseName::Outro { number: num },
"Intro" => VerseName::Intro { number: num },
"Instrumental" => VerseName::Instrumental { number: num },
_ => VerseName::Verse { number: 99 },
};
Ok((input, verse_name))
}
pub async fn search_genius(query: String, auth_token: String) -> Result<Vec<OnlineSong>> {
// let Some(auth_token) = option_env!("GENIUS_TOKEN") else {
// return Err(miette!("No Genius Token"));
// };
let head_value = header::HeaderValue::from_str(&auth_token).into_diagnostic()?;
let mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, head_value); headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_static(auth_token),
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.default_headers(headers) .default_headers(headers)
.build() .build()
@ -155,7 +46,8 @@ pub async fn search_genius(query: String, auth_token: String) -> Result<Vec<Onli
.text() .text()
.await .await
.into_diagnostic()?; .into_diagnostic()?;
let json: Value = serde_json::from_str(&response).into_diagnostic()?; let json: Value =
serde_json::from_str(&response).into_diagnostic()?;
let hits = json let hits = json
.get("response") .get("response")
.expect("respose") .expect("respose")
@ -163,11 +55,12 @@ pub async fn search_genius(query: String, auth_token: String) -> Result<Vec<Onli
.expect("hits") .expect("hits")
.as_array() .as_array()
.expect("array"); .expect("array");
let songs: Vec<Option<OnlineSong>> = Ok(hits
cosmic::iced::futures::future::join_all(hits.iter().map(|hit| async { .iter()
.map(|hit| {
let result = hit.get("result").expect("result"); let result = hit.get("result").expect("result");
let title = result let title = result
.get("title") .get("full_title")
.expect("title") .expect("title")
.as_str() .as_str()
.expect("title") .expect("title")
@ -185,27 +78,20 @@ pub async fn search_genius(query: String, auth_token: String) -> Result<Vec<Onli
.as_str() .as_str()
.expect("url") .expect("url")
.to_string(); .to_string();
let song = OnlineSong { OnlineSong {
lyrics: String::new(), lyrics: String::new(),
title, title,
author, author,
provider: Provider::Genius { parsable: false }, site: String::from("https://genius.com"),
link, link,
};
match get_genius_lyrics(song).await {
Ok(song) => Some(song),
Err(e) => {
error!("Couldn't get lyrics: {e}");
None
}
} }
})) })
.await; .collect())
Ok(songs.into_iter().flatten().collect())
} }
pub async fn get_genius_lyrics(mut song: OnlineSong) -> Result<OnlineSong> { pub async fn get_genius_lyrics(
mut song: OnlineSong,
) -> Result<OnlineSong> {
let html = reqwest::get(&song.link) let html = reqwest::get(&song.link)
.await .await
.into_diagnostic()? .into_diagnostic()?
@ -215,64 +101,31 @@ pub async fn get_genius_lyrics(mut song: OnlineSong) -> Result<OnlineSong> {
.await .await
.into_diagnostic()?; .into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(lyrics_root_selector) = let Ok(lyrics_root_selector) = scraper::Selector::parse(
scraper::Selector::parse(r#"div[data-lyrics-container="true"]"#) r#"div[data-lyrics-container="true"]"#,
else { ) else {
return Err(miette!("error in finding lyrics_root")); return Err(miette!("error in finding lyrics_root"));
}; };
let lyrics = document let lyrics = document
.select(&lyrics_root_selector) .select(&lyrics_root_selector)
.filter(|element| element.attr("data-exclude-from-selection").is_none()) .map(|root| {
.filter(|element| {
!element.value().classes().any(|class| {
class.contains("Contrib")
|| class.contains("LyricsHeader")
|| class.contains("StyledLink")
})
})
.flat_map(|element| {
// dbg!(&root); // dbg!(&root);
// debug!(?element); root.inner_html()
let inner = element.inner_html().replace("<br>", "\n");
// debug!(inner);
let line_broken = scraper::Html::parse_fragment(&inner);
line_broken
.root_element()
.descendent_elements()
.filter(|element| element.attr("data-exclude-from-selection").is_none())
.filter(|element| {
let element_name = element.value().name();
element_name != "div" && element_name != "path"
})
.filter(|element| {
!element.value().classes().any(|class| {
class.contains("Contrib")
|| class.contains("LyricsHeader")
|| class.contains("StyledLink")
})
})
.flat_map(|t| {
// let html = t.html();
// debug!(html);
t.text().collect::<Vec<&str>>()
})
.map(ToString::to_string)
.collect::<Vec<String>>()
}) })
.collect::<String>(); .collect::<String>();
let lyrics = lyrics.find('[').map_or_else( let lyrics = lyrics.find('[').map_or_else(
|| { || {
lyrics.find("</div></div></div>").map_or_else( lyrics.find("</div></div></div>").map_or_else(
|| lyrics.clone(), || lyrics.clone(),
|position| lyrics.split_at(position + 18).1.to_string(), |position| {
lyrics.split_at(position + 18).1.to_string()
},
) )
}, },
|position| lyrics.split_at(position).1.to_string(), |position| lyrics.split_at(position).1.to_string(),
); );
song.provider = Provider::Genius { let lyrics = lyrics.replace("<br>", "\n");
parsable: lyrics.contains('['),
};
song.lyrics = lyrics; song.lyrics = lyrics;
Ok(song) Ok(song)
} }
@ -280,17 +133,20 @@ pub async fn get_genius_lyrics(mut song: OnlineSong) -> Result<OnlineSong> {
pub async fn search_lyrics_com_links( pub async fn search_lyrics_com_links(
query: impl AsRef<str> + std::fmt::Display, query: impl AsRef<str> + std::fmt::Display,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let html = reqwest::get(format!("http://www.lyrics.com/lyrics/{query}")) let html =
.await reqwest::get(format!("http://www.lyrics.com/lyrics/{query}"))
.into_diagnostic()? .await
.error_for_status() .into_diagnostic()?
.into_diagnostic()? .error_for_status()
.text() .into_diagnostic()?
.await .text()
.into_diagnostic()?; .await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(best_matches_selector) = scraper::Selector::parse(".best-matches") else { let Ok(best_matches_selector) =
scraper::Selector::parse(".best-matches")
else {
return Err(miette!("error in finding matches")); return Err(miette!("error in finding matches"));
}; };
let Ok(lyric_selector) = scraper::Selector::parse("a") else { let Ok(lyric_selector) = scraper::Selector::parse("a") else {
@ -300,7 +156,9 @@ pub async fn search_lyrics_com_links(
Ok(document Ok(document
.select(&best_matches_selector) .select(&best_matches_selector)
.flat_map(|best_section| best_section.select(&lyric_selector)) .flat_map(|best_section| best_section.select(&lyric_selector))
.map(|a| a.value().attr("href").unwrap_or("").trim().to_string()) .map(|a| {
a.value().attr("href").unwrap_or("").trim().to_string()
})
.filter(|a| a.contains("/lyric/")) .filter(|a| a.contains("/lyric/"))
.dedup() .dedup()
.map(|link| { .map(|link| {
@ -340,7 +198,9 @@ pub async fn lyrics_com_link_to_song(
.into_diagnostic()?; .into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(lyric_selector) = scraper::Selector::parse(".lyric-body") else { let Ok(lyric_selector) =
scraper::Selector::parse(".lyric-body")
else {
return Err(miette!("error in finding lyric-body",)); return Err(miette!("error in finding lyric-body",));
}; };
@ -355,7 +215,7 @@ pub async fn lyrics_com_link_to_song(
lyrics, lyrics,
title: title.clone(), title: title.clone(),
author: author.clone(), author: author.clone(),
provider: Provider::LyricsCom, site: "https://www.lyrics.com".into(),
link, link,
}; };
@ -376,57 +236,35 @@ mod test {
async fn genius() -> Result<(), String> { async fn genius() -> Result<(), String> {
let song = OnlineSong { let song = OnlineSong {
lyrics: String::new(), lyrics: String::new(),
title: "Death Was Arrested".to_string(), title: "Death Was Arrested by North Point Worship (Ft. Seth Condrey)".to_string(),
author: "North Point Worship (Ft. Seth Condrey)".to_string(), author: "North Point Worship (Ft. Seth Condrey)".to_string(),
provider: Provider::Genius { parsable: false }, site: "https://genius.com".to_string(),
link: "https://genius.com/North-point-worship-death-was-arrested-lyrics" link: "https://genius.com/North-point-worship-death-was-arrested-lyrics".to_string(),
.to_string(),
}; };
let hits = search_genius( let hits = search_genius_links("Death was arrested")
"Death was arrested".to_string(), .await
env!("GENIUS_TOKEN").to_string(), .map_err(|e| e.to_string())?;
)
.await
.map_err(|e| e.to_string())?;
assert!( assert!(
hits[0].title == song.title, hits.iter().find(|hit| **hit == song).is_some(),
"There was no song that matched on Genius" "There was no song that matched on Genius"
); );
let titles: Vec<String> = hits.iter().map(|song| song.title.clone()).collect(); let titles: Vec<String> =
hits.iter().map(|song| song.title.clone()).collect();
dbg!(titles); dbg!(titles);
for hit in hits { for hit in hits {
let new_song = get_genius_lyrics(hit).await.map_err(|e| e.to_string())?; let new_song = get_genius_lyrics(hit)
.await
.map_err(|e| e.to_string())?;
dbg!(&new_song); dbg!(&new_song);
dbg!(&new_song.provider); if !new_song.lyrics.starts_with("[Verse 1]") {
if new_song.lyrics.starts_with("[Verse 1]") { assert!(new_song.lyrics.len() > 10);
} else {
assert!(new_song.lyrics.contains("[Verse 2]")); assert!(new_song.lyrics.contains("[Verse 2]"));
if !new_song.lyrics.contains("[Chorus]") { if !new_song.lyrics.contains("[Chorus]") {
assert!(new_song.lyrics.contains("[Chorus 1]")); assert!(new_song.lyrics.contains("[Chorus 1]"))
} }
} else {
assert!(new_song.lyrics.len() > 10);
}
let mapped_song = Song::from(new_song);
dbg!(&mapped_song);
if let Some(map) = mapped_song.verse_map.as_ref() {
assert!(!map.is_empty());
// Need to leave commented until I work on more robust tests.
assert!(
map.keys().contains(&VerseName::Verse { number: 1 }) // && map.keys().contains(&VerseName::Verse {
// number: 2
// })
// && map.keys().contains(&VerseName::Chorus {
// number: 1
// })
);
} else {
assert!(
!mapped_song
.lyrics
.is_some_and(|lyrics| lyrics.contains('['))
);
} }
} }
@ -439,7 +277,7 @@ mod test {
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(), 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(), title: "Death Was Arrested".to_string(),
author: "North Point InsideOut".to_string(), author: "North Point InsideOut".to_string(),
provider: Provider::LyricsCom, site: "https://www.lyrics.com".to_string(),
link: "https://www.lyrics.com/lyric/35090938/North+Point+InsideOut/Death+Was+Arrested".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") let links = search_lyrics_com_links("Death was arrested")
@ -448,131 +286,47 @@ mod test {
let songs = lyrics_com_link_to_song(links) let songs = lyrics_com_link_to_song(links)
.await .await
.map_err(|e| format!("{e}"))?; .map_err(|e| format!("{e}"))?;
if let Some(first) = songs if let Some(first) = songs.iter().find_or_first(|song| {
.iter() song.author == "North Point InsideOut"
.find_or_first(|song| song.author == "North Point InsideOut") }) {
{
assert_eq!(&song, first); assert_eq!(&song, first);
// online_song_to_song(song)?; online_song_to_song(song)?
} }
Ok(()) Ok(())
} }
#[allow(dead_code)]
fn online_song_to_song(song: OnlineSong) -> Result<(), String> { fn online_song_to_song(song: OnlineSong) -> Result<(), String> {
let song = Song::from(song); let song = Song::from(song);
if let Some(verse_map) = song.verse_map.as_ref() { if let Some(verse_map) = song.verse_map.as_ref() {
if verse_map.is_empty() { if verse_map.len() < 2 {
return Err(format!("VerseMap wasn't built right likely: {song:?}",)); return Err(format!(
"VerseMap wasn't built right likely: {:?}",
song
));
} }
} else { } else {
return Err(String::from("There is no VerseMap in this song")); return Err(String::from(
} "There is no VerseMap in this song",
));
};
Ok(()) Ok(())
} }
// #[tokio::test] #[tokio::test]
// async fn online_search() { async fn online_search() {
// let search = let search =
// search_lyrics_com_links("Death was arrested").await; search_lyrics_com_links("Death was arrested").await;
// match search { match search {
// Ok(songs) => { Ok(songs) => {
// assert_eq!( assert_eq!(
// songs, songs,
// vec![ vec![
// "33755723/Various+Artists/Death+Was+Arrested", "33755723/Various+Artists/Death+Was+Arrested",
// "35090938/North+Point+InsideOut/Death+Was+Arrested" "35090938/North+Point+InsideOut/Death+Was+Arrested"
// ] ]
// ); );
// } }
// Err(e) => panic!("{e}"), Err(e) => assert!(false, "{}", e),
// }
// }
#[test]
#[allow(clippy::redundant_closure_for_method_calls)]
fn test_parse_verse_name() -> Result<()> {
let names = [
"[ Chorus ]",
"[Verse 1]",
"[Pre-Chorus]",
"[ Post-Chorus ]",
"[ Post-Chorus 3]",
"[Verse 2]",
"[Verse 3]",
"[Verse 4:]",
"[Verse 5: Coffee]",
"[Chorus 1]",
"[ Chorus 2 ]",
];
for name in names {
let (_input, parsed) = parse_verse_name
.parse(name)
.map_err(|e| e.to_owned())
.into_diagnostic()?;
dbg!(parsed);
} }
Ok(())
}
#[test]
fn test_parse_song() -> Result<()> {
let song = r#"[Verse 1]
Glory, glory
I've been singing
Since I laid my burden down
Glory, glory
I've been singing
Since I laid my burden down
[Chorus]
I'm singing, "Hallelujah"
God is able, hallelujah
God is faithful, hallelujah
Lord, I'm gonna sing
[Verse 2]
I feel better
So much better
Since I laid my burden down
Yeah, I feel better
So much better
Since I laid, O Lord, I laid my burden down
[Chorus]
I'm singing, "Hallelujah"
God is able, hallelujah
God is faithful, hallelujah
Lord, I'm gonna sing
I'm singing, "Hallelujah"
God is able, hallelujah
God is faithful, hallelujah
Lord, I'm gonna sing
[Bridge]
As long as I'm alive there's gonna be praising
As long as I'm alive there's gonna be shouting
One thing that I know, oh, deep down in my soul
As long as I'm alive, I'm gonna sing
[Chorus]
I'm singing, "Hallelujah" (Hallelujah)
God is able, hallelujah (Hallelujah)
God is faithful, hallelujah
Lord, I'm gonna sing (Come on now, sing it)
Oh I'm singing, "Hallelujah" (Hallelujah)
God is able, hallelujah (Hallelujah)
God is faithful, hallelujah (God is so good)
Lord, I'm gonna sing (Sing it, Dave)
[Outro]
I'm gonna sing
Aw man, that was good"#;
let new_song = r"[Verse 1]\nAlone 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\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\n[Chorus]\nOh, Your grace so free, washes over me\nYou have made me new, now life begins with You\nIt's Your endless love, pouring down on us\nYou have made us new, now life begins with You\n\n[Verse 2]\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\n[Chorus]\nOh, Your grace so free, washes over me\nYou have made me new, now life begins with You\nIt's Your endless love, pouring down on us\nYou have made us new, now life begins with You\n[Verse 3]\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\n[Chorus]\nOh, Your grace so free, washes over me\nYou have made me new, now life begins with You\nIt's Your endless love, pouring down on us\nYou have made us new, now life begins with You\n\n[Outro]\nOh, we're free, free, forever we're free\nCome join the song of all the redeemed\nYes, we're free, free, forever amen\nWhen death was arrested and my life began\nOh, we're free, free, forever we're free\nCome join the song of all the redeemed\nYes, we're free, free, forever amen\nWhen death was arrested and my life began\nWhen death was arrested and my life began\nWhen death was arrested and my life began".replace("\\n", "\n");
let map = parse_genius_lyrics(song)?;
let new_map = parse_genius_lyrics(&new_song)?;
dbg!(map);
dbg!(new_map);
Ok(())
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,16 @@
use dirs; use dirs;
use std::error::Error; use std::error::Error;
use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::{fs, str}; use std::str;
use tracing::debug; use tracing::debug;
pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Error>> { pub fn bg_from_video(
video: &Path,
screenshot: &Path,
) -> Result<(), Box<dyn Error>> {
if screenshot.exists() { if screenshot.exists() {
debug!("Screenshot already exists"); debug!("Screenshot already exists");
} else { } else {
@ -36,8 +40,10 @@ pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Erro
} }
} }
let hours: i32 = hours.parse().unwrap_or_default(); let hours: i32 = hours.parse().unwrap_or_default();
let mut minutes: i32 = minutes.parse().unwrap_or_default(); let mut minutes: i32 =
let mut seconds: i32 = seconds.parse().unwrap_or_default(); minutes.parse().unwrap_or_default();
let mut seconds: i32 =
seconds.parse().unwrap_or_default();
minutes += hours * 60; minutes += hours * 60;
seconds += minutes * 60; seconds += minutes * 60;
at_second = seconds / 5; at_second = seconds / 5;
@ -65,15 +71,18 @@ pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Erro
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 = dirs::cache_dir().expect("Can't find cache dir"); let mut data_dir =
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); let _ = fs::create_dir_all(&data_dir);
if !data_dir.exists() { if !data_dir.exists() {
fs::create_dir(&data_dir).expect("Could not create thumbnails dir"); fs::create_dir(&data_dir)
.expect("Could not create thumbnails dir");
} }
let mut screenshot = data_dir.clone(); let mut screenshot = data_dir.clone();
screenshot.push(video.file_name().expect("Should have file name")); screenshot
.push(video.file_name().expect("Should have file name"));
screenshot.set_extension("png"); screenshot.set_extension("png");
screenshot screenshot
} }
@ -88,9 +97,11 @@ mod test {
let screenshot = bg_path_from_video(video); let screenshot = bg_path_from_video(video);
match bg_from_video(video, &screenshot) { match bg_from_video(video, &screenshot) {
Ok(_o) => assert!(screenshot.exists()), Ok(_o) => assert!(screenshot.exists()),
Err(e) => { Err(e) => debug_assert!(
debug_assert!(false, "There was an error in the runtime future. {e}",); false,
} "There was an error in the runtime future. {:?}",
e
),
} }
} }
} }

View file

@ -1,23 +1,25 @@
use crate::core::model::{Sort, SortDirection};
use crate::{Background, SlideBuilder, TextAlignment}; use crate::{Background, SlideBuilder, TextAlignment};
use super::content::Content; use super::{
use super::kinds::ServiceItemKind; content::Content,
use super::model::{LibraryKind, Model}; kinds::ServiceItemKind,
use super::service_items::ServiceTrait; model::{LibraryKind, Model},
use super::slide::Slide; service_items::ServiceTrait,
slide::Slide,
};
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use itertools::Itertools; use miette::{IntoDiagnostic, Result};
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::chrono::{DateTime, Local}; use sqlx::{
use sqlx::{AssertSqlSafe, Decode, SqliteConnection, SqlitePool, query, query_as}; Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection,
use std::mem::replace; query, query_as,
};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use tracing::{debug, error};
use tracing::error;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Decode)] #[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Video { pub struct Video {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
@ -25,10 +27,6 @@ pub struct Video {
pub start_time: Option<f32>, pub start_time: Option<f32>,
pub end_time: Option<f32>, pub end_time: Option<f32>,
pub looping: bool, pub looping: bool,
#[serde(skip)]
pub accessed_at: DateTime<Local>,
#[serde(skip)]
pub created_at: DateTime<Local>,
} }
impl From<&Video> for Value { impl From<&Video> for Value {
@ -99,21 +97,30 @@ impl From<&Value> for Video {
Value::List(list) => { Value::List(list) => {
let path = list let path = list
.iter() .iter()
.position(|v| v == &Value::Keyword(Keyword::from("source"))) .position(|v| {
v == &Value::Keyword(Keyword::from("source"))
})
.and_then(|path_pos| { .and_then(|path_pos| {
let pos = path_pos + 1; let pos = path_pos + 1;
list.get(pos).map(|p| PathBuf::from(String::from(p))) list.get(pos)
.map(|p| PathBuf::from(String::from(p)))
}); });
let title = path.clone().map(|p| { let title = path.clone().map(|p| {
let path = p.to_str().unwrap_or_default().to_string(); let path =
let title = path.rsplit_once('/').unwrap_or_default().1; p.to_str().unwrap_or_default().to_string();
let title =
path.rsplit_once('/').unwrap_or_default().1;
title.to_string() title.to_string()
}); });
let start_time = list let start_time = list
.iter() .iter()
.position(|v| v == &Value::Keyword(Keyword::from("start-time"))) .position(|v| {
v == &Value::Keyword(Keyword::from(
"start-time",
))
})
.and_then(|start_pos| { .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)
@ -121,7 +128,11 @@ impl From<&Value> for Video {
let end_time = list let end_time = list
.iter() .iter()
.position(|v| v == &Value::Keyword(Keyword::from("end-time"))) .position(|v| {
v == &Value::Keyword(Keyword::from(
"end-time",
))
})
.and_then(|end_pos| { .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)
@ -129,10 +140,14 @@ impl From<&Value> for Video {
let looping = list let looping = list
.iter() .iter()
.position(|v| v == &Value::Keyword(Keyword::from("loop"))) .position(|v| {
v == &Value::Keyword(Keyword::from("loop"))
})
.is_some_and(|loop_pos| { .is_some_and(|loop_pos| {
let pos = loop_pos + 1; let pos = loop_pos + 1;
list.get(pos).is_some_and(|l| String::from(l) == *"true") list.get(pos).is_some_and(|l| {
String::from(l) == *"true"
})
}); });
Self { Self {
@ -160,7 +175,10 @@ impl ServiceTrait for Video {
fn to_slides(&self) -> Result<Vec<Slide>> { fn to_slides(&self) -> Result<Vec<Slide>> {
let slide = SlideBuilder::new() let slide = SlideBuilder::new()
.background(Background::try_from(self.path.clone()).into_diagnostic()?) .background(
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("") .text("")
.audio("") .audio("")
.font("") .font("")
@ -180,18 +198,19 @@ impl ServiceTrait for Video {
} }
impl Model<Video> { impl Model<Video> {
pub async fn new_video_model(db: Arc<SqlitePool>) -> Self { pub async fn new_video_model(db: &mut SqlitePool) -> Self {
let mut model = Self { let mut model = Self {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
model.load_from_db(db).await; let mut db = db.acquire().await.expect("probs");
model.load_from_db(&mut db).await;
model model
} }
pub async fn load_from_db(&mut self, db: Arc<SqlitePool>) { pub async fn load_from_db(&mut self, db: &mut SqliteConnection) {
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", accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" 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 {
@ -199,129 +218,61 @@ impl Model<Video> {
} }
} }
Err(e) => { Err(e) => {
error!("There was an error in converting videos: {e}"); error!(
"There was an error in converting videos: {e}"
);
} }
} }
} }
pub fn sort(&mut self) {
match self.sorting_method {
Sort::AccessTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.accessed_at.cmp(&a.accessed_at))
}
Sort::AccessTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.accessed_at.cmp(&b.accessed_at))
}
Sort::Title(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.title.cmp(&a.title))
}
Sort::Title(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.title.cmp(&b.title))
}
Sort::CreatedTime(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.created_at.cmp(&a.created_at))
}
Sort::CreatedTime(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.created_at.cmp(&b.created_at))
}
Sort::Secondary(SortDirection::Descending) => {
self.items.sort_by(|a, b| b.path.cmp(&a.path))
}
Sort::Secondary(SortDirection::Ascending) => {
self.items.sort_by(|a, b| a.path.cmp(&b.path))
}
}
}
pub fn set_sort(mut self, method: Sort) -> Self {
self.sorting_method = method;
self.sort();
self
}
} }
pub async fn remove_videos( pub async fn remove_from_db(
db: Arc<SqlitePool>, db: PoolConnection<Sqlite>,
videos: Vec<Video>,
ids: Vec<i32>,
) -> Result<Vec<Video>> {
let videos = videos
.into_iter()
.filter(|current_video| !ids.contains(&current_video.id))
.collect();
let delete = format!(
"DELETE FROM videos WHERE id IN ({:})",
ids.iter().map(ToString::to_string).join(", ")
);
query(AssertSqlSafe(delete))
.execute(&*db)
.await
.into_diagnostic()
.map(|_| videos)
}
pub async fn remove_video(
db: Arc<SqlitePool>,
mut videos: Vec<Video>,
id: i32, id: i32,
) -> Result<Vec<Video>> { ) -> Result<()> {
query!("DELETE FROM videos WHERE id = $1", id) query!("DELETE FROM videos WHERE id = $1", id)
.execute(&*db) .execute(&mut db.detach())
.await .await
.into_diagnostic() .into_diagnostic()
.map(|_| ())?; .map(|_| ())
let index = videos
.iter()
.position(|current_video| current_video.id == id)
.ok_or_else(|| miette!("Could not find video in model"))?;
videos.remove(index);
Ok(videos)
} }
pub async fn add_video( pub async fn add_video_to_db(
new_videos: Vec<Video>,
mut current_videos: Vec<Video>,
db: Arc<SqlitePool>,
) -> Result<Vec<Video>> {
for video in new_videos {
let path = video
.path
.to_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
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(&*db)
.await
.into_diagnostic()?;
current_videos.push(video);
}
Ok(current_videos)
}
pub async fn update_video(
video: Video, video: Video,
mut videos: Vec<Video>, db: PoolConnection<Sqlite>,
db: Arc<SqlitePool>, ) -> Result<()> {
) -> Result<Vec<Video>> {
let path = video let path = video
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(std::string::ToString::to_string)
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach();
query!( 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(
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();
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,
@ -330,25 +281,26 @@ pub async fn update_video(
video.end_time, video.end_time,
video.looping, video.looping,
) )
.execute(&*db) .execute(&mut db)
.await.into_diagnostic()?; .await.into_diagnostic();
let current_video = videos match result {
.iter() Ok(_) => {
.position(|current_video| current_video.id == video.id) debug!("should have been updated");
.ok_or_else(|| miette!("Could not find video in model")) Ok(())
.map(|index| { }
videos Err(e) => {
.get_mut(index) error! {?e};
.expect("We should have this video already") Err(e)
})?; }
}
let _ = replace(current_video, video);
Ok(videos)
} }
pub async fn get_from_db(database_id: i32, db: &mut SqliteConnection) -> Result<Video> { pub async fn get_video_from_db(
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", accessed_at as "accessed_at!: DateTime<Local>", created_at as "created_at!: DateTime<Local>" from videos where id = ?"#, database_id).fetch_one(db).await.into_diagnostic() database_id: i32,
db: &mut SqliteConnection,
) -> Result<Video> {
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 where id = ?"#, database_id).fetch_one(db).await.into_diagnostic()
} }
#[cfg(test)] #[cfg(test)]
@ -360,7 +312,7 @@ mod test {
Video { Video {
title, title,
path: PathBuf::from( path: PathBuf::from(
"/home/chris/nc/tfc/Documents/lessons/videos/christ-nutshell.mp4", "/home/chris/docs/notes/lessons/christ-our-hope.mp4",
), ),
..Default::default() ..Default::default()
} }
@ -371,15 +323,14 @@ mod test {
let mut video_model: Model<Video> = Model { let mut video_model: Model<Video> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let db = Arc::new(add_db().await.expect("")); let mut db = add_db().await.unwrap().acquire().await.unwrap();
video_model.load_from_db(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 == 2) {
let test_video = test_video("christ-our-hope.mp4".into()); let test_video = test_video("christ-our-hope.mp4".into());
assert_eq!(test_video.title, video.title); assert_eq!(test_video.title, video.title);
} else { } else {
panic!(); assert!(false);
} }
} }
@ -389,18 +340,25 @@ mod test {
let mut video_model: Model<Video> = Model { let mut video_model: Model<Video> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let result = video_model.add_item(video.clone()); let result = video_model.add_item(video.clone());
let new_video = test_video("A newer video".into()); let new_video = test_video("A newer video".into());
match result { match result {
Ok(()) => { Ok(_) => {
assert_eq!(&video, video_model.find(|v| v.id == 0).expect("")); assert_eq!(
assert_ne!(&new_video, video_model.find(|v| v.id == 0).expect("")); &video,
} video_model.find(|v| v.id == 0).unwrap()
Err(e) => { );
panic!("There was an error adding the video: {e}",) assert_ne!(
&new_video,
video_model.find(|v| v.id == 0).unwrap()
);
} }
Err(e) => assert!(
false,
"There was an error adding the video: {:?}",
e
),
} }
} }

View file

@ -1,25 +0,0 @@
use std::path::PathBuf;
use youtube_dl::YoutubeDl;
pub async fn download_video(
url: impl Into<String>,
mut output_directory: PathBuf,
) -> Result<PathBuf, youtube_dl::Error> {
YoutubeDl::new(url)
.output_directory(output_directory.to_string_lossy())
.output_template("%(title).%(ext)s")
.run_async()
.await
.map(|output| {
if let Some(video) = output.into_single_video() {
let video_path = format!(
"{}.{}",
video.title.expect("Should be a title"),
video.ext.expect("Should be an extension")
);
output_directory.push(video_path);
};
output_directory
})
}

2249
src/main.rs Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,172 +0,0 @@
use std::fmt::Display;
use std::num::NonZero;
use std::path::{Path, PathBuf};
use std::time::Duration;
use cosmic::widget::image::Handle;
use iced_video_player::gst_app::prelude::*;
use iced_video_player::gst_app::{self};
use iced_video_player::{Position, Video, gst};
use image::{DynamicImage, ImageFormat, RgbaImage};
use tracing::debug;
use url::Url;
#[derive(Debug)]
pub struct VideoSettings {
pub mute: bool,
pub framerate: u16,
pub appsink_name: String,
}
impl Default for VideoSettings {
fn default() -> Self {
Self {
mute: true,
framerate: 60,
appsink_name: String::from("lumina_video"),
}
}
}
type Result<T> = std::result::Result<T, VideoError>;
pub fn create_video(url: &Url, settings: &VideoSettings) -> Result<Video> {
// Based on `iced_video_player::Video::new`,
// but without a text sink so that the built-in subtitle functionality triggers.
// and with some better gstreamer tweaks
gst::init().map_err(VideoError::GlibError)?;
let pipeline = format!(
r#"playbin uri="{0}" video-sink="videoscale ! videoconvert ! videoflip method=automatic ! videorate ! appsink name={1} drop=true caps=video/x-raw,format=NV12,framerate={2}/1,pixel-aspect-ratio=1/1{3}""#,
url.as_str(),
settings.appsink_name,
settings.framerate,
if settings.mute { ",mute=true" } else { "" },
);
let pipeline =
gst::parse::launch(pipeline.as_ref()).map_err(VideoError::GlibError)?;
let pipeline = pipeline
.downcast::<gst::Pipeline>()
.map_err(|_| VideoError::IcedVideoError(iced_video_player::Error::Cast))?;
let video_sink: gst::Element = pipeline.property("video-sink");
let pad = video_sink.pads().first().cloned().expect("first pad");
let pad = pad
.dynamic_cast::<gst::GhostPad>()
.map_err(|_| VideoError::IcedVideoError(iced_video_player::Error::Cast))?;
let bin = pad
.parent_element()
.ok_or_else(|| {
VideoError::IcedVideoError(iced_video_player::Error::AppSink(String::from(
"Should have a parent element here",
)))
})?
.downcast::<gst::Bin>()
.map_err(|_| VideoError::IcedVideoError(iced_video_player::Error::Cast))?;
let video_sink = bin.by_name(&settings.appsink_name).ok_or_else(|| {
VideoError::IcedVideoError(iced_video_player::Error::AppSink(format!(
"Can't find element {}",
settings.appsink_name
)))
})?;
let video_sink = video_sink
.downcast::<gst_app::AppSink>()
.map_err(|_| VideoError::IcedVideoError(iced_video_player::Error::Cast))?;
Video::from_gst_pipeline(pipeline, video_sink, None)
.map_err(VideoError::IcedVideoError)
}
pub fn thumbnail(input: &Url, output: &mut PathBuf) -> Result<Handle> {
output.set_extension("png");
if output.exists() {
let image = image::open(&output).map_err(VideoError::ThumbnailImageError)?;
let (width, height, pixels) =
(image.width(), image.height(), image.to_rgba8().to_vec());
return Ok(Handle::from_rgba(width, height, pixels));
}
debug!(?output);
let thumbnails = {
let mut video = create_video(input, &VideoSettings::default())?;
let duration = video.duration();
//TODO: how best to decide time?
let position = if duration.as_secs_f64() < 20.0 {
// If less than 20 seconds, divide duration by 2
Position::Time(duration / 2)
} else {
// If more than 20 seconds, thumbnail at 10 seconds
Position::Time(Duration::new(10, 0))
};
video
.thumbnails([position], NonZero::new(1).expect("Not zero"))
.map_err(VideoError::IcedVideoError)?
};
// TODO: do not require clone of pixels data
if let Some(cosmic::widget::image::Handle::Rgba {
id: _,
width,
height,
pixels,
}) = &thumbnails.first()
{
let image = RgbaImage::from_raw(*width, *height, pixels.to_vec())
.map(DynamicImage::ImageRgba8)
.ok_or_else(|| {
VideoError::ThumbnailError(String::from("Cannot convert handle to image"))
})?;
if !output.exists() {
output.set_extension("png");
debug!(?output);
}
image
.save_with_format(output, ImageFormat::Png)
.map_err(VideoError::ThumbnailImageError)?;
} else {
return Err(VideoError::ThumbnailError(String::from(
"Unsupported handle format",
)));
}
thumbnails
.first()
.cloned()
.ok_or_else(|| VideoError::ThumbnailError(String::from("Error creating handles")))
}
#[derive(Debug)]
pub enum VideoError {
ThumbnailError(String),
IcedVideoError(iced_video_player::Error),
GlibError(gst::glib::Error),
ThumbnailImageError(image::ImageError),
IOError(std::io::Error),
}
impl std::error::Error for VideoError {}
impl Display for VideoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ThumbnailError(message) => {
write!(f, "ThumbnailError: {message}")
}
Self::IcedVideoError(error) => {
write!(f, "IcedVideoError: {error}")
}
Self::GlibError(error) => {
write!(f, "GlibError: {error}")
}
Self::ThumbnailImageError(error) => {
write!(f, "ImageError: {error}")
}
Self::IOError(error) => {
write!(f, "IOError: {error}")
}
}
}
}

View file

@ -1,15 +1,17 @@
use std::io; use std::{io, path::PathBuf};
use std::path::PathBuf;
use crate::core::images::Image; use crate::core::images::Image;
use cosmic::dialog::file_chooser::FileFilter; use cosmic::{
use cosmic::dialog::file_chooser::open::Dialog; Apply, Element, Task,
use cosmic::iced::Length; dialog::file_chooser::{FileFilter, open::Dialog},
use cosmic::iced::alignment::Vertical; iced::{Length, alignment::Vertical},
use cosmic::iced::widget::{column, row}; iced_widget::{column, row},
use cosmic::widget::space::horizontal; theme,
use cosmic::widget::{self, Space, button, container, icon, text, text_input}; widget::{
use cosmic::{Apply, Element, Task, theme}; self, Space, button, container, icon, space::horizontal,
text, text_input,
},
};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
#[derive(Debug)] #[derive(Debug)]
@ -67,14 +69,21 @@ impl ImageEditor {
return Action::UpdateImage(image); return Action::UpdateImage(image);
} }
Message::PickImage => { Message::PickImage => {
let image_id = self.image.as_ref().map(|v| v.id).unwrap_or_default(); let image_id = self
let task = Task::perform(pick_image(), move |image_result| { .image
image_result.map_or(Message::None, |image| { .as_ref()
let mut image = Image::from(image); .map(|v| v.id)
image.id = image_id; .unwrap_or_default();
Message::Update(image) 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); return Action::Task(task);
} }
Message::None => (), Message::None => (),
@ -88,21 +97,25 @@ impl ImageEditor {
|| Space::new().apply(container), || Space::new().apply(container),
|pic| widget::image(pic.path.clone()).apply(container), |pic| widget::image(pic.path.clone()).apply(container),
); );
let column = column![self.toolbar(), container.center_x(Length::FillPortion(2))] let column = column![
.spacing(theme::active().cosmic().space_l()); self.toolbar(),
container.center_x(Length::FillPortion(2))
]
.spacing(theme::active().cosmic().space_l());
column.into() column.into()
} }
fn toolbar(&self) -> Element<Message> { fn toolbar(&self) -> Element<Message> {
let title_box = let title_box = text_input("Title...", &self.title)
text_input("Title...", &self.title).on_input(Message::ChangeTitle); .on_input(Message::ChangeTitle);
let image_selector = let image_selector = button::icon(
button::icon(icon::from_name("folder-images-symbolic").scale(2)) icon::from_name("folder-images-symbolic").scale(2),
.label("Image") )
.tooltip("Select a image") .label("Image")
.on_press(Message::PickImage) .tooltip("Select a image")
.padding(10); .on_press(Message::PickImage)
.padding(10);
row![ row![
text::body("Title:"), text::body("Title:"),
@ -150,7 +163,9 @@ async fn pick_image() -> Result<PathBuf, ImageError> {
error!(?e); error!(?e);
ImageError::DialogClosed ImageError::DialogClosed
}) })
.map(|file| file.url().to_file_path().expect("Should be a file here")) .map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new() // rfd::AsyncFileDialog::new()
// .set_title("Choose a background...") // .set_title("Choose a background...")
// .add_filter( // .add_filter(

View file

@ -1,61 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::PathBuf;
use cosmic::widget::image::Handle;
use tokio::task::JoinError;
type Result<T> = std::result::Result<T, Error>;
pub async fn load_images(path: PathBuf) -> Result<Handle> {
tokio::task::spawn_blocking(move || {
let image = image::open(&path).map_err(Error::ImageError)?;
let (width, height, pixels) =
(image.width(), image.height(), image.to_rgba8().to_vec());
Ok(Handle::from_rgba(width, height, pixels))
})
.await
.map_err(Error::AsyncError)
.flatten()
}
#[derive(Debug, Default)]
pub struct ImageLoader {
decoded_images: HashMap<PathBuf, Handle>,
decoding_images: HashSet<PathBuf>,
}
impl ImageLoader {
pub fn load_image(&mut self, path: &PathBuf) -> Result<Handle> {
if self.decoded_images.contains_key(path) {
self.decoding_images.remove(path);
self.decoded_images
.get(path)
.ok_or(Error::MissingImage)
.cloned()
} else {
self.decoding_images.insert(path.clone());
let image = image::open(path).map_err(Error::ImageError)?;
let (width, height, pixels) =
(image.width(), image.height(), image.into_bytes());
self.decoding_images.remove(path);
Ok(Handle::from_rgba(width, height, pixels))
}
}
pub fn get_image(&self, path: &PathBuf) -> Result<Handle> {
self.decoded_images
.get(path)
.ok_or(Error::MissingImage)
.cloned()
}
}
#[derive(Debug)]
pub enum Error {
NonImage,
AsyncError(JoinError),
LoadingError(io::Error),
ImageError(image::ImageError),
MissingImage,
}

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,10 @@ pub mod library;
pub mod presentation_editor; pub mod presentation_editor;
pub mod presenter; pub mod presenter;
// pub mod service; // pub mod service;
pub mod gst_video;
pub mod image_loader;
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_editor; pub mod video_editor;
pub mod widgets; pub mod widgets;

View file

@ -1,22 +1,25 @@
use std::collections::HashMap; use std::{
use std::io; collections::HashMap,
use std::ops::RangeBounds; io,
use std::path::{Path, PathBuf}; ops::RangeBounds,
path::{Path, PathBuf},
};
use crate::core::presentations::{PresKind, Presentation}; use crate::core::presentations::{PresKind, Presentation};
use crate::ui::widgets::loaded_image::loaded_image; use cosmic::{
use cosmic::dialog::file_chooser::FileFilter; Element, Task,
use cosmic::dialog::file_chooser::open::Dialog; dialog::file_chooser::{FileFilter, open::Dialog},
use cosmic::iced::alignment::Vertical; iced::{Background, ContentFit, Length, alignment::Vertical},
use cosmic::iced::widget::{column, row}; iced_widget::{column, row},
use cosmic::iced::{Background, ContentFit, Length}; theme,
use cosmic::widget::image::Handle; widget::{
use cosmic::widget::space::{self, horizontal}; self, Space, button, container, context_menu, icon,
use cosmic::widget::{ image::Handle,
self, Space, button, container, context_menu, icon, menu, mouse_area, scrollable, menu, mouse_area, scrollable,
text, text_input, space::{self, horizontal},
text, text_input,
},
}; };
use cosmic::{Element, Task, theme};
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use mupdf::{Colorspace, Document, Matrix}; use mupdf::{Colorspace, Document, Matrix};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@ -105,7 +108,7 @@ impl PresentationEditor {
if let PresKind::Pdf { if let PresKind::Pdf {
starting_index, starting_index,
ending_index, ending_index,
} = presentation.kind } = presentation.kind.clone()
{ {
let range = starting_index..=ending_index; let range = starting_index..=ending_index;
task = Task::perform( task = Task::perform(
@ -126,7 +129,8 @@ impl PresentationEditor {
if let Some(presentation) = &self.presentation { if let Some(presentation) = &self.presentation {
let mut presentation = presentation.clone(); let mut presentation = presentation.clone();
presentation.title = title; presentation.title = title;
return self.update(Message::Update(presentation)); return self
.update(Message::Update(presentation));
} }
} }
Message::Edit(edit) => { Message::Edit(edit) => {
@ -135,20 +139,30 @@ impl PresentationEditor {
} }
Message::Update(presentation) => { Message::Update(presentation) => {
warn!(?presentation, "about to update"); warn!(?presentation, "about to update");
self.presentation = Some(presentation.clone());
return Action::UpdatePresentation(presentation); return Action::UpdatePresentation(presentation);
} }
Message::PickPresentation => { Message::PickPresentation => {
let presentation_id = let presentation_id = self
self.presentation.as_ref().map(|v| v.id).unwrap_or_default(); .presentation
let task = .as_ref()
Task::perform(pick_presentation(), move |presentation_result| { .map(|v| v.id)
presentation_result.map_or(Message::None, |presentation| { .unwrap_or_default();
let mut presentation = Presentation::from(presentation); let task = Task::perform(
presentation.id = presentation_id; pick_presentation(),
Message::ChangePresentationFile(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); return Action::Task(task);
} }
Message::ChangePresentationFile(presentation) => { Message::ChangePresentationFile(presentation) => {
@ -173,7 +187,9 @@ impl PresentationEditor {
); );
} }
task = task.chain(Task::done(Message::Update(presentation.clone()))); task = task.chain(Task::done(Message::Update(
presentation.clone(),
)));
return Action::Task(task); return Action::Task(task);
} }
} }
@ -182,10 +198,13 @@ impl PresentationEditor {
} }
Message::None => (), Message::None => (),
Message::NextPage => { Message::NextPage => {
let next_index = self.current_slide_index.unwrap_or_default() + 1; let next_index =
self.current_slide_index.unwrap_or_default() + 1;
let last_index = if let Some(presentation) = self.presentation.as_ref() let last_index = if let Some(presentation) =
&& let PresKind::Pdf { ending_index, .. } = presentation.kind self.presentation.as_ref()
&& let PresKind::Pdf { ending_index, .. } =
presentation.kind
{ {
ending_index ending_index
} else { } else {
@ -195,31 +214,42 @@ impl PresentationEditor {
if next_index > last_index { if next_index > last_index {
return Action::None; return Action::None;
} }
self.current_slide = self.document.as_ref().and_then(|doc| { self.current_slide =
let page = doc.load_page(next_index).ok()?; self.document.as_ref().and_then(|doc| {
let matrix = Matrix::IDENTITY; let page = doc.load_page(next_index).ok()?;
let colorspace = Colorspace::device_rgb(); let matrix = Matrix::IDENTITY;
let Ok(pixmap) = page let colorspace = Colorspace::device_rgb();
.to_pixmap(&matrix, &colorspace, true, true) let Ok(pixmap) = page
.into_diagnostic() .to_pixmap(
else { &matrix,
error!("Can't turn this page into pixmap"); &colorspace,
return None; true,
}; true,
debug!(?pixmap); )
Some(Handle::from_rgba( .into_diagnostic()
pixmap.width(), else {
pixmap.height(), error!(
pixmap.samples().to_vec(), "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); self.current_slide_index = Some(next_index);
} }
Message::PrevPage => { Message::PrevPage => {
let previous_index = self.current_slide_index.unwrap_or_default() - 1; let previous_index =
self.current_slide_index.unwrap_or_default() - 1;
let first_index = if let Some(presentation) = self.presentation.as_ref() let first_index = if let Some(presentation) =
&& let PresKind::Pdf { starting_index, .. } = presentation.kind self.presentation.as_ref()
&& let PresKind::Pdf { starting_index, .. } =
presentation.kind
{ {
starting_index starting_index
} else { } else {
@ -229,44 +259,52 @@ impl PresentationEditor {
if previous_index < first_index { if previous_index < first_index {
return Action::None; return Action::None;
} }
self.current_slide = self.document.as_ref().and_then(|doc| { self.current_slide =
let page = doc.load_page(previous_index).ok()?; self.document.as_ref().and_then(|doc| {
let matrix = Matrix::IDENTITY; let page =
let colorspace = Colorspace::device_rgb(); doc.load_page(previous_index).ok()?;
let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(
&matrix,
&colorspace,
true,
true,
)
.ok()?;
Some(Handle::from_rgba( Some(Handle::from_rgba(
pixmap.width(), pixmap.width(),
pixmap.height(), pixmap.height(),
pixmap.samples().to_vec(), pixmap.samples().to_vec(),
)) ))
}); });
self.current_slide_index = Some(previous_index); self.current_slide_index = Some(previous_index);
} }
Message::ChangeSlide(index) => { Message::ChangeSlide(index) => {
let starting_index = if let Some(presentation) = self.current_slide =
self.presentation.as_ref() self.document.as_ref().and_then(|doc| {
&& let PresKind::Pdf { starting_index, .. } = presentation.kind let page = doc
{ .load_page(i32::try_from(index).ok()?)
starting_index .ok()?;
} else { let matrix = Matrix::IDENTITY;
0 let colorspace = Colorspace::device_rgb();
}; let pixmap = page
.to_pixmap(
&matrix,
&colorspace,
true,
true,
)
.ok()?;
self.current_slide = self.document.as_ref().and_then(|doc| { Some(Handle::from_rgba(
let page = doc pixmap.width(),
.load_page(i32::try_from(index).ok()? + starting_index) pixmap.height(),
.ok()?; pixmap.samples().to_vec(),
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(); self.current_slide_index = i32::try_from(index).ok();
} }
Message::HoverSlide(slide) => { Message::HoverSlide(slide) => {
@ -279,14 +317,18 @@ impl PresentationEditor {
if let Ok((first, second)) = self.split_before() { if let Ok((first, second)) = self.split_before() {
debug!(?first, ?second); debug!(?first, ?second);
self.update_entire_presentation(&first); self.update_entire_presentation(&first);
return Action::SplitAddPresentation((first, second)); return Action::SplitAddPresentation((
first, second,
));
} }
} }
Message::SplitAfter => { Message::SplitAfter => {
if let Ok((first, second)) = self.split_after() { if let Ok((first, second)) = self.split_after() {
debug!(?first, ?second); debug!(?first, ?second);
self.update_entire_presentation(&first); self.update_entire_presentation(&first);
return Action::SplitAddPresentation((first, second)); return Action::SplitAddPresentation((
first, second,
));
} }
} }
} }
@ -297,60 +339,71 @@ impl PresentationEditor {
let presentation = self.current_slide.as_ref().map_or_else( let presentation = self.current_slide.as_ref().map_or_else(
|| container(Space::new()), || container(Space::new()),
|slide| { |slide| {
container(loaded_image( container(
slide, widget::image(slide)
widget::image(slide).content_fit(ContentFit::ScaleDown), .content_fit(ContentFit::ScaleDown),
)) )
.style(|_| { .style(|_| {
container::background(Background::Color(cosmic::iced::Color::WHITE)) container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
}) })
}, },
); );
let pdf_pages: Vec<Element<Message>> = self.slides.as_ref().map_or_else( let pdf_pages: Vec<Element<Message>> =
|| vec![horizontal().into()], self.slides.as_ref().map_or_else(
|pages| { || vec![horizontal().into()],
pages |pages| {
.iter() pages
.enumerate() .iter()
.map(|(index, page)| { .enumerate()
let image = loaded_image( .map(|(index, page)| {
page, let image = widget::image(page)
widget::image(page) .height(
.height(theme::spacing().space_xxxl * 3) theme::spacing().space_xxxl * 3,
.content_fit(ContentFit::ScaleDown), )
); .content_fit(ContentFit::ScaleDown);
let slide = container(image).style(|_| {
let slide = container(image).style(|_| { container::background(Background::Color(
container::background(Background::Color( cosmic::iced::Color::WHITE,
cosmic::iced::Color::WHITE, ))
)) });
}); let clickable_slide = container(
let clickable_slide = container( mouse_area(slide)
mouse_area(slide) .on_enter(Message::HoverSlide(
.on_enter(Message::HoverSlide(i32::try_from(index).ok())) i32::try_from(index).ok(),
.on_exit(Message::HoverSlide(None)) ))
.on_right_press(Message::ContextMenu(index)) .on_exit(Message::HoverSlide(
.on_press(Message::ChangeSlide(index)), None,
) ))
.padding(theme::spacing().space_m) .on_right_press(
.clip(true) Message::ContextMenu(index),
.class(self.hovered_slide.map_or( )
theme::Container::Card, .on_press(Message::ChangeSlide(
|hovered_index| { index,
if i32::try_from(index) )),
.is_ok_and(|index| index == hovered_index) )
{ .padding(theme::spacing().space_m)
theme::Container::Primary .clip(true)
} else { .class(self.hovered_slide.map_or(
theme::Container::Card theme::Container::Card,
} |hovered_index| {
}, if i32::try_from(index).is_ok_and(
)); |index| {
clickable_slide.into() index == hovered_index
}) },
.collect() ) {
}, theme::Container::Primary
); } else {
theme::Container::Card
}
},
));
clickable_slide.into()
})
.collect()
},
);
let pages_column = container( let pages_column = container(
self.context_menu( self.context_menu(
scrollable( scrollable(
@ -368,12 +421,14 @@ impl PresentationEditor {
] ]
.spacing(theme::spacing().space_xxl); .spacing(theme::spacing().space_xxl);
let control_buttons = row![ let control_buttons = row![
button::standard("Previous Page").on_press(Message::PrevPage), button::standard("Previous Page")
.on_press(Message::PrevPage),
space::horizontal(), space::horizontal(),
button::standard("Next Page").on_press(Message::NextPage), button::standard("Next Page").on_press(Message::NextPage),
]; ];
let column = column![self.toolbar(), main_row, control_buttons] let column =
.spacing(theme::active().cosmic().space_l()); column![self.toolbar(), main_row, control_buttons]
.spacing(theme::active().cosmic().space_l());
column.into() column.into()
} }
@ -386,12 +441,13 @@ impl PresentationEditor {
) )
.on_input(Message::ChangeTitle); .on_input(Message::ChangeTitle);
let presentation_selector = let presentation_selector = button::icon(
button::icon(icon::from_name("folder-presentations-symbolic").scale(2)) icon::from_name("folder-presentations-symbolic").scale(2),
.label("Change Presentation") )
.tooltip("Select a presentation") .label("Change Presentation")
.on_press(Message::PickPresentation) .tooltip("Select a presentation")
.padding(10); .on_press(Message::PickPresentation)
.padding(10);
row![ row![
text::body("Title:"), text::body("Title:"),
@ -408,13 +464,17 @@ impl PresentationEditor {
self.editing self.editing
} }
fn context_menu<'b>(&self, items: Element<'b, Message>) -> Element<'b, Message> { fn context_menu<'b>(
const SPLIT_ABOVE_ICON: &[u8] = include_bytes!("../../res/icons/split-above.svg"); &self,
const SPLIT_BELOW_ICON: &[u8] = include_bytes!("../../res/icons/split-below.svg"); items: Element<'b, Message>,
) -> Element<'b, Message> {
if self.context_menu_id.is_some() { if self.context_menu_id.is_some() {
let before_icon = icon::from_svg_bytes(SPLIT_ABOVE_ICON).symbolic(true); let before_icon =
let after_icon = icon::from_svg_bytes(SPLIT_BELOW_ICON).symbolic(true); 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![ let menu_items = vec![
menu::Item::Button( menu::Item::Button(
"Spit Before", "Spit Before",
@ -431,7 +491,9 @@ impl PresentationEditor {
items, items,
self.context_menu_id.map_or_else( self.context_menu_id.map_or_else(
|| None, || None,
|_| Some(menu::items(&HashMap::new(), menu_items)), |_| {
Some(menu::items(&HashMap::new(), menu_items))
},
), ),
); );
Element::from(context_menu) Element::from(context_menu)
@ -440,45 +502,60 @@ impl PresentationEditor {
} }
} }
fn update_entire_presentation(&mut self, presentation: &Presentation) { fn update_entire_presentation(
&mut self,
presentation: &Presentation,
) {
self.presentation = Some(presentation.clone()); self.presentation = Some(presentation.clone());
self.title.clone_from(&presentation.title); self.title.clone_from(&presentation.title);
self.document = self.document =
Document::open(presentation.path.to_str().unwrap_or_default()).ok(); Document::open(&presentation.path.as_path()).ok();
self.page_count = self.document.as_ref().and_then(|doc| doc.page_count().ok()); self.page_count = self
.document
.as_ref()
.and_then(|doc| doc.page_count().ok());
warn!("changing presentation"); warn!("changing presentation");
let pages = if let PresKind::Pdf { let pages = if let PresKind::Pdf {
starting_index, starting_index,
ending_index, ending_index,
} = presentation.kind } = presentation.kind
{ {
self.current_slide = self.document.as_ref().and_then(|doc| { self.current_slide =
let page = doc.load_page(starting_index).ok()?; self.document.as_ref().and_then(|doc| {
let matrix = Matrix::IDENTITY; let page = doc.load_page(starting_index).ok()?;
let colorspace = Colorspace::device_rgb(); let matrix = Matrix::IDENTITY;
let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba( Some(Handle::from_rgba(
pixmap.width(), pixmap.width(),
pixmap.height(), pixmap.height(),
pixmap.samples().to_vec(), pixmap.samples().to_vec(),
)) ))
}); });
self.current_slide_index = Some(starting_index); self.current_slide_index = Some(starting_index);
get_pages(starting_index..=ending_index, presentation.path.clone()) get_pages(
starting_index..=ending_index,
presentation.path.clone(),
)
} else { } else {
self.current_slide = self.document.as_ref().and_then(|doc| { self.current_slide =
let page = doc.load_page(0).ok()?; self.document.as_ref().and_then(|doc| {
let matrix = Matrix::IDENTITY; let page = doc.load_page(0).ok()?;
let colorspace = Colorspace::device_rgb(); let matrix = Matrix::IDENTITY;
let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; let colorspace = Colorspace::device_rgb();
let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba( Some(Handle::from_rgba(
pixmap.width(), pixmap.width(),
pixmap.height(), pixmap.height(),
pixmap.samples().to_vec(), pixmap.samples().to_vec(),
)) ))
}); });
self.current_slide_index = Some(0); self.current_slide_index = Some(0);
get_pages(.., presentation.path.clone()) get_pages(.., presentation.path.clone())
}; };
@ -487,8 +564,12 @@ impl PresentationEditor {
fn split_before(&self) -> Result<(Presentation, Presentation)> { fn split_before(&self) -> Result<(Presentation, Presentation)> {
if let Some(index) = self.context_menu_id { if let Some(index) = self.context_menu_id {
let Some(current_presentation) = self.presentation.as_ref() else { let Some(current_presentation) =
return Err(miette!("There is no current presentation")); self.presentation.as_ref()
else {
return Err(miette!(
"There is no current presentation"
));
}; };
let first_presentation = Presentation { let first_presentation = Presentation {
id: current_presentation.id, id: current_presentation.id,
@ -501,22 +582,23 @@ impl PresentationEditor {
}, },
_ => current_presentation.kind.clone(), _ => current_presentation.kind.clone(),
}, },
created_at: current_presentation.created_at,
accessed_at: current_presentation.accessed_at,
}; };
let second_presentation = Presentation { let second_presentation = Presentation {
id: 0, id: 0,
title: format!("{} (2)", current_presentation.title.clone()), title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(), path: current_presentation.path.clone(),
kind: match current_presentation.kind { kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => PresKind::Pdf { PresKind::Pdf { ending_index, .. } => {
starting_index: index, PresKind::Pdf {
ending_index, starting_index: index,
}, ending_index,
}
}
_ => current_presentation.kind.clone(), _ => current_presentation.kind.clone(),
}, },
created_at: current_presentation.created_at,
accessed_at: current_presentation.accessed_at,
}; };
Ok((first_presentation, second_presentation)) Ok((first_presentation, second_presentation))
} else { } else {
@ -529,8 +611,12 @@ impl PresentationEditor {
fn split_after(&self) -> Result<(Presentation, Presentation)> { fn split_after(&self) -> Result<(Presentation, Presentation)> {
if let Some(index) = self.context_menu_id { if let Some(index) = self.context_menu_id {
let Some(current_presentation) = self.presentation.as_ref() else { let Some(current_presentation) =
return Err(miette!("There is no current presentation")); self.presentation.as_ref()
else {
return Err(miette!(
"There is no current presentation"
));
}; };
let first_presentation = Presentation { let first_presentation = Presentation {
id: current_presentation.id, id: current_presentation.id,
@ -543,22 +629,23 @@ impl PresentationEditor {
}, },
_ => current_presentation.kind.clone(), _ => current_presentation.kind.clone(),
}, },
created_at: current_presentation.created_at,
accessed_at: current_presentation.accessed_at,
}; };
let second_presentation = Presentation { let second_presentation = Presentation {
id: 0, id: 0,
title: format!("{} (2)", current_presentation.title.clone()), title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(), path: current_presentation.path.clone(),
kind: match current_presentation.kind { kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => PresKind::Pdf { PresKind::Pdf { ending_index, .. } => {
starting_index: index + 1, PresKind::Pdf {
ending_index, starting_index: index + 1,
}, ending_index,
}
}
_ => current_presentation.kind.clone(), _ => current_presentation.kind.clone(),
}, },
created_at: current_presentation.created_at,
accessed_at: current_presentation.accessed_at,
}; };
Ok((first_presentation, second_presentation)) Ok((first_presentation, second_presentation))
} else { } else {
@ -580,23 +667,23 @@ fn get_pages(
range: impl RangeBounds<i32>, range: impl RangeBounds<i32>,
presentation_path: impl AsRef<Path>, presentation_path: impl AsRef<Path>,
) -> Option<Vec<Handle>> { ) -> Option<Vec<Handle>> {
let document = let document = Document::open(presentation_path.as_ref()).ok()?;
Document::open(presentation_path.as_ref().to_str().unwrap_or_default()).ok()?;
let pages = document.pages().ok()?; let pages = document.pages().ok()?;
Some( Some(
pages pages
.enumerate() .enumerate()
.filter_map(|(index, page)| { .filter_map(|(index, page)| {
if !range.contains( if !range.contains(&i32::try_from(index).expect(
&i32::try_from(index) "looking for a pdf index that is way too large",
.expect("looking for a pdf index that is way too large"), )) {
) {
return None; return None;
} }
let page = page.ok()?; let page = page.ok()?;
let matrix = Matrix::IDENTITY; let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb(); let colorspace = Colorspace::device_rgb();
let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; let pixmap = page
.to_pixmap(&matrix, &colorspace, true, true)
.ok()?;
Some(Handle::from_rgba( Some(Handle::from_rgba(
pixmap.width(), pixmap.width(),
@ -622,7 +709,9 @@ async fn pick_presentation() -> Result<PathBuf, PresentationError> {
error!(?e); error!(?e);
PresentationError::DialogClosed PresentationError::DialogClosed
}) })
.map(|file| file.url().to_file_path().expect("Should be a file here")) .map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new() // rfd::AsyncFileDialog::new()
// .set_title("Choose a background...") // .set_title("Choose a background...")
// .add_filter( // .add_filter(

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,14 @@ use cosmic::iced::Size;
use cosmic::iced_core::widget::tree; use cosmic::iced_core::widget::tree;
use cosmic::{ use cosmic::{
Element, Element,
iced::core::{
self, Clipboard, Shell, layout, renderer, widget::Tree,
},
iced::{ iced::{
Event, Length, Point, Rectangle, Vector, Event, Length, Point, Rectangle, Vector,
clipboard::dnd::{DndEvent, SourceEvent}, clipboard::dnd::{DndEvent, SourceEvent},
event, mouse, event, mouse,
}, },
iced_core::{
self, Clipboard, Shell, layout, renderer, widget::Tree,
},
widget::Widget, widget::Widget,
}; };
use tracing::debug; use tracing::debug;

View file

@ -1,10 +1,14 @@
use std::io; use std::{io, path::PathBuf};
use std::path::PathBuf;
use cosmic::Renderer; use cosmic::{
use cosmic::iced::{Color, Font, Length, Size}; Renderer,
use cosmic::widget::canvas::{self, Program, Stroke}; iced::{Color, Font, Length, Size},
use cosmic::widget::{self, container}; widget::{
self,
canvas::{self, Program, Stroke},
container,
},
};
use tracing::debug; use tracing::debug;
#[derive(Debug, Default)] #[derive(Debug, Default)]
@ -55,7 +59,10 @@ struct EditorProgram {
} }
impl SlideEditor { impl SlideEditor {
pub fn view(&self, _font: Font) -> cosmic::Element<'_, SlideWidget> { pub fn view(
&self,
_font: Font,
) -> cosmic::Element<'_, SlideWidget> {
container( container(
widget::canvas(&self.program) widget::canvas(&self.program)
.height(Length::Fill) .height(Length::Fill)
@ -68,7 +75,9 @@ impl SlideEditor {
/// Ensure to use the `cosmic::Theme and cosmic::Renderer` here /// Ensure to use the `cosmic::Theme and cosmic::Renderer` here
/// or else it will not compile /// or else it will not compile
#[allow(clippy::extra_unused_lifetimes)] #[allow(clippy::extra_unused_lifetimes)]
impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram { impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
for EditorProgram
{
type State = (); type State = ();
fn draw( fn draw(
@ -77,7 +86,7 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
renderer: &Renderer, renderer: &Renderer,
_theme: &cosmic::Theme, _theme: &cosmic::Theme,
bounds: cosmic::iced::Rectangle, bounds: cosmic::iced::Rectangle,
_cursor: cosmic::iced::core::mouse::Cursor, _cursor: cosmic::iced_core::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());
@ -94,11 +103,15 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
frame.fill(&circle, Color::BLACK); frame.fill(&circle, Color::BLACK);
frame.stroke( frame.stroke(
&circle, &circle,
Stroke::default().with_width(5.0).with_color(Color::BLACK), Stroke::default()
.with_width(5.0)
.with_color(Color::BLACK),
); );
frame.stroke( frame.stroke(
&border, &border,
Stroke::default().with_width(5.0).with_color(Color::BLACK), Stroke::default()
.with_width(5.0)
.with_color(Color::BLACK),
); );
// Then, we produce the geometry // Then, we produce the geometry
@ -110,8 +123,8 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
_state: &mut Self::State, _state: &mut Self::State,
event: &canvas::Event, event: &canvas::Event,
bounds: cosmic::iced::Rectangle, bounds: cosmic::iced::Rectangle,
_cursor: cosmic::iced::core::mouse::Cursor, _cursor: cosmic::iced_core::mouse::Cursor,
) -> Option<cosmic::iced::widget::Action<SlideWidget>> { ) -> Option<cosmic::iced_widget::Action<SlideWidget>> {
match event { match event {
canvas::Event::Mouse(event) => match event { canvas::Event::Mouse(event) => match event {
cosmic::iced::mouse::Event::CursorEntered => { cosmic::iced::mouse::Event::CursorEntered => {
@ -120,7 +133,9 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
cosmic::iced::mouse::Event::CursorLeft => { cosmic::iced::mouse::Event::CursorLeft => {
debug!("cursor left"); debug!("cursor left");
} }
cosmic::iced::mouse::Event::CursorMoved { position } => { cosmic::iced::mouse::Event::CursorMoved {
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
@ -133,18 +148,18 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
// self.mouse_button_pressed = Some(button); // self.mouse_button_pressed = Some(button);
debug!(?button, "mouse button pressed"); debug!(?button, "mouse button pressed");
} }
cosmic::iced::mouse::Event::ButtonReleased(button) => { cosmic::iced::mouse::Event::ButtonReleased(
debug!(?button, "mouse button released"); button,
} ) => debug!(?button, "mouse button released"),
cosmic::iced::mouse::Event::WheelScrolled { delta } => { cosmic::iced::mouse::Event::WheelScrolled {
debug!(?delta, "scroll wheel"); delta,
} } => debug!(?delta, "scroll wheel"),
}, },
canvas::Event::Touch(_event) => debug!("test"), canvas::Event::Touch(_event) => debug!("test"),
canvas::Event::Keyboard(_event) => debug!("test"), canvas::Event::Keyboard(_event) => debug!("test"),
canvas::Event::Window(_event) => todo!(), canvas::Event::Window(_event) => todo!(),
canvas::Event::InputMethod(_event) => todo!(), canvas::Event::InputMethod(_event) => todo!(),
// canvas::Event::A11y(_id, _action_request) => todo!(), canvas::Event::A11y(_id, _action_request) => todo!(),
canvas::Event::Dnd(_dnd_event) => todo!(), canvas::Event::Dnd(_dnd_event) => todo!(),
canvas::Event::PlatformSpecific(_platform_specific) => { canvas::Event::PlatformSpecific(_platform_specific) => {
todo!() todo!()
@ -157,8 +172,8 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
&self, &self,
_state: &Self::State, _state: &Self::State,
_bounds: cosmic::iced::Rectangle, _bounds: cosmic::iced::Rectangle,
_cursor: cosmic::iced::core::mouse::Cursor, _cursor: cosmic::iced_core::mouse::Cursor,
) -> cosmic::iced::core::mouse::Interaction { ) -> cosmic::iced_core::mouse::Interaction {
cosmic::iced::core::mouse::Interaction::default() cosmic::iced_core::mouse::Interaction::default()
} }
} }

1901
src/ui/song_editor.rs Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,31 @@
use std::fmt::{Display, Write}; use std::{
use std::fs; fmt::{Display, Write},
use std::hash::{Hash, Hasher}; fs,
use std::path::PathBuf; hash::{Hash, Hasher},
use std::sync::Arc; path::PathBuf,
sync::Arc,
};
use cosmic::cosmic_theme::palette::rgb::Rgba; use cosmic::{
use cosmic::cosmic_theme::palette::{IntoColor, Srgb}; cosmic_theme::palette::{IntoColor, Srgb, rgb::Rgba},
use cosmic::iced::font::{Style, Weight}; iced::{
use cosmic::iced::{ContentFit, Length, Size}; ContentFit, Length, Size,
use cosmic::prelude::*; font::{Style, Weight},
use cosmic::widget::image::Handle; },
use cosmic::widget::{Image, Space}; prelude::*,
widget::{Image, Space, image::Handle},
};
use derive_more::Debug; use derive_more::Debug;
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use rapidhash::v3::rapidhash_v3; use rapidhash::v3::rapidhash_v3;
use resvg::tiny_skia::{self, Pixmap}; use resvg::{
use resvg::usvg::{Tree, fontdb}; tiny_skia::{self, Pixmap},
usvg::{Tree, fontdb},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use crate::TextAlignment; use crate::{TextAlignment, core::slide::Slide};
use crate::core::slide::Slide;
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TextSvg { pub struct TextSvg {
@ -63,7 +68,9 @@ impl Hash for TextSvg {
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct Font { pub struct Font {
name: String, name: String,
weight: Weight, weight: Weight,
@ -71,7 +78,9 @@ pub struct Font {
size: u8, size: u8,
} }
#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize,
)]
pub struct Shadow { pub struct Shadow {
pub offset_x: i16, pub offset_x: i16,
pub offset_y: i16, pub offset_y: i16,
@ -79,7 +88,9 @@ pub struct Shadow {
pub color: Color, pub color: Color,
} }
#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize,
)]
pub struct Stroke { pub struct Stroke {
size: u16, size: u16,
color: Color, color: Color,
@ -96,7 +107,9 @@ impl From<cosmic::font::Font> for Font {
fn from(value: cosmic::font::Font) -> Self { fn from(value: cosmic::font::Font) -> Self {
Self { Self {
name: match value.family { name: match value.family {
cosmic::iced::font::Family::Name(name) => name.to_string(), cosmic::iced::font::Family::Name(name) => {
name.to_string()
}
_ => "Quicksand Bold".into(), _ => "Quicksand Bold".into(),
}, },
size: 20, size: 20,
@ -222,7 +235,10 @@ impl Default for Color {
} }
impl Display for Color { impl Display for Color {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(f, "{}", self.to_css_hex_string()) write!(f, "{}", self.to_css_hex_string())
} }
} }
@ -275,15 +291,23 @@ impl TextSvg {
} }
#[must_use] #[must_use]
pub const fn alignment(mut self, alignment: TextAlignment) -> Self { pub const fn alignment(
mut self,
alignment: TextAlignment,
) -> Self {
self.alignment = alignment; self.alignment = alignment;
self self
} }
#[must_use] #[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn build(mut self, size: Size, mut cache: Option<PathBuf>) -> Self { pub fn build(
mut self,
size: Size,
mut cache: Option<PathBuf>,
) -> Self {
// debug!("starting..."); // debug!("starting...");
let mut final_svg = String::with_capacity(1024); let mut final_svg = String::with_capacity(1024);
@ -298,47 +322,55 @@ impl TextSvg {
let center_y = (size.width / 2.0).to_string(); let center_y = (size.width / 2.0).to_string();
let x_width_padded = (size.width - 10.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 { let (text_anchor, starting_y_position, text_x_position) =
TextAlignment::TopLeft => ("start", font_size, "10"), match self.alignment {
TextAlignment::TopCenter => ("middle", font_size, center_y.as_str()), TextAlignment::TopLeft => ("start", font_size, "10"),
TextAlignment::TopRight => ("end", font_size, x_width_padded.as_str()), TextAlignment::TopCenter => {
TextAlignment::MiddleLeft => { ("middle", font_size, center_y.as_str())
let middle_position = size.height / 2.0; }
let position = half_lines TextAlignment::TopRight => {
.mul_add(-text_and_line_spacing, middle_position) ("end", font_size, x_width_padded.as_str())
+ text_and_line_spacing / 2.0; }
("start", position, "10") TextAlignment::MiddleLeft => {
} let middle_position = size.height / 2.0;
TextAlignment::MiddleCenter => { let position = half_lines.mul_add(
let middle_position = size.height / 2.0; -text_and_line_spacing,
let position = half_lines middle_position,
.mul_add(-text_and_line_spacing, middle_position) );
+ text_and_line_spacing / 2.0; ("start", position, "10")
("middle", position, center_y.as_str()) }
} TextAlignment::MiddleCenter => {
TextAlignment::MiddleRight => { let middle_position = size.height / 2.0;
let middle_position = size.height / 2.0; let position = half_lines.mul_add(
let position = half_lines -text_and_line_spacing,
.mul_add(-text_and_line_spacing, middle_position) middle_position,
+ text_and_line_spacing / 2.0; );
("end", position, x_width_padded.as_str()) ("middle", position, center_y.as_str())
} }
TextAlignment::BottomLeft => { TextAlignment::MiddleRight => {
let position = let middle_position = size.height / 2.0;
(total_lines as f32).mul_add(-text_and_line_spacing, size.height); let position = half_lines.mul_add(
("start", position, "10") -text_and_line_spacing,
} middle_position,
TextAlignment::BottomCenter => { );
let position = ("end", position, x_width_padded.as_str())
(total_lines as f32).mul_add(-text_and_line_spacing, size.height); }
("middle", position, center_y.as_str()) TextAlignment::BottomLeft => {
} let position = (total_lines as f32)
TextAlignment::BottomRight => { .mul_add(-text_and_line_spacing, size.height);
let position = ("start", position, "10")
(total_lines as f32).mul_add(-text_and_line_spacing, size.height); }
("end", position, x_width_padded.as_str()) 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 { let font_style = match self.font.style {
Style::Normal => "normal", Style::Normal => "normal",
@ -347,7 +379,9 @@ impl TextSvg {
}; };
let font_weight = match self.font.weight { let font_weight = match self.font.weight {
Weight::Thin | Weight::ExtraLight | Weight::Light => "lighter", Weight::Thin | Weight::ExtraLight | Weight::Light => {
"lighter"
}
Weight::Normal | Weight::Medium => "normal", Weight::Normal | Weight::Medium => "normal",
Weight::Semibold | Weight::Bold => "bold", Weight::Semibold | Weight::Bold => "bold",
Weight::ExtraBold | Weight::Black => "bolder", Weight::ExtraBold | Weight::Black => "bolder",
@ -363,7 +397,10 @@ impl TextSvg {
let _ = write!( let _ = write!(
final_svg, final_svg,
"<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>", "<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
shadow.offset_x, shadow.offset_y, shadow.spread, shadow.color shadow.offset_x,
shadow.offset_y,
shadow.spread,
shadow.color
); );
} }
final_svg.push_str("</defs>"); final_svg.push_str("</defs>");
@ -402,7 +439,10 @@ impl TextSvg {
let _ = write!( let _ = write!(
final_svg, final_svg,
"<tspan x=\"0\" y=\"{}\">{}</tspan>", "<tspan x=\"0\" y=\"{}\">{}</tspan>",
(index as f32).mul_add(text_and_line_spacing, starting_y_position), (index as f32).mul_add(
text_and_line_spacing,
starting_y_position
),
text text
); );
} }
@ -449,9 +489,11 @@ impl TextSvg {
let transform = tiny_skia::Transform::default(); let transform = tiny_skia::Transform::default();
#[allow(clippy::cast_sign_loss)] #[allow(clippy::cast_sign_loss)]
let (size_width, size_height) = (size.width as u32, size.height as u32); let (size_width, size_height) =
(size.width as u32, size.height as u32);
let Some(mut pixmap) = Pixmap::new(size_width, size_height) else { let Some(mut pixmap) = Pixmap::new(size_width, size_height)
else {
error!("Couldn't create a new pixmap from size"); error!("Couldn't create a new pixmap from size");
return self; return self;
}; };
@ -467,7 +509,8 @@ impl TextSvg {
// debug!("saved"); // debug!("saved");
// let handle = Handle::from_path(path); // let handle = Handle::from_path(path);
let handle = Handle::from_rgba(size_width, size_height, pixmap.take()); let handle =
Handle::from_rgba(size_width, size_height, pixmap.take());
self.handle = Some(handle); self.handle = Some(handle);
// debug!("stored"); // debug!("stored");
self self
@ -475,7 +518,13 @@ impl TextSvg {
pub fn view<'a>(&self) -> Element<'a, Message> { pub fn view<'a>(&self) -> Element<'a, Message> {
self.handle.clone().map_or_else( self.handle.clone().map_or_else(
|| Element::from(Space::new().height(Length::Fill).width(Length::Fill)), || {
Element::from(
Space::new()
.height(Length::Fill)
.width(Length::Fill),
)
},
|handle| { |handle| {
Image::new(handle) Image::new(handle)
.content_fit(ContentFit::Cover) .content_fit(ContentFit::Cover)
@ -538,7 +587,9 @@ pub fn text_svg_generator_with_cache(
let font = slide.font().unwrap_or_default(); let font = slide.font().unwrap_or_default();
let text_svg = TextSvg::new(slide.text()) let text_svg = TextSvg::new(slide.text())
.alignment(slide.text_alignment()) .alignment(slide.text_alignment())
.fill(slide.text_color().unwrap_or_else(|| "#fff".into())); .fill(
slide.text_color().unwrap_or_else(|| "#fff".into()),
);
let text_svg = if let Some(stroke) = slide.stroke() { let text_svg = if let Some(stroke) = slide.stroke() {
text_svg.stroke(stroke) text_svg.stroke(stroke)
} else { } else {
@ -551,7 +602,8 @@ pub fn text_svg_generator_with_cache(
}; };
let text_svg = text_svg.font(font).fontdb(Arc::clone(fontdb)); 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); // 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); let text_svg =
text_svg.build(Size::new(1280.0, 720.0), cache);
slide.text_svg = Some(text_svg); slide.text_svg = Some(text_svg);
Ok(slide) Ok(slide)
} }
@ -591,10 +643,10 @@ mod tests {
slide slide
.text_svg .text_svg
.is_some_and(|svg| svg.handle.is_some()) .is_some_and(|svg| svg.handle.is_some())
); )
}, },
Err(e) => panic!("There was an issue creating the TextSvg: {e}"), Err(e) => assert!(false, "There was an issue creating the TextSvg: {e}"),
} };
}); });
} }
} }

3
src/ui/video.rs Normal file
View file

@ -0,0 +1,3 @@
// use iced_video_player::Video;
// fn video_player(video: &Video) -> Element<Message> {}

View file

@ -1,21 +1,22 @@
use std::io; use std::{io, path::PathBuf};
use std::path::PathBuf;
use cosmic::dialog::file_chooser::FileFilter; use cosmic::{
use cosmic::dialog::file_chooser::open::Dialog; Element, Task,
use cosmic::iced::Length; dialog::file_chooser::{FileFilter, open::Dialog},
use cosmic::iced::alignment::Vertical; iced::{Length, alignment::Vertical},
use cosmic::iced::widget::{column, row}; iced_widget::{column, row},
use cosmic::prelude::*; theme,
use cosmic::widget::space::{self, horizontal}; widget::{
use cosmic::widget::{Space, button, container, icon, slider, text, text_input}; Space, button, container, icon, progress_bar,
use cosmic::{Element, Task, theme}; space::{self, horizontal},
use iced_video_player::{Position, Video, VideoPlayer}; text, text_input,
},
};
use iced_video_player::{Video, VideoPlayer};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use url::Url; use url::Url;
use crate::core::videos; use crate::core::videos;
use crate::ui::gst_video;
#[derive(Debug)] #[derive(Debug)]
pub struct VideoEditor { pub struct VideoEditor {
@ -23,7 +24,6 @@ pub struct VideoEditor {
core_video: Option<videos::Video>, core_video: Option<videos::Video>,
title: String, title: String,
editing: bool, editing: bool,
position: f64,
} }
pub enum Action { pub enum Action {
@ -42,8 +42,6 @@ pub enum Message {
None, None,
PauseVideo, PauseVideo,
UpdateVideoFile(videos::Video), UpdateVideoFile(videos::Video),
VideoPos(f64),
NewFrame,
} }
impl VideoEditor { impl VideoEditor {
@ -54,7 +52,6 @@ impl VideoEditor {
core_video: None, core_video: None,
title: "Death was Arrested".to_string(), title: "Death was Arrested".to_string(),
editing: false, editing: false,
position: 0.0,
} }
} }
pub fn update(&mut self, message: Message) -> Action { pub fn update(&mut self, message: Message) -> Action {
@ -84,41 +81,29 @@ impl VideoEditor {
warn!(?video); warn!(?video);
return Action::UpdateVideo(video); return Action::UpdateVideo(video);
} }
Message::VideoPos(position) => {
if let Some(video) = self.video.as_mut() {
let pausing = video.paused();
video.set_paused(true);
let position =
Position::Time(std::time::Duration::from_secs_f64(position));
if let Err(e) = video.seek(position, false) {
error!(?e);
}
video.set_paused(pausing);
}
}
Message::PickVideo => { Message::PickVideo => {
let video_id = self.core_video.as_ref().map(|v| v.id).unwrap_or_default(); let video_id = self
let task = Task::perform(pick_video(), move |video_result| { .core_video
video_result.map_or(Message::None, |video| { .as_ref()
let mut video = videos::Video::from(video); .map(|v| v.id)
video.id = video_id; .unwrap_or_default();
Message::UpdateVideoFile(video) 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); return Action::Task(task);
} }
Message::UpdateVideoFile(video) => { Message::UpdateVideoFile(video) => {
self.update_entire_video(&video); self.update_entire_video(&video);
return Action::UpdateVideo(video); return Action::UpdateVideo(video);
} }
Message::NewFrame => {
if let Some(video) = &self.video
&& self.position > 0.0
&& video.position().as_secs_f64() != 0.0
{
self.position = video.position().as_secs_f64();
}
}
Message::None => (), Message::None => (),
} }
Action::None Action::None
@ -129,19 +114,17 @@ impl VideoEditor {
|| container(horizontal()), || container(horizontal()),
|video| { |video| {
let play_button = button::icon(if video.paused() { let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start-symbolic") icon::from_name("media-playback-start")
} else { } else {
icon::from_name("media-playback-pause-symbolic") icon::from_name("media-playback-pause")
}) })
.on_press(Message::PauseVideo); .on_press(Message::PauseVideo);
let video_track = slider( let video_track = progress_bar(
0.0..=video.duration().as_secs_f64(), 0.0..=video.duration().as_secs_f32(),
video.position().as_secs_f64(), video.position().as_secs_f32(),
Message::VideoPos,
) )
.step(0.1) .girth(cosmic::theme::spacing().space_s)
.width(Length::Fill) .length(Length::Fill);
.height(cosmic::theme::spacing().space_s);
container( container(
row![play_button, video_track] row![play_button, video_track]
.align_y(Vertical::Center) .align_y(Vertical::Center)
@ -152,18 +135,10 @@ impl VideoEditor {
}, },
); );
let video_player = self let video_player = self.video.as_ref().map_or_else(
.video || Element::from(Space::new()),
.as_ref() |video| Element::from(VideoPlayer::new(video)),
.map_or_else( );
|| Space::new().apply(container),
|video| {
VideoPlayer::new(video)
.on_new_frame(Message::NewFrame)
.apply(container)
},
)
.center(Length::Fill);
let video_section = column![video_player, video_elements] let video_section = column![video_player, video_elements]
.spacing(cosmic::theme::spacing().space_s); .spacing(cosmic::theme::spacing().space_s);
@ -176,15 +151,16 @@ impl VideoEditor {
} }
fn toolbar(&self) -> Element<Message> { fn toolbar(&self) -> Element<Message> {
let title_box = let title_box = text_input("Title...", &self.title)
text_input("Title...", &self.title).on_input(Message::ChangeTitle); .on_input(Message::ChangeTitle);
let video_selector = let video_selector = button::icon(
button::icon(icon::from_name("folder-videos-symbolic").scale(2)) icon::from_name("folder-videos-symbolic").scale(2),
.label("Video") )
.tooltip("Select a video") .label("Video")
.on_press(Message::PickVideo) .tooltip("Select a video")
.padding(10); .on_press(Message::PickVideo)
.padding(10);
row![ row![
text::body("Title:"), text::body("Title:"),
@ -203,26 +179,15 @@ impl VideoEditor {
fn update_entire_video(&mut self, video: &videos::Video) { fn update_entire_video(&mut self, video: &videos::Video) {
debug!(?video); debug!(?video);
let Ok(url) = Url::from_file_path(video.path.clone()) else { 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.video = None;
self.title.clone_from(&video.title); self.title.clone_from(&video.title);
self.core_video = Some(video.clone()); self.core_video = Some(video.clone());
return; return;
}; };
let settings = gst_video::VideoSettings {
mute: false,
framerate: 60,
appsink_name: "lumina_video".to_string(),
};
let Ok(mut player_video) = gst_video::create_video(&url, &settings) else {
self.video = None;
self.title = format!("{}: {}", String::from("Video Missing"), &video.title);
self.core_video = Some(video.clone());
return;
};
player_video.set_paused(true); player_video.set_paused(true);
self.video = Some(player_video); self.video = Some(player_video);
self.title.clone_from(&video.title); self.title.clone_from(&video.title);
@ -251,7 +216,9 @@ async fn pick_video() -> Result<PathBuf, VideoError> {
error!(?e); error!(?e);
VideoError::DialogClosed VideoError::DialogClosed
}) })
.map(|file| file.url().to_file_path().expect("Should be a file here")) .map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new() // rfd::AsyncFileDialog::new()
// .set_title("Choose a background...") // .set_title("Choose a background...")
// .add_filter( // .add_filter(

View file

@ -28,15 +28,18 @@ use cosmic::iced::advanced::widget::{Operation, Tree, Widget, tree};
use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer}; use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer};
use cosmic::iced::alignment::{self, Alignment}; use cosmic::iced::alignment::{self, Alignment};
use cosmic::iced::event::Event; use cosmic::iced::event::Event;
use cosmic::iced::{self, Transformation, mouse};
use cosmic::iced::{ use cosmic::iced::{
self, Background, Border, Color, Element, Length, Padding, Pixels, Point, Rectangle, Background, Border, Color, Element, Length, Padding, Pixels,
Size, Transformation, Vector, mouse, Point, Rectangle, Size, Vector,
}; };
use super::{Action, DragEvent, DropPosition}; use super::{Action, DragEvent, DropPosition};
pub fn column<'a, Message, Theme, Renderer>( pub fn column<'a, Message, Theme, Renderer>(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Column<'a, Message, Theme, Renderer> ) -> Column<'a, Message, Theme, Renderer>
where where
Renderer: renderer::Renderer, Renderer: renderer::Renderer,
@ -70,8 +73,12 @@ const DRAG_DEADBAND_DISTANCE: f32 = 5.0;
/// } /// }
/// ``` /// ```
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
pub struct Column<'a, Message, Theme = cosmic::Theme, Renderer = iced::Renderer> pub struct Column<
where 'a,
Message,
Theme = cosmic::Theme,
Renderer = iced::Renderer,
> where
Theme: Catalog, Theme: Catalog,
{ {
spacing: f32, spacing: f32,
@ -87,7 +94,8 @@ where
class: Theme::Class<'a>, class: Theme::Class<'a>,
} }
impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer>
Column<'a, Message, Theme, Renderer>
where where
Renderer: renderer::Renderer, Renderer: renderer::Renderer,
Theme: Catalog, Theme: Catalog,
@ -106,7 +114,9 @@ where
/// Creates a [`Column`] with the given elements. /// Creates a [`Column`] with the given elements.
pub fn with_children( pub fn with_children(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self { ) -> Self {
let iterator = children.into_iter(); let iterator = children.into_iter();
@ -121,7 +131,9 @@ where
/// If any of the children have a [`Length::Fill`] strategy, you will need to /// If any of the children have a [`Length::Fill`] strategy, you will need to
/// call [`Column::width`] or [`Column::height`] accordingly. /// call [`Column::width`] or [`Column::height`] accordingly.
#[must_use] #[must_use]
pub fn from_vec(children: Vec<Element<'a, Message, Theme, Renderer>>) -> Self { pub fn from_vec(
children: Vec<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self { Self {
spacing: 0.0, spacing: 0.0,
padding: Padding::ZERO, padding: Padding::ZERO,
@ -172,7 +184,10 @@ where
} }
/// Sets the horizontal alignment of the contents of the [`Column`] . /// Sets the horizontal alignment of the contents of the [`Column`] .
pub fn align_x(mut self, align: impl Into<alignment::Horizontal>) -> Self { pub fn align_x(
mut self,
align: impl Into<alignment::Horizontal>,
) -> Self {
self.align = Alignment::from(align.into()); self.align = Alignment::from(align.into());
self self
} }
@ -208,7 +223,9 @@ where
/// Adds an element to the [`Column`], if `Some`. /// Adds an element to the [`Column`], if `Some`.
pub fn push_maybe( pub fn push_maybe(
self, self,
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>, child: Option<
impl Into<Element<'a, Message, Theme, Renderer>>,
>,
) -> Self { ) -> Self {
if let Some(child) = child { if let Some(child) = child {
self.push(child) self.push(child)
@ -219,7 +236,10 @@ where
/// Sets the style of the [`Column`]. /// Sets the style of the [`Column`].
#[must_use] #[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self pub fn style(
mut self,
style: impl Fn(&Theme) -> Style + 'a,
) -> Self
where where
Theme::Class<'a>: From<StyleFn<'a, Theme>>, Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{ {
@ -229,7 +249,10 @@ where
/// Sets the style class of the [`Column`]. /// Sets the style class of the [`Column`].
#[must_use] #[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self { pub fn class(
mut self,
class: impl Into<Theme::Class<'a>>,
) -> Self {
self.class = class.into(); self.class = class.into();
self self
} }
@ -237,13 +260,18 @@ where
/// Extends the [`Column`] with the given children. /// Extends the [`Column`] with the given children.
pub fn extend( pub fn extend(
self, self,
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self { ) -> Self {
children.into_iter().fold(self, Self::push) children.into_iter().fold(self, Self::push)
} }
/// The message produced by the [`Column`] when a child is dragged. /// 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 { pub fn on_drag(
mut self,
on_reorder: impl Fn(DragEvent) -> Message + 'a,
) -> Self {
self.on_drag = Some(Box::new(on_reorder)); self.on_drag = Some(Box::new(on_reorder));
self self
} }
@ -292,7 +320,8 @@ where
} }
} }
impl<Message, Renderer> Default for Column<'_, Message, Theme, Renderer> impl<Message, Renderer> Default
for Column<'_, Message, Theme, Renderer>
where where
Renderer: renderer::Renderer, Renderer: renderer::Renderer,
Theme: Catalog, Theme: Catalog,
@ -308,7 +337,9 @@ impl<'a, Message, Theme, Renderer: renderer::Renderer>
where where
Theme: Catalog, Theme: Catalog,
{ {
fn from_iter<T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>>( fn from_iter<
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
>(
iter: T, iter: T,
) -> Self { ) -> Self {
Self::with_children(iter) Self::with_children(iter)
@ -383,7 +414,9 @@ where
.for_each(|((child, state), c_layout)| { .for_each(|((child, state), c_layout)| {
child.as_widget_mut().operate( child.as_widget_mut().operate(
state, state,
c_layout.with_virtual_offset(layout.virtual_offset()), c_layout.with_virtual_offset(
layout.virtual_offset(),
),
renderer, renderer,
operation, operation,
); );
@ -404,29 +437,20 @@ where
) { ) {
let action = tree.state.downcast_mut::<Action>(); let action = tree.state.downcast_mut::<Action>();
// let children have precedence
self.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), c_layout)| {
child.as_widget_mut().update(
state,
&event.clone(),
c_layout.with_virtual_offset(layout.virtual_offset()),
cursor,
renderer,
clipboard,
shell,
viewport,
)
});
match event { match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { Event::Mouse(mouse::Event::ButtonPressed(
if let Some(cursor_position) = cursor.position_over(layout.bounds()) { mouse::Button::Left,
for (index, child_layout) in layout.children().enumerate() { )) => {
if child_layout.bounds().contains(cursor_position) { 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 { *action = Action::Picking {
index, index,
origin: cursor_position, origin: cursor_position,
@ -440,8 +464,10 @@ where
Event::Mouse(mouse::Event::CursorMoved { .. }) => { Event::Mouse(mouse::Event::CursorMoved { .. }) => {
match *action { match *action {
Action::Picking { index, origin } => { Action::Picking { index, origin } => {
if let Some(cursor_position) = cursor.position() if let Some(cursor_position) =
&& cursor_position.distance(origin) > self.deadband_zone cursor.position()
&& cursor_position.distance(origin)
> self.deadband_zone
{ {
// Start dragging // Start dragging
*action = Action::Dragging { *action = Action::Dragging {
@ -450,13 +476,17 @@ where
last_cursor: cursor_position, last_cursor: cursor_position,
}; };
if let Some(on_reorder) = &self.on_drag { if let Some(on_reorder) = &self.on_drag {
shell.publish(on_reorder(DragEvent::Picked { index })); shell.publish(on_reorder(
DragEvent::Picked { index },
));
} }
shell.capture_event(); shell.capture_event();
} }
} }
Action::Dragging { origin, index, .. } => { Action::Dragging { origin, index, .. } => {
if let Some(cursor_position) = cursor.position() { if let Some(cursor_position) =
cursor.position()
{
*action = Action::Dragging { *action = Action::Dragging {
last_cursor: cursor_position, last_cursor: cursor_position,
origin, origin,
@ -468,25 +498,41 @@ where
_ => {} _ => {}
} }
} }
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
)) => {
match *action { match *action {
Action::Dragging { index, .. } => { Action::Dragging { index, .. } => {
if let Some(cursor_position) = cursor.position() { if let Some(cursor_position) =
cursor.position()
{
let bounds = layout.bounds(); let bounds = layout.bounds();
if bounds.contains(cursor_position) { if bounds.contains(cursor_position) {
let (target_index, drop_position) = self let (target_index, drop_position) =
.compute_target_index(cursor_position, layout, index); self.compute_target_index(
cursor_position,
if let Some(on_reorder) = &self.on_drag { layout,
shell.publish(on_reorder(DragEvent::Dropped {
index, index,
target_index, );
drop_position,
})); if let Some(on_reorder) =
&self.on_drag
{
shell.publish(on_reorder(
DragEvent::Dropped {
index,
target_index,
drop_position,
},
));
shell.capture_event(); shell.capture_event();
} }
} else if let Some(on_reorder) = &self.on_drag { } else if let Some(on_reorder) =
shell.publish(on_reorder(DragEvent::Canceled { index })); &self.on_drag
{
shell.publish(on_reorder(
DragEvent::Canceled { index },
));
shell.capture_event(); shell.capture_event();
} }
} }
@ -501,6 +547,24 @@ where
} }
_ => {} _ => {}
} }
self.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), c_layout)| {
child.as_widget_mut().update(
state,
&event.clone(),
c_layout
.with_virtual_offset(layout.virtual_offset()),
cursor,
renderer,
clipboard,
shell,
viewport,
)
});
} }
fn mouse_interaction( fn mouse_interaction(
@ -524,7 +588,8 @@ where
.map(|((child, state), c_layout)| { .map(|((child, state), c_layout)| {
child.as_widget().mouse_interaction( child.as_widget().mouse_interaction(
state, state,
c_layout.with_virtual_offset(layout.virtual_offset()), c_layout
.with_virtual_offset(layout.virtual_offset()),
cursor, cursor,
viewport, viewport,
renderer, renderer,
@ -558,15 +623,20 @@ where
// Determine the target index based on cursor position // Determine the target index based on cursor position
let target_index = if cursor.position().is_some() { let target_index = if cursor.position().is_some() {
let (target_index, _) = let (target_index, _) = self
self.compute_target_index(*last_cursor, layout, *index); .compute_target_index(
*last_cursor,
layout,
*index,
);
target_index.min(child_count - 1) target_index.min(child_count - 1)
} else { } else {
*index *index
}; };
// Store the width of the dragged item // Store the width of the dragged item
let drag_bounds = layout.children().nth(*index).unwrap().bounds(); let drag_bounds =
layout.children().nth(*index).unwrap().bounds();
let drag_height = drag_bounds.height + self.spacing; let drag_height = drag_bounds.height + self.spacing;
// Draw all children except the one being dragged // Draw all children except the one being dragged
@ -574,92 +644,125 @@ where
for i in 0..child_count { for i in 0..child_count {
let child = &self.children[i]; let child = &self.children[i];
let state = &tree.children[i]; let state = &tree.children[i];
let child_layout = layout.children().nth(i).unwrap(); let child_layout =
layout.children().nth(i).unwrap();
// Draw the dragged item separately // Draw the dragged item separately
// TODO: Draw a shadow below the picked item to enhance the // TODO: Draw a shadow below the picked item to enhance the
// floating effect // floating effect
if i == *index { if i == *index {
let scaling = Transformation::scale(style.scale); let scaling =
let translation = *last_cursor - *origin * scaling; Transformation::scale(style.scale);
renderer.with_translation(translation, |renderer| { let translation =
renderer.with_transformation(scaling, |renderer| { *last_cursor - *origin * scaling;
renderer.with_layer(child_layout.bounds(), |renderer| { renderer.with_translation(
child.as_widget().draw( translation,
state, |renderer| {
renderer, renderer.with_transformation(
theme, scaling,
default_style, |renderer| {
child_layout, renderer.with_layer(
cursor, child_layout.bounds(),
viewport, |renderer| {
); child
}); .as_widget()
}); .draw(
}); state,
} else { renderer,
let offset: i32 = match target_index.cmp(index) { theme,
std::cmp::Ordering::Less default_style,
if i >= target_index && i < *index => child_layout,
{ cursor,
1 viewport,
} );
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,
); );
},
);
} 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,
};
// Keep track of the total translation so we can let translation = Vector::new(
// draw the "ghost" of the dragged item later 0.0,
translations -= (child_layout.bounds().height offset as f32 * drag_height,
+ self.spacing) );
* offset.signum() as f32; 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 // Draw a ghost of the dragged item in its would-be position
let ghost_translation = Vector::new(0.0, translations); let ghost_translation =
renderer.with_translation(ghost_translation, |renderer| { Vector::new(0.0, translations);
renderer.fill_quad( renderer.with_translation(
renderer::Quad { ghost_translation,
bounds: drag_bounds, |renderer| {
border: style.ghost_border, renderer.fill_quad(
..renderer::Quad::default() renderer::Quad {
}, bounds: drag_bounds,
style.ghost_background, border: style.ghost_border,
); ..renderer::Quad::default()
}); },
style.ghost_background,
);
},
);
} }
_ => { _ => {
// Draw all children normally when not dragging // Draw all children normally when not dragging
if let Some(clipped_viewport) = layout.bounds().intersection(viewport) { if let Some(clipped_viewport) =
layout.bounds().intersection(viewport)
{
let viewport = if self.clip { let viewport = if self.clip {
&clipped_viewport &clipped_viewport
} else { } else {
@ -670,14 +773,18 @@ where
.iter() .iter()
.zip(&tree.children) .zip(&tree.children)
.zip(layout.children()) .zip(layout.children())
.filter(|(_, layout)| layout.bounds().intersects(viewport)) .filter(|(_, layout)| {
layout.bounds().intersects(viewport)
})
{ {
child.as_widget().draw( child.as_widget().draw(
state, state,
renderer, renderer,
theme, theme,
default_style, default_style,
c_layout.with_virtual_offset(layout.virtual_offset()), c_layout.with_virtual_offset(
layout.virtual_offset(),
),
cursor, cursor,
viewport, viewport,
); );
@ -710,7 +817,7 @@ where
state: &Tree, state: &Tree,
layout: Layout<'_>, layout: Layout<'_>,
renderer: &Renderer, renderer: &Renderer,
dnd_rectangles: &mut cosmic::iced::core::clipboard::DndDestinationRectangles, dnd_rectangles: &mut cosmic::iced_core::clipboard::DndDestinationRectangles,
) { ) {
for ((e, c_layout), state) in self for ((e, c_layout), state) in self
.children .children
@ -728,7 +835,8 @@ where
} }
} }
impl<'a, Message, Theme, Renderer> From<Column<'a, Message, Theme, Renderer>> impl<'a, Message, Theme, Renderer>
From<Column<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer> for Element<'a, Message, Theme, Renderer>
where where
Message: 'a, Message: 'a,
@ -784,7 +892,8 @@ impl Catalog for cosmic::Theme {
pub fn default(theme: &Theme) -> Style { pub fn default(theme: &Theme) -> Style {
Style { Style {
scale: 1.05, scale: 1.05,
moved_item_overlay: Color::from(theme.cosmic().primary.base).scale_alpha(0.2), moved_item_overlay: Color::from(theme.cosmic().primary.base)
.scale_alpha(0.2),
ghost_border: Border { ghost_border: Border {
width: 1.0, width: 1.0,
color: theme.cosmic().secondary.base.into(), color: theme.cosmic().secondary.base.into(),

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,8 @@
use cosmic::iced::Point; use cosmic::iced::Point;
pub use self::column::column; pub use self::column::column;
pub use self::flex_row::flex_row;
pub use self::row::row; pub use self::row::row;
pub mod column; pub mod column;
pub mod flex_row;
pub mod row; pub mod row;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -28,15 +28,18 @@ use cosmic::iced::advanced::widget::{Operation, Tree, Widget, tree};
use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer}; use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer};
use cosmic::iced::alignment::{self, Alignment}; use cosmic::iced::alignment::{self, Alignment};
use cosmic::iced::event::Event; use cosmic::iced::event::Event;
use cosmic::iced::{self, Transformation, mouse};
use cosmic::iced::{ use cosmic::iced::{
self, Background, Border, Color, Element, Length, Padding, Pixels, Point, Rectangle, Background, Border, Color, Element, Length, Padding, Pixels,
Size, Transformation, Vector, mouse, Point, Rectangle, Size, Vector,
}; };
use super::{Action, DragEvent, DropPosition}; use super::{Action, DragEvent, DropPosition};
pub fn row<'a, Message, Theme, Renderer>( pub fn row<'a, Message, Theme, Renderer>(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Row<'a, Message, Theme, Renderer> ) -> Row<'a, Message, Theme, Renderer>
where where
Renderer: renderer::Renderer, Renderer: renderer::Renderer,
@ -105,7 +108,9 @@ where
/// Creates a [`Row`] with the given elements. /// Creates a [`Row`] with the given elements.
pub fn with_children( pub fn with_children(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self { ) -> Self {
let iterator = children.into_iter(); let iterator = children.into_iter();
@ -120,7 +125,9 @@ where
/// If any of the children have a [`Length::Fill`] strategy, you will need to /// If any of the children have a [`Length::Fill`] strategy, you will need to
/// call [`Row::width`] or [`Row::height`] accordingly. /// call [`Row::width`] or [`Row::height`] accordingly.
#[must_use] #[must_use]
pub fn from_vec(children: Vec<Element<'a, Message, Theme, Renderer>>) -> Self { pub fn from_vec(
children: Vec<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self { Self {
spacing: 0.0, spacing: 0.0,
padding: Padding::ZERO, padding: Padding::ZERO,
@ -164,7 +171,10 @@ where
} }
/// Sets the vertical alignment of the contents of the [`Row`] . /// Sets the vertical alignment of the contents of the [`Row`] .
pub fn align_y(mut self, align: impl Into<alignment::Vertical>) -> Self { pub fn align_y(
mut self,
align: impl Into<alignment::Vertical>,
) -> Self {
self.align = Alignment::from(align.into()); self.align = Alignment::from(align.into());
self self
} }
@ -200,7 +210,9 @@ where
/// Adds an element to the [`Row`], if `Some`. /// Adds an element to the [`Row`], if `Some`.
pub fn push_maybe( pub fn push_maybe(
self, self,
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>, child: Option<
impl Into<Element<'a, Message, Theme, Renderer>>,
>,
) -> Self { ) -> Self {
if let Some(child) = child { if let Some(child) = child {
self.push(child) self.push(child)
@ -211,7 +223,10 @@ where
/// Sets the style of the [`Row`]. /// Sets the style of the [`Row`].
#[must_use] #[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self pub fn style(
mut self,
style: impl Fn(&Theme) -> Style + 'a,
) -> Self
where where
Theme::Class<'a>: From<StyleFn<'a, Theme>>, Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{ {
@ -221,7 +236,10 @@ where
/// Sets the style class of the [`Row`]. /// Sets the style class of the [`Row`].
#[must_use] #[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self { pub fn class(
mut self,
class: impl Into<Theme::Class<'a>>,
) -> Self {
self.class = class.into(); self.class = class.into();
self self
} }
@ -229,7 +247,9 @@ where
/// Extends the [`Row`] with the given children. /// Extends the [`Row`] with the given children.
pub fn extend( pub fn extend(
self, self,
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>, children: impl IntoIterator<
Item = Element<'a, Message, Theme, Renderer>,
>,
) -> Self { ) -> Self {
children.into_iter().fold(self, Self::push) children.into_iter().fold(self, Self::push)
} }
@ -237,12 +257,17 @@ where
/// Turns the [`Row`] into a [`Wrapping`] row. /// Turns the [`Row`] into a [`Wrapping`] row.
/// ///
/// The original alignment of the [`Row`] is preserved per row wrapped. /// The original alignment of the [`Row`] is preserved per row wrapped.
pub const fn wrap(self) -> Wrapping<'a, Message, Theme, Renderer> { pub const fn wrap(
self,
) -> Wrapping<'a, Message, Theme, Renderer> {
Wrapping { row: self } Wrapping { row: self }
} }
/// The message produced by the [`Row`] when a child is dragged. /// The message produced by the [`Row`] when a child is dragged.
pub fn on_drag(mut self, on_reorder: impl Fn(DragEvent) -> Message + 'a) -> Self { pub fn on_drag(
mut self,
on_reorder: impl Fn(DragEvent) -> Message + 'a,
) -> Self {
self.on_drag = Some(Box::new(on_reorder)); self.on_drag = Some(Box::new(on_reorder));
self self
} }
@ -307,7 +332,9 @@ impl<'a, Message, Theme, Renderer: renderer::Renderer>
where where
Theme: Catalog, Theme: Catalog,
{ {
fn from_iter<T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>>( fn from_iter<
T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
>(
iter: T, iter: T,
) -> Self { ) -> Self {
Self::with_children(iter) Self::with_children(iter)
@ -397,7 +424,117 @@ where
) { ) {
let action = tree.state.downcast_mut::<Action>(); let action = tree.state.downcast_mut::<Action>();
// let children have precedence 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,
};
shell.capture_event();
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 },
));
}
shell.capture_event();
}
}
Action::Dragging { origin, index, .. } => {
if let Some(cursor_position) =
cursor.position()
{
*action = Action::Dragging {
last_cursor: cursor_position,
origin,
index,
};
shell.capture_event();
}
}
_ => {}
}
}
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,
},
));
shell.capture_event();
}
} else if let Some(on_reorder) =
&self.on_drag
{
shell.publish(on_reorder(
DragEvent::Canceled { index },
));
shell.capture_event();
}
}
*action = Action::Idle;
}
Action::Picking { .. } => {
// Did not move enough to start dragging
*action = Action::Idle;
}
_ => {}
}
}
_ => {}
}
self.children self.children
.iter_mut() .iter_mut()
.zip(&mut tree.children) .zip(&mut tree.children)
@ -414,86 +551,6 @@ where
viewport, viewport,
) )
}); });
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,
};
shell.capture_event();
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 }));
}
shell.capture_event();
}
}
Action::Dragging { origin, index, .. } => {
if let Some(cursor_position) = cursor.position() {
*action = Action::Dragging {
last_cursor: cursor_position,
origin,
index,
};
shell.capture_event();
}
}
_ => {}
}
}
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,
}));
shell.capture_event();
}
} else if let Some(on_reorder) = &self.on_drag {
shell.publish(on_reorder(DragEvent::Canceled { index }));
shell.capture_event();
}
}
*action = Action::Idle;
}
Action::Picking { .. } => {
// Did not move enough to start dragging
*action = Action::Idle;
}
_ => {}
}
}
_ => {}
}
} }
fn mouse_interaction( fn mouse_interaction(
@ -515,9 +572,9 @@ where
.zip(&tree.children) .zip(&tree.children)
.zip(layout.children()) .zip(layout.children())
.map(|((child, state), layout)| { .map(|((child, state), layout)| {
child child.as_widget().mouse_interaction(
.as_widget() state, layout, cursor, viewport, renderer,
.mouse_interaction(state, layout, cursor, viewport, renderer) )
}) })
.max() .max()
.unwrap_or_default() .unwrap_or_default()
@ -547,15 +604,20 @@ where
// Determine the target index based on cursor position // Determine the target index based on cursor position
let target_index = if cursor.position().is_some() { let target_index = if cursor.position().is_some() {
let (target_index, _) = let (target_index, _) = self
self.compute_target_index(*last_cursor, layout, *index); .compute_target_index(
*last_cursor,
layout,
*index,
);
target_index.min(child_count - 1) target_index.min(child_count - 1)
} else { } else {
*index *index
}; };
// Store the width of the dragged item // Store the width of the dragged item
let drag_bounds = layout.children().nth(*index).unwrap().bounds(); let drag_bounds =
layout.children().nth(*index).unwrap().bounds();
let drag_width = drag_bounds.width + self.spacing; let drag_width = drag_bounds.width + self.spacing;
// Draw all children except the one being dragged // Draw all children except the one being dragged
@ -563,88 +625,118 @@ where
for i in 0..child_count { for i in 0..child_count {
let child = &self.children[i]; let child = &self.children[i];
let state = &tree.children[i]; let state = &tree.children[i];
let child_layout = layout.children().nth(i).unwrap(); let child_layout =
layout.children().nth(i).unwrap();
// Draw the dragged item separately // Draw the dragged item separately
// TODO: Draw a shadow below the picked item to enhance the // TODO: Draw a shadow below the picked item to enhance the
// floating effect // floating effect
if i == *index { if i == *index {
let scaling = Transformation::scale(style.scale); let scaling =
let translation = *last_cursor - *origin * scaling; Transformation::scale(style.scale);
renderer.with_translation(translation, |renderer| { let translation =
renderer.with_transformation(scaling, |renderer| { *last_cursor - *origin * scaling;
renderer.with_layer(child_layout.bounds(), |renderer| { renderer.with_translation(
child.as_widget().draw( translation,
state, |renderer| {
renderer, renderer.with_transformation(
theme, scaling,
defaults, |renderer| {
child_layout, renderer.with_layer(
cursor, child_layout.bounds(),
viewport, |renderer| {
); child
}); .as_widget()
}); .draw(
}); state,
} else { renderer,
let offset: i32 = match target_index.cmp(index) { theme,
std::cmp::Ordering::Less defaults,
if i >= target_index && i < *index => child_layout,
{ cursor,
1 viewport,
} );
std::cmp::Ordering::Greater },
if i > *index && i <= target_index => );
{
-1
}
_ => 0,
};
let translation = Vector::new(offset as f32 * drag_width, 0.0);
renderer.with_translation(translation, |renderer| {
child.as_widget().draw(
state,
renderer,
theme,
defaults,
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,
); );
},
);
} 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,
};
// Keep track of the total translation so we can let translation = Vector::new(
// draw the "ghost" of the dragged item later offset as f32 * drag_width,
translations -= (child_layout.bounds().width 0.0,
+ self.spacing) );
* offset.signum() as f32; renderer.with_translation(
} translation,
}); |renderer| {
child.as_widget().draw(
state,
renderer,
theme,
defaults,
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().width
+ self.spacing)
* offset.signum() as f32;
}
},
);
} }
} }
// Draw a ghost of the dragged item in its would-be position // Draw a ghost of the dragged item in its would-be position
let ghost_translation = Vector::new(translations, 0.0); let ghost_translation =
renderer.with_translation(ghost_translation, |renderer| { Vector::new(translations, 0.0);
renderer.fill_quad( renderer.with_translation(
renderer::Quad { ghost_translation,
bounds: drag_bounds, |renderer| {
border: style.ghost_border, renderer.fill_quad(
..renderer::Quad::default() renderer::Quad {
}, bounds: drag_bounds,
style.ghost_background, border: style.ghost_border,
); ..renderer::Quad::default()
}); },
style.ghost_background,
);
},
);
} }
_ => { _ => {
// Draw all children normally when not dragging // Draw all children normally when not dragging
@ -654,9 +746,10 @@ where
.zip(&tree.children) .zip(&tree.children)
.zip(layout.children()) .zip(layout.children())
{ {
child child.as_widget().draw(
.as_widget() state, renderer, theme, defaults, layout,
.draw(state, renderer, theme, defaults, layout, cursor, viewport); cursor, viewport,
);
} }
} }
} }
@ -681,7 +774,8 @@ where
} }
} }
impl<'a, Message, Theme, Renderer> From<Row<'a, Message, Theme, Renderer>> impl<'a, Message, Theme, Renderer>
From<Row<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer> for Element<'a, Message, Theme, Renderer>
where where
Message: 'a, Message: 'a,
@ -700,8 +794,12 @@ where
/// ///
/// The original alignment of the [`Row`] is preserved per row wrapped. /// The original alignment of the [`Row`] is preserved per row wrapped.
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
pub struct Wrapping<'a, Message, Theme = cosmic::Theme, Renderer = iced::Renderer> pub struct Wrapping<
where 'a,
Message,
Theme = cosmic::Theme,
Renderer = iced::Renderer,
> where
Theme: Catalog, Theme: Catalog,
{ {
row: Row<'a, Message, Theme, Renderer>, row: Row<'a, Message, Theme, Renderer>,
@ -752,31 +850,34 @@ where
Alignment::End => 1.0, Alignment::End => 1.0,
}; };
let align = |row_start: std::ops::Range<usize>, let align =
row_height: f32, |row_start: std::ops::Range<usize>,
children: &mut Vec<layout::Node>| { row_height: f32,
if align_factor != 0.0 { children: &mut Vec<layout::Node>| {
for node in &mut children[row_start] { if align_factor != 0.0 {
let height = node.size().height; for node in &mut children[row_start] {
let height = node.size().height;
node.translate_mut(Vector::new( node.translate_mut(Vector::new(
0.0, 0.0,
(row_height - height) / align_factor, (row_height - height) / align_factor,
)); ));
}
} }
} };
};
for (i, child) in self.row.children.iter_mut().enumerate() { for (i, child) in self.row.children.iter_mut().enumerate() {
let node = let node = child.as_widget_mut().layout(
child &mut tree.children[i],
.as_widget_mut() renderer,
.layout(&mut tree.children[i], renderer, &limits); &limits,
);
let child_size = node.size(); let child_size = node.size();
if x != 0.0 && x + child_size.width > max_width { if x != 0.0 && x + child_size.width > max_width {
intrinsic_size.width = intrinsic_size.width.max(x - spacing); intrinsic_size.width =
intrinsic_size.width.max(x - spacing);
align(row_start..i, row_height, &mut children); align(row_start..i, row_height, &mut children);
@ -788,23 +889,32 @@ where
row_height = row_height.max(child_size.height); row_height = row_height.max(child_size.height);
children.push( children.push(node.move_to((
node.move_to((x + self.row.padding.left, y + self.row.padding.top)), x + self.row.padding.left,
); y + self.row.padding.top,
)));
x += child_size.width + spacing; x += child_size.width + spacing;
} }
if x != 0.0 { if x != 0.0 {
intrinsic_size.width = intrinsic_size.width.max(x - spacing); intrinsic_size.width =
intrinsic_size.width.max(x - spacing);
} }
intrinsic_size.height = y + row_height; intrinsic_size.height = y + row_height;
align(row_start..children.len(), row_height, &mut children); align(row_start..children.len(), row_height, &mut children);
let size = limits.resolve(self.row.width, self.row.height, intrinsic_size); let size = limits.resolve(
self.row.width,
self.row.height,
intrinsic_size,
);
layout::Node::with_children(size.expand(self.row.padding), children) layout::Node::with_children(
size.expand(self.row.padding),
children,
)
} }
fn operate( fn operate(
@ -829,7 +939,8 @@ where
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
self.row.update( self.row.update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport, tree, event, layout, cursor, renderer, clipboard, shell,
viewport,
) )
} }
@ -841,8 +952,9 @@ where
viewport: &Rectangle, viewport: &Rectangle,
renderer: &Renderer, renderer: &Renderer,
) -> mouse::Interaction { ) -> mouse::Interaction {
self.row self.row.mouse_interaction(
.mouse_interaction(tree, layout, cursor, viewport, renderer) tree, layout, cursor, viewport, renderer,
)
} }
fn draw( fn draw(
@ -855,8 +967,9 @@ where
cursor: mouse::Cursor, cursor: mouse::Cursor,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
self.row self.row.draw(
.draw(tree, renderer, theme, style, layout, cursor, viewport); tree, renderer, theme, style, layout, cursor, viewport,
);
} }
fn overlay<'b>( fn overlay<'b>(
@ -867,12 +980,18 @@ where
viewport: &Rectangle, viewport: &Rectangle,
translation: Vector, translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> { ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.row self.row.overlay(
.overlay(tree, layout, renderer, viewport, translation) tree,
layout,
renderer,
viewport,
translation,
)
} }
} }
impl<'a, Message, Theme, Renderer> From<Wrapping<'a, Message, Theme, Renderer>> impl<'a, Message, Theme, Renderer>
From<Wrapping<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer> for Element<'a, Message, Theme, Renderer>
where where
Message: 'a, Message: 'a,
@ -928,15 +1047,19 @@ impl Catalog for cosmic::Theme {
pub fn default(theme: &cosmic::Theme) -> Style { pub fn default(theme: &cosmic::Theme) -> Style {
Style { Style {
scale: 1.05, scale: 1.05,
moved_item_overlay: Color::from(theme.cosmic().primary.base.color) moved_item_overlay: Color::from(
.scale_alpha(0.2), theme.cosmic().primary.base.color,
)
.scale_alpha(0.2),
ghost_border: Border { ghost_border: Border {
width: 1.0, width: 1.0,
color: theme.cosmic().secondary.base.color.into(), color: theme.cosmic().secondary.base.color.into(),
radius: 0.0.into(), radius: 0.0.into(),
}, },
ghost_background: Color::from(theme.cosmic().secondary.base.color) ghost_background: Color::from(
.scale_alpha(0.2) theme.cosmic().secondary.base.color,
.into(), )
.scale_alpha(0.2)
.into(),
} }
} }

View file

@ -1,239 +0,0 @@
use cosmic::iced::{core as iced_core, widget as iced_widget};
use iced_core::event::Event;
use iced_core::widget::{Operation, Tree};
use iced_core::{
Clipboard, Element, Layout, Length, Rectangle, Shell, Vector, Widget, layout, mouse,
overlay, renderer,
};
pub fn loaded_image<'a, Message: 'static, Theme, Renderer>(
handle: impl Into<<cosmic::Renderer as iced_core::image::Renderer>::Handle>,
content: impl Into<cosmic::iced::Element<'a, Message, Theme, Renderer>>,
) -> LoadedImage<'a, Message, Theme, Renderer>
where
Theme: iced_widget::container::Catalog,
<Theme as iced_widget::container::Catalog>::Class<'a>:
From<cosmic::theme::Container<'a>>,
Renderer: iced_core::Renderer
+ iced_core::image::Renderer<Handle = cosmic::widget::image::Handle>,
<Renderer as iced_core::image::Renderer>::Handle: 'a,
{
LoadedImage::new(handle.into(), content.into())
}
/// Forces the wrapped image to be loaded before drawing.
///
/// May cause a dropped frame if the image is not already in the cache.
/// This is useful when you want to ensure an image is loaded before it is drawn, for example when swapping out a placeholder.
/// Otherwise, the image may be blank until the next redraw.
#[allow(missing_debug_implementations)]
pub struct LoadedImage<'a, Message, Theme, Renderer>
where
Renderer: iced_core::Renderer + iced_core::image::Renderer,
{
handle: <Renderer as iced_core::image::Renderer>::Handle,
content: cosmic::iced::Element<'a, Message, Theme, Renderer>,
}
impl<'a, Message, Theme, Renderer> LoadedImage<'a, Message, Theme, Renderer>
where
Renderer: iced_core::Renderer + iced_core::image::Renderer,
<Renderer as iced_core::image::Renderer>::Handle: 'a,
{
/// Creates an empty [`LoadedImage`].
pub(crate) fn new(
handle: <Renderer as iced_core::image::Renderer>::Handle,
content: impl Into<cosmic::iced::Element<'a, Message, Theme, Renderer>>,
) -> Self {
LoadedImage {
handle,
content: content.into(),
}
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for LoadedImage<'_, Message, Theme, Renderer>
where
Renderer: iced_core::Renderer + iced_core::image::Renderer,
{
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> iced_core::Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let node =
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits);
let size = node.size();
layout::Node::with_children(size, vec![node])
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout
.children()
.next()
.expect("There should always be a child")
.with_virtual_offset(layout.virtual_offset()),
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout
.children()
.next()
.expect("There should always be a child")
.with_virtual_offset(layout.virtual_offset()),
cursor_position,
renderer,
clipboard,
shell,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let content_layout = layout
.children()
.next()
.expect("There should always be a child");
self.content.as_widget().mouse_interaction(
&tree.children[0],
content_layout.with_virtual_offset(layout.virtual_offset()),
cursor_position,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
viewport: &Rectangle,
) {
let content_layout = layout
.children()
.next()
.expect("There should always be a child");
// forces image to be loaded before drawing
_ = renderer.load_image(&self.handle);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
content_layout.with_virtual_offset(layout.virtual_offset()),
cursor_position,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout
.children()
.next()
.expect("There should always be a child")
.with_virtual_offset(layout.virtual_offset()),
renderer,
viewport,
translation,
)
}
fn drag_destinations(
&self,
state: &Tree,
layout: Layout<'_>,
renderer: &Renderer,
dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
) {
let content_layout = layout
.children()
.next()
.expect("There should always be a child");
self.content.as_widget().drag_destinations(
&state.children[0],
content_layout.with_virtual_offset(layout.virtual_offset()),
renderer,
dnd_rectangles,
);
}
}
#[allow(clippy::use_self)]
impl<'a, Message, Theme, Renderer> From<LoadedImage<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: 'a + iced_core::Renderer + iced_core::image::Renderer,
Theme: 'a,
{
fn from(
c: LoadedImage<'a, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Self::new(c)
}
}

View file

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

View file

@ -3,9 +3,9 @@ use cosmic::iced::advanced::renderer;
use cosmic::iced::advanced::widget::{self, Widget}; use cosmic::iced::advanced::widget::{self, Widget};
use cosmic::iced::border; use cosmic::iced::border;
use cosmic::iced::mouse; use cosmic::iced::mouse;
use cosmic::iced::wgpu::Primitive;
use cosmic::iced::wgpu::primitive::Renderer as PrimitiveRenderer;
use cosmic::iced::{Color, Element, Length, Rectangle, Size}; use cosmic::iced::{Color, Element, Length, Rectangle, Size};
use cosmic::iced_wgpu::Primitive;
use cosmic::iced_wgpu::primitive::Renderer as PrimitiveRenderer;
pub struct SlideText { pub struct SlideText {
_text: String, _text: String,

View file

@ -1,8 +1,13 @@
use cosmic::cosmic_theme::palette::WithAlpha; use cosmic::{
use cosmic::iced::widget::{column, row}; Element, Task,
use cosmic::iced::{Background, Border}; cosmic_theme::palette::WithAlpha,
use cosmic::widget::{button, combo_box, container, icon, space, text_editor}; iced::{Background, Border},
use cosmic::{Element, Task, theme}; iced_widget::{column, row},
theme,
widget::{
button, combo_box, container, icon, space, text_editor,
},
};
use crate::core::songs::VerseName; use crate::core::songs::VerseName;
@ -33,7 +38,6 @@ pub enum Action {
None, None,
} }
#[allow(clippy::cast_precision_loss)]
impl VerseEditor { impl VerseEditor {
#[must_use] #[must_use]
pub fn new(verse: VerseName, lyric: &str) -> Self { pub fn new(verse: VerseName, lyric: &str) -> Self {
@ -42,7 +46,9 @@ impl VerseEditor {
lyric: lyric.to_string(), lyric: lyric.to_string(),
content: text_editor::Content::with_text(lyric), content: text_editor::Content::with_text(lyric),
editing_verse_name: false, editing_verse_name: false,
verse_name_combo: combo_box::State::new(VerseName::all_names()), verse_name_combo: combo_box::State::new(
VerseName::all_names(),
),
} }
} }
pub fn update(&mut self, message: Message) -> Action { pub fn update(&mut self, message: Message) -> Action {
@ -68,7 +74,9 @@ impl VerseEditor {
Action::None Action::None
} }
}, },
Message::UpdateVerseName(verse_name) => Action::UpdateVerseName(verse_name), Message::UpdateVerseName(verse_name) => {
Action::UpdateVerseName(verse_name)
}
Message::EditVerseName => { Message::EditVerseName => {
self.editing_verse_name = !self.editing_verse_name; self.editing_verse_name = !self.editing_verse_name;
Action::None Action::None
@ -87,7 +95,9 @@ impl VerseEditor {
} = theme::spacing(); } = theme::spacing();
let delete_button = button::text("Delete") let delete_button = button::text("Delete")
.trailing_icon(icon::from_name("window-close-symbolic")) .trailing_icon(
icon::from_name("view-close").symbolic(true),
)
.class(theme::Button::Destructive) .class(theme::Button::Destructive)
.on_press(Message::DeleteVerse(self.verse_name)); .on_press(Message::DeleteVerse(self.verse_name));
let combo = combo_box( let combo = combo_box(
@ -97,43 +107,59 @@ impl VerseEditor {
Message::UpdateVerseName, Message::UpdateVerseName,
); );
let verse_title = row![combo, space::horizontal(), delete_button]; let verse_title =
row![combo, space::horizontal(), delete_button];
let lyric: Element<Message> = if self.verse_name == VerseName::Blank { let lyric: Element<Message> = if self.verse_name
== VerseName::Blank
{
space::horizontal().into() space::horizontal().into()
} else { } else {
text_editor(&self.content) text_editor(&self.content)
.on_action(Message::UpdateLyric) .on_action(Message::UpdateLyric)
.padding(space_m) .padding(space_m)
.class(theme::iced::TextEditor::Custom(Box::new(move |t, s| { .class(theme::iced::TextEditor::Custom(Box::new(
let neutral = t.cosmic().palette.neutral_9; move |t, s| {
let mut base_style = text_editor::Style { let neutral = t.cosmic().palette.neutral_9;
background: Background::Color( let mut base_style = text_editor::Style {
t.cosmic().background.small_widget.with_alpha(0.25).into(), background: Background::Color(
), t.cosmic()
border: Border::default() .background
.small_widget
.with_alpha(0.25)
.into(),
),
border: Border::default()
.rounded(space_s as u8)
.width(2)
.color(
t.cosmic().bg_component_divider(),
),
placeholder: neutral
.with_alpha(0.7)
.into(),
value: neutral.into(),
selection: t.cosmic().accent.base.into(),
};
let hovered_border = Border::default()
.rounded(space_s as u8) .rounded(space_s as u8)
.width(2) .width(3)
.color(t.cosmic().bg_component_divider()), .color(t.cosmic().accent.hover);
placeholder: neutral.with_alpha(0.7).into(), match s {
value: neutral.into(), text_editor::Status::Active => base_style,
selection: t.cosmic().accent.base.into(), text_editor::Status::Hovered
}; | text_editor::Status::Focused {
let hovered_border = Border::default() ..
.rounded(space_s as u8) } => {
.width(3) base_style.border = hovered_border;
.color(t.cosmic().accent.hover); base_style
match s { }
text_editor::Status::Hovered text_editor::Status::Disabled => {
| text_editor::Status::Focused { .. } => { base_style
base_style.border = hovered_border; }
base_style
} }
text_editor::Status::Active | text_editor::Status::Disabled => { },
base_style )))
}
}
})))
.height(150) .height(150)
.into() .into()
}; };

BIN
test.db

Binary file not shown.

View file

@ -1,77 +0,0 @@
app-id: xyz.cochrun.lumina
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-nightly
- org.freedesktop.Sdk.Extension.llvm22
base: com.system76.Cosmic.BaseApp
command: lumina
add-extensions:
org.freedesktop.Platform.ffmpeg-full:
version: '25.08' # replace by appropriate version
directory: lib/ffmpeg
add-ld-path: .
org.freedesktop.Platform.GStreamer:
version: '25.08'
add-ld-path: .
finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
- --share=network
- --talk-name=com.system76.CosmicSettingsDaemon
- --talk-name=com.system76.CosmicSettingsDaemon.*
- --filesystem=home:rw
- --filesystem=xdg-config/cosmic:rw
- --filesystem=xdg-data/lumina:rw
- --filesystem=xdg-cache/lumina:rw
build-options:
append-path: /usr/lib/sdk/rust-nightly/bin:/usr/lib/sdk/llvm22/bin
# append-path: /usr/lib/sdk/llvm22/bin
prepend-ld-library-path: /usr/lib/sdk/llvm22/lib
env:
CARGO_HOME: /run/build/lumina/cargo
LIBCLANG_PATH: /usr/lib/sdk/llvm22/lib
BINDGEN_EXTRA_CLANG_ARGS: -I/usr/lib/sdk/llvm22/lib/clang/22/include
PKG_CONFIG_PATH: /app/lib64/pkgconfig:/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
# CC: gcc
# CXX: g++
# CARGO_NET_OFFLINE: 'true'
modules:
# - name: mupdf-rs
# buildsystem: simple
# build-commands:
# - cargo build --offline --release
# sources:
# - type: git
# url: https://github.com/messense/mupdf-rs.git
# commit: 902d44bf7becfc84f8349c524cb8acfb18a6f3d4
# - mupdf-cargo-sources.json
- name: lumina
buildsystem: simple
build-options:
env:
CARGO_HOME: /run/build/lumina/cargo
DATABASE_URL: sqlite://./test.db
# CARGO_NET_OFFLINE: 'true'
build-commands:
- just build-offline
# - cargo build --release
- install -Dm755 target/release/lumina -t /app/bin
- install -Dm644 res/icons/lumina.svg -t /app/share/icons/hicolor/scalable/apps
- install -Dm644 res/${FLATPAK_ID}.desktop -t /app/share/applications
- install -Dm644 res/${FLATPAK_ID}.metainfo.xml -t /app/share/metainfo
sources:
# - type: git
# url: https://git.tfcconnection.org/chris/lumina.git
# commit: 53d1ad6163634658a274293d3a2ca7a69a4d9421
- type: dir
path: .
- cargo-sources.json