diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..6a1b75e --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1 @@ +experimental = [ "benchmarks" ] \ No newline at end of file diff --git a/.envrc b/.envrc index e6b394d..ce64c4d 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -DATABASE_URL="sqlite:///home/chris/.local/share/lumina/library-db.sqlite3" +DATABASE_URL="sqlite://./test.db" use flake . # eval $(guix shell -D --search-paths) diff --git a/.forgejo/workflows/demo.yaml b/.forgejo/workflows/demo.yaml deleted file mode 100644 index 7657407..0000000 --- a/.forgejo/workflows/demo.yaml +++ /dev/null @@ -1,18 +0,0 @@ -on: [push] -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v3 - - - run: | - apt update - apt install sudo - apt install just - - - uses: https://github.com/cachix/install-nix-action@v27 - with: - nix_path: nixpkgs=channel:nixos-unstable - - - run: nix develop --command just test diff --git a/.forgejo/workflows/lints.yaml b/.forgejo/workflows/lints.yaml new file mode 100644 index 0000000..3e08132 --- /dev/null +++ b/.forgejo/workflows/lints.yaml @@ -0,0 +1,9 @@ +on: [push] +jobs: + clippy: + runs-on: nixos-latest + steps: + - run: nix-env --install nodejs + - name: checkout + uses: actions/checkout@v4 + - run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop --command cargo clippy -- -D clippy::pedantic -D clippy::perf -D clippy::nursery -D clippy::unwrap_used diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..5aa92e6 --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,9 @@ +on: [push] +jobs: + test: + runs-on: nixos-latest + steps: + - run: nix-env --install nodejs + - name: checkout + uses: actions/checkout@v4 + - run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop --command just ci-test diff --git a/.gitignore b/.gitignore index 41e108c..8181bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ data.db /perf.data /perf.data.old .aider* + +test.db-shm +test.db-wal +test.lum +test.pres +profile.json.gz \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 976a902..514c9e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4488,6 +4488,7 @@ dependencies = [ "ron 0.8.1", "scraper", "serde", + "serde_json", "sqlx", "strum", "strum_macros", @@ -6988,16 +6989,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "indexmap 2.12.1", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -10204,6 +10205,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 4606ba5..09ada1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ derive_more = { version = "2.1.1", features = ["debug"] } reqwest = "0.13.1" scraper = "0.25.0" itertools = "0.14.0" +serde_json = "1.0.149" # rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] } diff --git a/TODO.org b/TODO.org index c227af9..faa2969 100644 --- a/TODO.org +++ b/TODO.org @@ -1,33 +1,34 @@ #+TITLE: The Task list for Lumina -* TODO [#A] Need to fix tests now that the basic app is working -Lots of them have been tweaked to be completing now, but there is more work to do and several need to likely be a lot more robust. - -Still failing 4 tests, all to do with the db or lisp. I might throw out the lisp code at some point tho. I keep thinking that a better alternative would be to have a markdown serialization system such that you can write slides in markdown somehow and they would be able to be loaded. - -* TODO [#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. - -* TODO [#A] Make sure updating verse updates the lyrics too -[[file:~/dev/lumina-iced/src/core/songs.rs::old_verse = verse;]] - -This is necessary so that the entire song gets changed and we can propogate those changes then back to the db. - -There is likely some work that still needs to be done here, I believe I am somehow deleting some of my verses. * TODO [#A] Add Action system This will be based on each slide having the ability to activate an action (i.e. OBS scene switch, OBS start or stop) when it is active. This is working but the right click context menu is all the way on the edge of the ui so you can't control all the slides. It also needs a lot of help in making the system more robust and potentially lest reliant on the Presenter struct itself. +* TODO [#B] Font in the song editor doesn't always use the original version +There seems to be some issue with fontdb not able to decipher all the versions of some fonts that are OTF and then end up loading the wrong ones in some issues. + +* TODO [#B] Find a way to use auth-token in tests for ci +If I can find out how to use my secrets in ci that would free up more tests, but I could also just turn that test off for the CI so that it won't constantly fail for now +* TODO [#C] Rename menu actions to menu commands and build a reverse hashmap for settings to map commands to key-binding such that we can allow for remapping them on the fly. + +* TODO [#B] Saving and loading font awareness +Someday we should make the saving and loading to be aware of the fonts on the system and find a way to embed them into the save file. + * TODO [#B] Develop ui for settings * TODO [#B] Develop library system for slides that are more than images or video i.e. content -* TODO [#B] Functions for text alignments -This will need to be matched on for the =TextAlignment= from the user +* TODO [#C] Use orgize as a file parser and allow for orgdown files to represent a presentation. +Orgize has some very nice features that will let me determine what things are in an orgdown file and thus take said file and turn it into a presentation. + +After looking more and more at how the orgize docs describe things and the testing platform found at: https://poiscript.github.io/orgize/ I believe this will work. The main things are that I can possibly decide how to interpret certain pieces of orgdown to mean certain things in lumina. Essentially a properties drawer or tag can indicate backgrounds and other info for the slides or songs and then the notes blocks can indicate text that shouldn't be printed into the slide, thus allowing a single orgdown document to illustrate both an entire presentation, but also the notes and plan for the presenter. + +I could potentially do the same with markdown, but since this is for me first, I'll use orgdown because I enjoy the syntax a lot more. * TODO [#C] Allow for a way to split the presentation up with a right click menu for the presentation preview row. + * TODO [#C] Text could be built by using SVG instead of the text element. Maybe I could construct my own text element even This does almost work. There is a clear amount of lag or rather hang up since switching to the =text_svg= element. I think I may only keep it till I can figure out how to do strokes and shadows in iced's normal text element. @@ -46,8 +47,10 @@ I tried out a way of generating the svg and rasterizing it ahead of time and the The problem with this approach is that every change to a song's text or font metrics means we need to rebuild all the text items for that song. I need to think of a way for the text generation to be done asynchronously so that the ui isn't locked up. +I bet this is tricking up the loading mechanism. Loading only grabs all the backgrounds and audio pieces, not the text_svg pieces. So maybe it should so that the generator can run again and grab the same pieces from the filesystem rather than recreate them. This gets extra tricky because we may have fonts that are missing when loading a file. In such a case the loading mechanism ought to suggest to the user to grab those fonts and then perhaps load the cached file while being extra clear that any changes will mess up the text since they no longer possess the font that is in the loaded file. Maybe what we can do is during save, save a copy of all the fonts as well and then during load check to see if the computer has them, if they don't offer to install them on the spot such that they can use the font as is. I wonder if we are allowed to pass fonts around that way. + ** Made this slightly faster -Since strings are allocated on the heap, I've changed how to construct the svg string a bit, but honestly, it doesn't matter too much because most of the performance cost seems to be in rendering the string using resvg. So, this can still be something that get's fixed later +Since strings are allocated on the heap, I've changed how to construct the svg string a bit, but honestly, it doesn't matter too much because most of the performance cost seems to be in rendering the string using resvg. So, this can still be something that get's fixed later, and I believe that fix will come in the form of a multi-channel signed distance field wgpu rendered text eventually. We can work on this much later though. * TODO [#C] Make the presenter more modular so things are easier to change. This is vague... @@ -60,6 +63,14 @@ This is limited by the fact that I need to develop this in cosmic. I am honestly This needs lots more attention +* DONE [#A] File saving and loading +Need to make sure we can save a file with all files archived in it and load it back up. + +This is giving me a lot of thoughts... +1. That saving and loading needs to know about fonts as well. +2. That TextSvgs should likely be saved as well since the other machines may not always have the same fonts. +3. That means that TextSvg should have a path option that could hold the cached svg that has already been rendered and that this gets changed to the loaded files directory rather than using the default cache directory. + * DONE [#A] Add removal and reordering of service_items Reordering is finished * DONE [#A] Change return type of all components to an Action enum instead of the Task type [0%] [0/0] @@ -67,6 +78,23 @@ Reordering is finished ** DONE SongEditor ** DONE Presenter +* DONE [#A] Need to fix tests now that the basic app is working +Lots of them have been tweaked to be completing now, but there is more work to do and several need to likely be a lot more robust. + +Still failing 4 tests, all to do with the db or lisp. I might throw out the lisp code at some point tho. I keep thinking that a better alternative would be to have a markdown serialization system such that you can write slides in markdown somehow and they would be able to be loaded. + +* DONE [#A] Make sure updating verse updates the lyrics too +[[file:~/dev/lumina-iced/src/core/songs.rs::old_verse = verse;]] + +This is necessary so that the entire song gets changed and we can propogate those changes then back to the db. + +There is likely some work that still needs to be done here, I believe I am somehow deleting some of my verses. +* DONE [#A] Need to fixup how songs are edited in the editors +Currently the song is cloned many times to pass around and then finally get updated in DB. Instead, we need to edit the song directly in the editor and after it's been changed appropriatel, run the update_song method to get the current song and create slides from it and then update it in the DB. + +* DONE [#B] Functions for text alignments +This will need to be matched on for the =TextAlignment= from the user + * DONE Move text_generation function to be asynchronous so that UI doesn't lock up during song editing. * DONE Build a presentation editor diff --git a/flake.lock b/flake.lock index f4c6489..aa3d695 100644 --- a/flake.lock +++ b/flake.lock @@ -1,31 +1,16 @@ { "nodes": { - "crane": { - "locked": { - "lastModified": 1770419512, - "narHash": "sha256-o8Vcdz6B6bkiGUYkZqFwH3Pv1JwZyXht3dMtS7RchIo=", - "owner": "ipetkov", - "repo": "crane", - "rev": "2510f2cbc3ccd237f700bb213756a8f35c32d8d7", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, "fenix": { "inputs": { "nixpkgs": "nixpkgs", "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1765435813, - "narHash": "sha256-C6tT7K1Lx6VsYw1BY5S3OavtapUvEnDQtmQB5DSgbCc=", + "lastModified": 1770794449, + "narHash": "sha256-1nFkhcZx9+Sdw5OXwJqp5TxvGncqRqLeK781v0XV3WI=", "owner": "nix-community", "repo": "fenix", - "rev": "6399553b7a300c77e7f07342904eb696a5b6bf9d", + "rev": "b19d93fdf9761e6101f8cb5765d638bacebd9a1b", "type": "github" }, "original": { @@ -80,11 +65,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1763384566, - "narHash": "sha256-r+wgI+WvNaSdxQmqaM58lVNvJYJ16zoq+tKN20cLst4=", + "lastModified": 1769799857, + "narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=", "owner": "nix-community", "repo": "naersk", - "rev": "d4155d6ebb70fbe2314959842f744aa7cabbbf6a", + "rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339", "type": "github" }, "original": { @@ -95,11 +80,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1765186076, - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", + "lastModified": 1770562336, + "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "owner": "nixos", "repo": "nixpkgs", - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", + "rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "type": "github" }, "original": { @@ -127,11 +112,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1765472234, - "narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=", + "lastModified": 1770562336, + "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "owner": "nixos", "repo": "nixpkgs", - "rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b", + "rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "type": "github" }, "original": { @@ -141,23 +126,39 @@ "type": "github" } }, + "nixpkgs_4": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { - "crane": "crane", "fenix": "fenix", "flake-utils": "flake-utils", "naersk": "naersk", - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_3", + "rust-overlay": "rust-overlay" } }, "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1765400135, - "narHash": "sha256-D3+4hfNwUhG0fdCpDhOASLwEQ1jKuHi4mV72up4kLQM=", + "lastModified": 1770702974, + "narHash": "sha256-CbvWu72rpGHK5QynoXwuOnVzxX7njF2LYgk8wRSiAQ0=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "fface27171988b3d605ef45cf986c25533116f7e", + "rev": "07a594815f7c1d6e7e39f21ddeeedb75b21795f4", "type": "github" }, "original": { @@ -184,6 +185,24 @@ "type": "github" } }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1770779462, + "narHash": "sha256-ykcXTKtV+dOaKlOidAj6dpewBHjni9/oy/6VKcqfzfY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8a53b3ade61914cdb10387db991b90a3a6f3c441", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index 0f022c9..396ab59 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ naersk.url = "github:nix-community/naersk"; flake-utils.url = "github:numtide/flake-utils"; fenix.url = "github:nix-community/fenix"; - crane.url = "github:ipetkov/crane"; + rust-overlay.url = "github:oxalica/rust-overlay"; }; outputs = @@ -15,46 +15,43 @@ flake-utils.lib.eachDefaultSystem ( system: let + overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { - inherit system; - overlays = [ fenix.overlays.default ]; + inherit system overlays; + # overlays = [ rust-overlay.overlays.default ]; # overlays = [cargo2nix.overlays.default]; }; - inherit (pkgs) lib; - craneLib = (crane.mkLib pkgs).overrideToolchain fenix.packages.${system}.stable.toolchain; naersk' = pkgs.callPackage naersk { }; - 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 - ]; - }; + # toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]); + - nbi = with pkgs; [ + nativeBuildInputs = with pkgs; [ # Rust tools - alejandra - (pkgs.fenix.stable.withComponents [ - "cargo" - "clippy" - "rust-src" - "rustc" - "rustfmt" - ]) + # toolchain + # (pkgs.fenix.default.withComponents [ + # "cargo" + # "clippy" + # "rust-std" + # # "rust-src" + # "rustc" + # "rustfmt" + # ]) + (rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" ]; + })) cargo-nextest - rust-analyzer + cargo-criterion + # rust-analyzer-nightly vulkan-loader wayland wayland-protocols + libxkbcommon pkg-config sccache ]; - bi = with pkgs; [ + buildInputs = with pkgs; [ gcc stdenv gnumake @@ -63,12 +60,13 @@ cmake clang libclang - libxkbcommon makeWrapper vulkan-headers vulkan-loader vulkan-tools libGL + cargo-flamegraph + bacon fontconfig glib @@ -85,78 +83,57 @@ mupdf # yt-dlp - ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ - # Additional darwin specific inputs can be set here - pkgs.libiconv + just + sqlx-cli + cargo-watch + samply ]; - ldLibPaths = "$LD_LIBRARY_PATH:${ + LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${ with pkgs; - lib.makeLibraryPath [ - alsa-lib - gst_all_1.gst-libav - gst_all_1.gstreamer - gst_all_1.gst-plugins-bad - gst_all_1.gst-plugins-good - gst_all_1.gst-plugins-ugly - gst_all_1.gst-plugins-base - gst_all_1.gst-plugins-rs - gst_all_1.gst-vaapi - glib - fontconfig - vulkan-loader - wayland - wayland-protocols - libxkbcommon - mupdf - libclang + pkgs.lib.makeLibraryPath [ + pkgs.alsa-lib + pkgs.gst_all_1.gst-libav + pkgs.gst_all_1.gstreamer + pkgs.gst_all_1.gst-plugins-bad + pkgs.gst_all_1.gst-plugins-good + pkgs.gst_all_1.gst-plugins-ugly + pkgs.gst_all_1.gst-plugins-base + pkgs.gst_all_1.gst-plugins-rs + pkgs.gst_all_1.gst-vaapi + pkgs.glib + pkgs.fontconfig + pkgs.vulkan-loader + pkgs.wayland + pkgs.wayland-protocols + pkgs.libxkbcommon + pkgs.mupdf + pkgs.libclang ] }"; - - commonArgs = { - inherit src; - strictDeps = true; - nativeBuildInputs = nbi; - buildInputs = bi; - LD_LIBRARY_PATH = ldLibPaths; - }; - - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - lumina = craneLib.buildPackage ( - commonArgs - // { - inherit cargoArtifacts; - - nativeBuildInputs = (commonArgs.nativeBuildInputs or [ ]) ++ [ - pkgs.sqlx-cli - ]; - - preBuild = '' - export DATABASE_URL=sqlite:./db.sqlite3 - sqlx database create - sqlx migrate run - ''; - } - ); - in rec { - checks = { - inherit lumina; + devShell = + pkgs.mkShell.override + { + # stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv; + } + { + inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH; + # LIBCLANG_PATH = "${pkgs.clang}"; + DATABASE_URL = "sqlite://./test.db"; + # RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library"; + }; + defaultPackage = naersk'.buildPackage { + inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH; + src = ./.; }; - devShells.default = craneLib.devShell { - checks = self.checks.${system}; - inputsFrom = [ lumina ]; - packages = with pkgs; [ - sqlx-cli - cargo-flamegraph - bacon - just - cargo-watch - ]; + packages = { + default = naersk'.buildPackage { + inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH; + src = ./.; + }; }; - packages.default = lumina; } ); } diff --git a/justfile b/justfile index 409e5e3..b87ae1e 100644 --- a/justfile +++ b/justfile @@ -20,9 +20,14 @@ run-file: clean: cargo clean test: - cargo test --benches --tests --all-features -- --nocapture + cargo nextest run +ci-test: + cargo nextest run -- --skip test_db_and_model --skip test_update --skip test_song_slide_speed --skip test_song_to_slide --skip test_song_from_db +bench: + export NEXTEST_EXPERIMENTAL_BENCHMARKS=1 + cargo nextest bench profile: - cargo flamegraph -- {{verbose}} {{ui}} + samply record cargo run --release -- {{verbose}} {{ui}} alias b := build alias r := run diff --git a/migrations/20260211174933_add_stroke_and_shadow.sql b/migrations/20260211174933_add_stroke_and_shadow.sql new file mode 100644 index 0000000..ca5955e --- /dev/null +++ b/migrations/20260211174933_add_stroke_and_shadow.sql @@ -0,0 +1,19 @@ +-- Add migration script here +ALTER TABLE songs +ADD COLUMN stroke_size INTEGER; + +ALTER TABLE songs +ADD COLUMN stroke_color TEXT; + +ALTER TABLE songs +ADD COLUMN shadow_size INTEGER; + +ALTER TABLE songs +ADD COLUMN shadow_offset_x INTEGER; + +ALTER TABLE songs +ADD COLUMN shadow_offset_y INTEGER; + +ALTER TABLE songs +ADD COLUMN shadow_color TEXT; + diff --git a/migrations/20260217193356_add_text_weight_and_style.sql b/migrations/20260217193356_add_text_weight_and_style.sql new file mode 100644 index 0000000..ce3f306 --- /dev/null +++ b/migrations/20260217193356_add_text_weight_and_style.sql @@ -0,0 +1,6 @@ +-- Add migration script here +ALTER TABLE songs +ADD COLUMN weight TEXT; + +ALTER TABLE songs +ADD COLUMN style TEXT; diff --git a/res/bigbuckbunny.mp4 b/res/bigbuckbunny.mp4 new file mode 100644 index 0000000..210600b Binary files /dev/null and b/res/bigbuckbunny.mp4 differ diff --git a/res/nerdfont.ttf b/res/nerdfont.ttf new file mode 100644 index 0000000..38c24fb Binary files /dev/null and b/res/nerdfont.ttf differ diff --git a/res/shadow.svg b/res/shadow.svg new file mode 100644 index 0000000..6203343 --- /dev/null +++ b/res/shadow.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/text-shadow.svg b/res/text-shadow.svg new file mode 100644 index 0000000..c55317c --- /dev/null +++ b/res/text-shadow.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/core/file.rs b/src/core/file.rs index 4b93d14..c0feefe 100644 --- a/src/core/file.rs +++ b/src/core/file.rs @@ -2,28 +2,40 @@ use crate::core::{ kinds::ServiceItemKind, service_items::ServiceItem, slide::Background, }; -use miette::{IntoDiagnostic, Result}; +use cosmic::widget::image::Handle; +use miette::{IntoDiagnostic, Result, miette}; use std::{ fs::{self, File}, io::Write, iter, path::{Path, PathBuf}, }; -use tar::Builder; -use tracing::error; -use zstd::Encoder; +use tar::{Archive, Builder}; +use tracing::{debug, error}; +use zstd::{Decoder, Encoder}; -pub async fn save( +#[allow(clippy::too_many_lines)] +pub fn save( list: Vec, path: impl AsRef, + overwrite: bool, ) -> Result<()> { let path = path.as_ref(); + if overwrite && path.exists() { + fs::remove_file(path).into_diagnostic()?; + } let save_file = File::create(path).into_diagnostic()?; - let ron = process_service_items(&list)?; + let ron_pretty = ron::ser::PrettyConfig::default(); + let ron = ron::ser::to_string_pretty(&list, ron_pretty) + .into_diagnostic()?; - let encoder = Encoder::new(save_file, 3).unwrap(); + let encoder = Encoder::new(save_file, 3) + .expect("file encoder shouldn't fail") + .auto_finish(); let mut tar = Builder::new(encoder); - let mut temp_dir = dirs::data_dir().unwrap(); + let mut temp_dir = dirs::data_dir().expect( + "there should be a data directory, ~/.local/share/ for linux, but couldn't find it", + ); temp_dir.push("lumina"); let mut s: String = iter::repeat_with(fastrand::alphanumeric).take(5).collect(); @@ -31,6 +43,7 @@ pub async fn save( temp_dir.push(s); fs::create_dir_all(&temp_dir).into_diagnostic()?; let service_file = temp_dir.join("serviceitems.ron"); + debug!(?service_file); fs::File::create(&service_file).into_diagnostic()?; match fs::File::options() .read(true) @@ -38,19 +51,40 @@ pub async fn save( .open(service_file) { Ok(mut f) => { - f.write(ron.as_bytes()).into_diagnostic()?; + match f.write(ron.as_bytes()) { + Ok(size) => { + debug!(size); + } + Err(e) => { + error!(?e); + return Err(miette!("PROBS: {e}")); + } + } + match tar.append_file("serviceitems.ron", &mut f) { + Ok(()) => { + debug!( + "should have added serviceitems.ron to the file" + ); + } + Err(e) => { + error!(?e); + return Err(miette!("PROBS: {e}")); + } + } } Err(e) => { error!("There were problems making a file i guess: {e}"); + return Err(miette!("There was a problem: {e}")); } } - // let list list.iter_mut().map(|item| { - // match item.kind { - // ServiceItemKind::Song(mut song) => { - // song.background - // } - // } - // }).collect(); + + let mut append_file = |path: PathBuf| -> Result<()> { + let file_name = path.file_name().unwrap_or_default(); + let mut file = fs::File::open(&path).into_diagnostic()?; + tar.append_file(file_name, &mut file).into_diagnostic()?; + Ok(()) + }; + for item in list { let background; let audio: Option; @@ -84,323 +118,417 @@ pub async fn save( todo!() } } - if let Some(file) = audio { - let audio_file = - temp_dir.join(file.file_name().expect( - "Audio file couldn't be added to temp_dir", - )); - if let Ok(file) = file.strip_prefix("file://") { - fs::File::create(&audio_file).into_diagnostic()?; - fs::copy(file, audio_file).into_diagnostic()?; - } else { - fs::File::create(&audio_file).into_diagnostic()?; - fs::copy(file, audio_file).into_diagnostic()?; - } + if let Some(path) = audio + && path.exists() + { + debug!(?path); + append_file(path)?; } - if let Some(file) = background { - let background_file = - temp_dir.join(file.path.file_name().expect( - "Background file couldn't be added to temp_dir", - )); - if let Ok(file) = file.path.strip_prefix("file://") { - fs::File::create(&background_file) - .into_diagnostic()?; - fs::copy(file, background_file).into_diagnostic()?; - } else { - fs::File::create(&background_file) - .into_diagnostic()?; - fs::copy(file.path, background_file) - .into_diagnostic()?; + if let Some(background) = background + && let path = background.path + && path.exists() + { + debug!(?path); + append_file(path)?; + } + for slide in item.slides { + if let Some(svg) = slide.text_svg + && let Some(path) = svg.path + { + append_file(path)?; } } } - tar.append_dir_all(path, temp_dir).into_diagnostic()?; - tar.finish().into_diagnostic() + + match tar.finish() { + Ok(()) => (), + Err(e) => { + error!(?e); + return Err(miette!("tar error: {e}")); + } + } + fs::remove_dir_all(temp_dir).into_diagnostic() } -async fn clear_temp_dir(_temp_dir: &Path) -> Result<()> { - todo!() -} +#[allow(clippy::too_many_lines)] +pub fn load(path: impl AsRef) -> Result> { + let decoder = + Decoder::new(fs::File::open(&path).into_diagnostic()?) + .into_diagnostic()?; + let mut tar = Archive::new(decoder); -fn process_service_items(items: &Vec) -> Result { - Ok(items - .iter() - .filter_map(|item| { - let ron = ron::ser::to_string(item); - ron.ok() + let mut cache_dir = + dirs::cache_dir().expect("Should be a cache dir"); + cache_dir.push("lumina"); + cache_dir.push("cached_save_files"); + + let save_name_ext = path + .as_ref() + .extension() + .expect("Should have extension") + .to_str() + .expect("Should be fine"); + let save_name_string = path + .as_ref() + .file_name() + .expect("Should be a name") + .to_os_string() + .into_string() + .expect("Should be fine"); + let save_name = save_name_string + .trim_end_matches(&format!(".{save_name_ext}")); + cache_dir.push(save_name); + + if let Err(e) = fs::remove_dir_all(&cache_dir) { + debug!("There is no dir here: {e}"); + } + fs::create_dir_all(&cache_dir).into_diagnostic()?; + + for entry in tar.entries().into_diagnostic()? { + let mut entry = entry.into_diagnostic()?; + entry.unpack_in(&cache_dir).into_diagnostic()?; + } + + let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?; + let ron_file = dir + .find_map(|file| { + if file.as_ref().ok()?.path().extension()?.to_str()? + == "ron" + { + Some(file.ok()?.path()) + } else { + None + } }) - .collect()) + .expect("Should have a ron file"); + + let ron_string = + fs::read_to_string(ron_file).into_diagnostic()?; + + let mut items = + ron::de::from_str::>(&ron_string) + .into_diagnostic()?; + + for item in &mut items { + let dir = fs::read_dir(&cache_dir).into_diagnostic()?; + for file in dir { + for slide in &mut item.slides { + if let Ok(file) = file.as_ref() { + let file_name = file.file_name(); + let audio_path = + slide.audio().clone().unwrap_or_default(); + let text_path = slide + .text_svg + .as_ref() + .and_then(|svg| svg.path.clone()); + if Some(file_name.as_os_str()) + == slide.background.path.file_name() + { + slide.background.path = file.path(); + } else if Some(file_name.as_os_str()) + == audio_path.file_name() + { + let new_slide = slide + .clone() + .set_audio(Some(file.path())); + *slide = new_slide; + } else if Some(file_name.as_os_str()) + == text_path + .clone() + .unwrap_or_default() + .file_name() + && let Some(svg) = slide.text_svg.as_mut() + { + svg.path = Some(file.path()); + svg.handle = + Some(Handle::from_path(file.path())); + } + } + } + + match &mut item.kind { + ServiceItemKind::Song(song) => { + if let Ok(file) = file.as_ref() { + let file_name = file.file_name(); + let audio_path = + song.audio.clone().unwrap_or_default(); + if Some(file_name.as_os_str()) + == song + .background + .clone() + .unwrap_or_default() + .path + .file_name() + { + let background = song.background.clone(); + song.background = + background.map(|mut background| { + background.path = file.path(); + background + }); + } else if Some(file_name.as_os_str()) + == audio_path.file_name() + { + song.audio = Some(file.path()); + } + } + } + ServiceItemKind::Video(video) => { + if let Ok(file) = file.as_ref() { + let file_name = file.file_name(); + if Some(file_name.as_os_str()) + == video.path.file_name() + { + video.path = file.path(); + } + } + } + ServiceItemKind::Image(image) => { + if let Ok(file) = file.as_ref() { + let file_name = file.file_name(); + if Some(file_name.as_os_str()) + == image.path.file_name() + { + image.path = file.path(); + } + } + } + ServiceItemKind::Presentation(presentation) => { + if let Ok(file) = file.as_ref() { + let file_name = file.file_name(); + if Some(file_name.as_os_str()) + == presentation.path.file_name() + { + presentation.path = file.path(); + } + } + } + ServiceItemKind::Content(_slide) => todo!(), + } + } + } + Ok(items) } -// async fn process_song( -// database_id: i32, -// db: &mut SqliteConnection, -// ) -> Result { -// let song = get_song_from_db(database_id, db).await?; -// let song_ron = ron::to_value(&song)?; -// let kind_ron = ron::to_value(ServiceItemKind::Song)?; -// let json = -// serde_json::json!({"item": song_json, "kind": kind_json}); -// Ok(json) -// } +#[cfg(test)] +mod test { + use rayon::iter::{IntoParallelIterator, ParallelIterator}; + use resvg::usvg::fontdb; -// async fn process_image( -// database_id: i32, -// db: &mut SqliteConnection, -// ) -> Result { -// let image = get_image_from_db(database_id, db).await?; -// let image_json = serde_json::to_value(&image)?; -// let kind_json = serde_json::to_value(ServiceItemKind::Image)?; -// let json = -// serde_json::json!({"item": image_json, "kind": kind_json}); -// Ok(json) -// } + use super::*; + use crate::{ + core::{ + service_items::ServiceTrait, + slide::{Slide, TextAlignment}, + songs::{Song, VerseName}, + }, + ui::text_svg::text_svg_generator, + }; + use std::{collections::HashMap, path::PathBuf, sync::Arc}; -// async fn process_video( -// database_id: i32, -// db: &mut SqliteConnection, -// ) -> Result { -// let video = get_video_from_db(database_id, db).await?; -// let video_json = serde_json::to_value(&video)?; -// let kind_json = serde_json::to_value(ServiceItemKind::Video)?; -// let json = -// serde_json::json!({"item": video_json, "kind": kind_json}); -// Ok(json) -// } + 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 verse_map: Option> = + ron::from_str(&lyrics).unwrap(); + Song { + id: 7, + title: "Death Was Arrested".to_string(), + lyrics: Some(lyrics), + author: Some( + "North Point Worship".to_string(), + ), + ccli: None, + 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()]), + background: Some(Background::try_from("/home/chris/nc/tfc/openlp/Flood/motions/Ocean_Floor_HD.mp4").unwrap()), + text_alignment: Some(TextAlignment::MiddleCenter), + font: None, + font_size: Some(120), + font_style: None, + font_weight: None, + text_color: None, + stroke_size: None, + verses: Some(vec![VerseName::Chorus { number: 1 }, VerseName::Intro { number: 1 }, VerseName::Other { number: 99 }, VerseName::Bridge { number: 1 }, VerseName::Verse { number: 4 }, VerseName::Verse { number: 2 }, VerseName::Verse { number: 3 }, VerseName::Verse { number: 1 } + ]), + verse_map, + ..Default::default() + } + } -// async fn process_presentation( -// database_id: i32, -// db: &mut SqliteConnection, -// ) -> Result { -// let presentation = -// get_presentation_from_db(database_id, db).await?; -// let presentation_json = serde_json::to_value(&presentation)?; -// let kind_json = match presentation.kind { -// PresKind::Html => serde_json::to_value( -// ServiceItemKind::Presentation(PresKind::Html), -// )?, -// PresKind::Pdf => serde_json::to_value( -// ServiceItemKind::Presentation(PresKind::Pdf), -// )?, -// PresKind::Generic => serde_json::to_value( -// ServiceItemKind::Presentation(PresKind::Generic), -// )?, -// }; -// let json = serde_json::json!({"item": presentation_json, "kind": kind_json}); -// Ok(json) -// } + fn get_items() -> Vec { + let song = test_song(); + let mut fontdb = fontdb::Database::new(); + fontdb.load_system_fonts(); + let fontdb = Arc::new(fontdb); + let slides = song + .to_slides() + .unwrap() + .into_par_iter() + .map(|slide| { + text_svg_generator( + slide.clone(), + &Arc::clone(&fontdb), + ) + .map_or_else( + |e| { + assert!(false, "Couldn't create svg: {e}"); + slide + }, + |slide| slide, + ) + }) + .collect::>(); + let items = vec![ + ServiceItem { + database_id: 7, + kind: ServiceItemKind::Song(song.clone()), + id: 0, + title: "Death was Arrested".into(), + slides: slides.clone(), + }, + ServiceItem { + database_id: 7, + kind: ServiceItemKind::Song(song), + id: 1, + title: "Death was Arrested".into(), + slides: slides, + }, + ]; + items + } -// #[cfg(test)] -// mod test { -// use std::path::PathBuf; + #[test] + fn test_load() -> Result<(), String> { + test_save(); + let path = PathBuf::from("./test.pres"); + let result = load(&path); + match result { + Ok(items) => { + assert!(items.len() > 0); + // assert_eq!(items, get_items()); + let cache_dir = cache_dir(); + assert!(fs::read_dir(&cache_dir).is_ok()); + assert!( + find_paths(&items), + "Some paths must not have the cache_dir in it's path" + ); + find_svgs(&items)?; + Ok(()) + } + Err(e) => Err(e.to_string()), + } + } -// use super::*; -// use fs::canonicalize; -// use pretty_assertions::assert_eq; -// use sqlx::Connection; -// use tracing::debug; + fn find_svgs(items: &Vec) -> Result<(), String> { + let cache_dir = cache_dir(); + items.iter().try_for_each(|item| { + if let ServiceItemKind::Song(..) = item.kind { + item.slides.iter().try_for_each(|slide| { + slide.text_svg.as_ref().map_or(Err(String::from("There is no TextSvg for this song")), |text_svg| { -// async fn get_db() -> SqliteConnection { -// let mut data = dirs::data_local_dir().unwrap(); -// data.push("lumina"); -// data.push("library-db.sqlite3"); -// let mut db_url = String::from("sqlite://"); -// db_url.push_str(data.to_str().unwrap()); -// SqliteConnection::connect(&db_url).await.expect("problems") -// } + if text_svg.handle.is_none() { + return Err(String::from("There is no handle in this song's TextSvg")); + }; -// #[tokio::test(flavor = "current_thread")] -// async fn test_process_song() { -// let mut db = get_db().await; -// let result = process_song(7, &mut db).await; -// let json_song_file = PathBuf::from("./test/test_song.json"); -// if let Ok(path) = canonicalize(json_song_file) { -// debug!(file = ?&path); -// if let Ok(s) = fs::read_to_string(path) { -// debug!(s); -// match result { -// Ok(json) => assert_eq!(json.to_string(), s), -// Err(e) => panic!( -// "There was an error in processing the song: {e}" -// ), -// } -// } else { -// panic!("String wasn't read from file"); -// } -// } else { -// panic!("Cannot find absolute path to test_song.json"); -// } -// } + text_svg.path.as_ref().map_or(Err(String::from("There is no path in this song's TextSvg")), |path| { + if path.exists() { + let mut path = path.clone(); + if path.metadata().unwrap().len() < 20000 { + return Err(String::from("SVG text is too small, maybe the svg didn't generate properly")) + } + if path.pop() && path == cache_dir { + Ok(()) + } else { + Err(String::from("The path of the TextSvg isn't in the load directory")) + } + } else { + Err(String::from("The path in this TextSvg doesn't exist")) + } + }) + }) + }) + } else { + Ok(()) + } + }) + } -// #[tokio::test(flavor = "current_thread")] -// async fn test_process_image() { -// let mut db = get_db().await; -// let result = process_image(3, &mut db).await; -// let json_image_file = PathBuf::from("./test/test_image.json"); -// if let Ok(path) = canonicalize(json_image_file) { -// debug!(file = ?&path); -// if let Ok(s) = fs::read_to_string(path) { -// debug!(s); -// match result { -// Ok(json) => assert_eq!(json.to_string(), s), -// Err(e) => panic!( -// "There was an error in processing the image: {e}" -// ), -// } -// } else { -// panic!("String wasn't read from file"); -// } -// } else { -// panic!("Cannot find absolute path to test_image.json"); -// } -// } + // checks to make sure all paths in slides and items point to cache_dir + fn find_paths(items: &Vec) -> bool { + let cache_dir = cache_dir(); + items.iter().all(|item| { + match &item.kind { + ServiceItemKind::Song(song) => { + if let Some(bg) = &song.background { + if !bg.path.starts_with(&cache_dir) { + return false; + } + } + if let Some(audio) = &song.audio { + if !audio.starts_with(&cache_dir) { + return false; + } + } + } + ServiceItemKind::Video(video) => { + if !video.path.starts_with(&cache_dir) { + return false; + } + } + ServiceItemKind::Image(image) => { + if !image.path.starts_with(&cache_dir) { + return false; + } + } + ServiceItemKind::Presentation(presentation) => { + if !presentation.path.starts_with(&cache_dir) { + return false; + } + } + ServiceItemKind::Content(_slide) => todo!(), + } + for slide in &item.slides { + if !slide.background().path.starts_with(&cache_dir) { + return false; + } + if !slide.audio().map_or(true, |audio| { + audio.starts_with(&cache_dir) + }) { + return false; + } + } + true + }) + } -// #[tokio::test(flavor = "current_thread")] -// async fn test_process_video() { -// let mut db = get_db().await; -// let result = process_video(73, &mut db).await; -// let json_video_file = PathBuf::from("./test/test_video.json"); -// if let Ok(path) = canonicalize(json_video_file) { -// debug!(file = ?&path); -// if let Ok(s) = fs::read_to_string(path) { -// debug!(s); -// match result { -// Ok(json) => assert_eq!(json.to_string(), s), -// Err(e) => panic!( -// "There was an error in processing the video: {e}" -// ), -// } -// } else { -// panic!("String wasn't read from file"); -// } -// } else { -// panic!("Cannot find absolute path to test_video.json"); -// } -// } + fn cache_dir() -> PathBuf { + let mut cache_dir = dirs::cache_dir().unwrap(); + cache_dir.push("lumina"); + cache_dir.push("cached_save_files"); + cache_dir.push("test"); + cache_dir + } -// #[tokio::test(flavor = "current_thread")] -// async fn test_process_presentation() { -// let mut db = get_db().await; -// let result = process_presentation(54, &mut db).await; -// let json_presentation_file = -// PathBuf::from("./test/test_presentation.json"); -// if let Ok(path) = canonicalize(json_presentation_file) { -// debug!(file = ?&path); -// if let Ok(s) = fs::read_to_string(path) { -// debug!(s); -// match result { -// Ok(json) => assert_eq!(json.to_string(), s), -// Err(e) => panic!( -// "There was an error in processing the presentation: {e}" -// ), -// } -// } else { -// panic!("String wasn't read from file"); -// } -// } else { -// panic!( -// "Cannot find absolute path to test_presentation.json" -// ); -// } -// } - -// fn get_items() -> Vec { -// let items = vec![ -// ServiceItem { -// database_id: 7, -// kind: ServiceItemKind::Song, -// id: 0, -// }, -// ServiceItem { -// database_id: 54, -// kind: ServiceItemKind::Presentation(PresKind::Html), -// id: 0, -// }, -// ServiceItem { -// database_id: 73, -// kind: ServiceItemKind::Video, -// id: 0, -// }, -// ]; -// items -// } - -// #[tokio::test] -// async fn test_service_items() { -// let mut db = get_db().await; -// let items = get_items(); -// let json_item_file = -// PathBuf::from("./test/test_service_items.json"); -// let result = process_service_items(&items, &mut db).await; -// if let Ok(path) = canonicalize(json_item_file) { -// if let Ok(s) = fs::read_to_string(path) { -// match result { -// Ok(strings) => assert_eq!(strings.to_string(), s), -// Err(e) => panic!("There was an error: {e}"), -// } -// } -// } -// } - -// // #[tokio::test] -// // async fn test_save() { -// // let path = PathBuf::from("~/dev/lumina/src/rust/core/test.pres"); -// // let list = get_items(); -// // match save(list, path).await { -// // Ok(_) => assert!(true), -// // Err(e) => panic!("There was an error: {e}"), -// // } -// // } - -// #[tokio::test] -// async fn test_store() { -// let path = PathBuf::from( -// "/home/chris/dev/lumina/src/rust/core/test.pres", -// ); -// let save_file = match File::create(path) { -// Ok(f) => f, -// Err(e) => panic!("Couldn't create save_file: {e}"), -// }; -// let mut db = get_db().await; -// let list = get_items(); -// if let Ok(json) = process_service_items(&list, &mut db).await -// { -// println!("{:?}", json); -// match store_service_items( -// &list, &mut db, &save_file, &json, -// ) -// .await -// { -// Ok(_) => assert!(true), -// Err(e) => panic!("There was an error: {e}"), -// } -// } else { -// panic!("There was an error getting the json value"); -// } -// } - -// // #[tokio::test] -// // async fn test_things() { -// // let mut temp_dir = dirs::data_dir().unwrap(); -// // temp_dir.push("lumina"); -// // let mut s: String = -// // iter::repeat_with(fastrand::alphanumeric) -// // .take(5) -// // .collect(); -// // s.insert_str(0, "temp_"); -// // temp_dir.push(s); -// // let _ = fs::create_dir_all(&temp_dir); -// // let mut db = get_db().await; -// // let service_file = temp_dir.join("serviceitems.json"); -// // let list = get_items(); -// // if let Ok(json) = process_service_items(&list, &mut db).await { -// // let _ = fs::File::create(&service_file); -// // match fs::write(service_file, json.to_string()) { -// // Ok(_) => assert!(true), -// // Err(e) => panic!("There was an error: {e}"), -// // } -// // } else { -// // panic!("There was an error getting the json value"); -// // } -// // } -// } + #[test] + fn test_save() { + let path = PathBuf::from("./test.pres"); + let list = get_items(); + match save(list, &path, true) { + Ok(_) => { + assert!(path.is_file()); + let Ok(file) = fs::File::open(path) else { + return assert!(false, "couldn't open file"); + }; + let Ok(size) = file.metadata().map(|data| data.len()) + else { + return assert!( + false, + "couldn't get file metadata" + ); + }; + assert!(size > 0); + } + Err(e) => assert!(false, "{e}"), + } + } +} diff --git a/src/core/images.rs b/src/core/images.rs index 9ded9c2..3667c49 100644 --- a/src/core/images.rs +++ b/src/core/images.rs @@ -72,11 +72,10 @@ impl Content for Image { fn subtext(&self) -> String { if self.path.exists() { - self.path - .file_name() - .map_or("Missing image".into(), |f| { - f.to_string_lossy().to_string() - }) + self.path.file_name().map_or_else( + || "Missing image".into(), + |f| f.to_string_lossy().to_string(), + ) } else { "Missing image".into() } @@ -89,6 +88,7 @@ impl From for Image { } } +#[allow(clippy::option_if_let_else)] impl From<&Value> for Image { fn from(value: &Value) -> Self { match value { @@ -269,7 +269,9 @@ mod test { fn test_image(title: String) -> Image { Image { title, - path: PathBuf::from("~/pics/camprules2024.mp4"), + path: PathBuf::from( + "/home/chris/pics/memes/no-i-dont-think.gif", + ), ..Default::default() } } @@ -280,10 +282,10 @@ mod test { items: vec![], kind: LibraryKind::Image, }; - let mut db = crate::core::model::get_db().await; + let mut db = add_db().await.unwrap().acquire().await.unwrap(); image_model.load_from_db(&mut db).await; - if let Some(image) = image_model.find(|i| i.id == 3) { - let test_image = test_image("nccq5".into()); + if let Some(image) = image_model.find(|i| i.id == 23) { + let test_image = test_image("no-i-dont-think.gif".into()); assert_eq!(test_image.title, image.title); } else { assert!(false); @@ -317,4 +319,9 @@ mod test { ), } } + + async fn add_db() -> Result { + let db_url = String::from("sqlite://./test.db"); + SqlitePool::connect(&db_url).await.into_diagnostic() + } } diff --git a/src/core/kinds.rs b/src/core/kinds.rs index 00d9917..59de7ba 100644 --- a/src/core/kinds.rs +++ b/src/core/kinds.rs @@ -28,9 +28,11 @@ impl TryFrom for ServiceItemKind { let ext = path .extension() .and_then(|ext| ext.to_str()) - .ok_or(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 { "png" | "jpg" | "jpeg" => { Ok(Self::Image(Image::from(path))) @@ -38,6 +40,7 @@ impl TryFrom for ServiceItemKind { "mp4" | "mkv" | "webm" => { Ok(Self::Video(Video::from(path))) } + "pdf" => Ok(Self::Presentation(Presentation::from(path))), _ => Err(miette::miette!("Unknown item")), } } diff --git a/src/core/model.rs b/src/core/model.rs index 25affdc..03615b4 100644 --- a/src/core/model.rs +++ b/src/core/model.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, mem::replace, path::PathBuf}; +use std::{borrow::Cow, fs, mem::replace, path::PathBuf}; use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes}; use miette::{IntoDiagnostic, Result, miette}; @@ -84,26 +84,36 @@ impl Model { } pub fn update_item(&mut self, item: T, index: i32) -> Result<()> { - if let Some(current_item) = self.items.get_mut(index as usize) - { - let _old_item = replace(current_item, item); - Ok(()) - } else { - Err(miette!( - "Item doesn't exist in model. Id was {}", - index - )) - } + self.items + .get_mut( + usize::try_from(index) + .expect("Shouldn't be negative"), + ) + .map_or_else( + || { + Err(miette!( + "Item doesn't exist in model. Id was {index}" + )) + }, + |current_item| { + let _old_item = replace(current_item, item); + Ok(()) + }, + ) } pub fn remove_item(&mut self, index: i32) -> Result<()> { - self.items.remove(index as usize); + self.items.remove( + usize::try_from(index).expect("Shouldn't be negative"), + ); Ok(()) } #[must_use] pub fn get_item(&self, index: i32) -> Option<&T> { - self.items.get(index as usize) + self.items.get( + usize::try_from(index).expect("shouldn't be negative"), + ) } pub fn find

(&self, f: P) -> Option<&T> @@ -114,7 +124,10 @@ impl Model { } pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> { - self.items.insert(index as usize, item); + self.items.insert( + usize::try_from(index).expect("Shouldn't be negative"), + item, + ); Ok(()) } } @@ -131,11 +144,13 @@ impl Model { // } pub async fn get_db() -> SqliteConnection { - let mut data = dirs::data_local_dir().unwrap(); + let mut data = dirs::data_local_dir() + .expect("Should be able to find a data dir"); data.push("lumina"); + let _ = fs::create_dir_all(&data); data.push("library-db.sqlite3"); let mut db_url = String::from("sqlite://"); - db_url.push_str(data.to_str().unwrap()); + db_url.push_str(data.to_str().expect("Should be there")); SqliteConnection::connect(&db_url).await.expect("problems") } diff --git a/src/core/presentations.rs b/src/core/presentations.rs index 0303f84..4a74f79 100644 --- a/src/core/presentations.rs +++ b/src/core/presentations.rs @@ -65,27 +65,24 @@ impl From for Presentation { .to_str() .unwrap_or_default() { - "pdf" => { - if let Ok(document) = Document::open(&value.as_path()) - { - if let Ok(count) = document.page_count() { - PresKind::Pdf { - starting_index: 0, - ending_index: count - 1, - } - } else { + "pdf" => Document::open(&value.as_path()).map_or( + PresKind::Pdf { + starting_index: 0, + ending_index: 0, + }, + |document| { + document.page_count().map_or( PresKind::Pdf { starting_index: 0, ending_index: 0, - } - } - } else { - PresKind::Pdf { - starting_index: 0, - ending_index: 0, - } - } - } + }, + |count| PresKind::Pdf { + starting_index: 0, + ending_index: count - 1, + }, + ) + }, + ), "html" => PresKind::Html, _ => PresKind::Generic, }; @@ -129,11 +126,10 @@ impl Content for Presentation { fn subtext(&self) -> String { if self.path.exists() { - self.path - .file_name() - .map_or("Missing presentation".into(), |f| { - f.to_string_lossy().to_string() - }) + self.path.file_name().map_or_else( + || "Missing presentation".into(), + |f| f.to_string_lossy().to_string(), + ) } else { "Missing presentation".into() } @@ -146,6 +142,7 @@ impl From for Presentation { } } +#[allow(clippy::option_if_let_else)] impl From<&Value> for Presentation { fn from(value: &Value) -> Self { match value { @@ -206,15 +203,14 @@ impl ServiceTrait for Presentation { let pages: Vec = pages .enumerate() .filter_map(|(index, page)| { - if (index as i32) < starting_index { - return None; - } else if (index as i32) > ending_index { + let index = i32::try_from(index) + .expect("Shouldn't be that high"); + + if index < starting_index || index > ending_index { return None; } - let Some(page) = page.ok() else { - return None; - }; + let page = page.ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let Ok(pixmap) = page @@ -248,7 +244,10 @@ impl ServiceTrait for Presentation { .video_loop(false) .video_start_time(0.0) .video_end_time(0.0) - .pdf_index(index as u32) + .pdf_index( + u32::try_from(index) + .expect("Shouldn't get that high"), + ) .pdf_page(page) .build()?; slides.push(slide); @@ -334,32 +333,38 @@ impl Model { presentation.ending_index, ) { PresKind::Pdf { - starting_index: starting_index as i32, - ending_index: ending_index as i32, + starting_index: i32::try_from( + starting_index, + ) + .expect("Shouldn't get that high"), + ending_index: i32::try_from( + ending_index, + ) + .expect("Shouldn't get that high"), } } else { let path = PathBuf::from(presentation.path); - if let Ok(document) = - Document::open(path.as_path()) - { - if let Ok(count) = - document.page_count() - { - let ending_index = count - 1; - PresKind::Pdf { - starting_index: 0, - ending_index, - } - } else { - PresKind::Pdf { - starting_index: 0, - ending_index: 0, - } - } - } else { - PresKind::Generic - } + + Document::open(path.as_path()).map_or( + PresKind::Generic, + |document| { + document.page_count().map_or( + PresKind::Pdf { + starting_index: 0, + ending_index: 0, + }, + |count| { + let ending_index = + count - 1; + PresKind::Pdf { + starting_index: 0, + ending_index, + } + }, + ) + }, + ) }, }); } @@ -393,11 +398,22 @@ pub async fn add_presentation_to_db( .unwrap_or_default(); let html = presentation.kind == PresKind::Html; let mut db = db.detach(); + let (starting_index, ending_index) = if let PresKind::Pdf { + starting_index, + ending_index, + } = presentation.kind + { + (starting_index, ending_index) + } else { + (0, 0) + }; query!( - r#"INSERT INTO presentations (title, file_path, html) VALUES ($1, $2, $3)"#, + 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 @@ -416,16 +432,17 @@ pub async fn update_presentation_in_db( .unwrap_or_default(); let html = presentation.kind == PresKind::Html; let mut db = db.detach(); - let mut starting_index = 0; - let mut ending_index = 0; - if let PresKind::Pdf { + let (starting_index, ending_index) = if let PresKind::Pdf { starting_index: s_index, ending_index: e_index, - } = presentation.get_kind() + } = + presentation.get_kind() { - starting_index = *s_index; - ending_index = *e_index; - } + (*s_index, *e_index) + } else { + (0, 0) + }; + debug!(starting_index, ending_index); let id = presentation.id; if let Err(e) = query!("SELECT id FROM presentations where id = $1", id) @@ -439,7 +456,10 @@ pub async fn update_presentation_in_db( 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"); + 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)"#, @@ -456,7 +476,7 @@ pub async fn update_presentation_in_db( return match result { Ok(_) => { - debug!("should have been updated"); + debug!("presentation should have been added"); Ok(()) } Err(e) => { @@ -470,11 +490,13 @@ pub async fn update_presentation_in_db( debug!(?presentation, "should be been updated"); let result = query!( - r#"UPDATE presentations SET title = $2, file_path = $3, html = $4 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.title, path, - html + html, + starting_index, + ending_index ) .execute(&mut db) .await.into_diagnostic(); @@ -506,12 +528,13 @@ mod test { fn test_presentation() -> Presentation { Presentation { - id: 54, - title: "20240327T133649--12-isaiah-and-jesus__lesson_project_tfc".into(), - path: PathBuf::from( - "file:///home/chris/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html", - ), - kind: PresKind::Html, + id: 4, + title: "mzt52.pdf".into(), + path: PathBuf::from("/home/chris/docs/mzt52.pdf"), + kind: PresKind::Pdf { + starting_index: 0, + ending_index: 67, + }, } } @@ -521,16 +544,21 @@ mod test { assert_eq!(pres.get_kind(), &PresKind::Generic) } + async fn add_db() -> Result { + let mut db_url = String::from("sqlite://./test.db"); + SqlitePool::connect(&db_url).await.into_diagnostic() + } + #[tokio::test] async fn test_db_and_model() { let mut presentation_model: Model = Model { items: vec![], kind: LibraryKind::Presentation, }; - let mut db = crate::core::model::get_db().await; + let mut db = add_db().await.unwrap().acquire().await.unwrap(); presentation_model.load_from_db(&mut db).await; if let Some(presentation) = - presentation_model.find(|p| p.id == 54) + presentation_model.find(|p| p.id == 4) { let test_presentation = test_presentation(); assert_eq!(&test_presentation, presentation); diff --git a/src/core/service_items.rs b/src/core/service_items.rs index 8a4481b..fb341fa 100644 --- a/src/core/service_items.rs +++ b/src/core/service_items.rs @@ -32,7 +32,7 @@ impl Eq for ServiceItem {} impl PartialOrd for ServiceItem { fn partial_cmp(&self, other: &Self) -> Option { - self.id.partial_cmp(&other.id) + Some(self.cmp(other)) } } @@ -89,9 +89,11 @@ impl TryFrom for ServiceItem { let ext = path .extension() .and_then(|ext| ext.to_str()) - .ok_or(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 { "png" | "jpg" | "jpeg" => { Ok(Self::from(&Image::from(path))) @@ -157,6 +159,8 @@ impl From for ServiceItem { } } +#[allow(clippy::option_if_let_else)] +#[allow(clippy::match_like_matches_macro)] impl From<&Value> for ServiceItem { fn from(value: &Value) -> Self { match value { @@ -280,64 +284,61 @@ impl From> for Service { impl From<&Song> for ServiceItem { fn from(song: &Song) -> Self { - if let Ok(slides) = song.to_slides() { - Self { + song.to_slides().map_or_else( + |_| Self { + kind: ServiceItemKind::Song(song.clone()), + database_id: song.id, + title: song.title.clone(), + ..Default::default() + }, + |slides| Self { kind: ServiceItemKind::Song(song.clone()), database_id: song.id, title: song.title.clone(), slides, ..Default::default() - } - } else { - Self { - kind: ServiceItemKind::Song(song.clone()), - database_id: song.id, - title: song.title.clone(), - ..Default::default() - } - } + }, + ) } } impl From<&Video> for ServiceItem { fn from(video: &Video) -> Self { - if let Ok(slides) = video.to_slides() { - Self { + video.to_slides().map_or_else( + |_| Self { + kind: ServiceItemKind::Video(video.clone()), + database_id: video.id, + title: video.title.clone(), + ..Default::default() + }, + |slides| Self { kind: ServiceItemKind::Video(video.clone()), database_id: video.id, title: video.title.clone(), slides, ..Default::default() - } - } else { - Self { - kind: ServiceItemKind::Video(video.clone()), - database_id: video.id, - title: video.title.clone(), - ..Default::default() - } - } + }, + ) } } impl From<&Image> for ServiceItem { fn from(image: &Image) -> Self { - if let Ok(slides) = image.to_slides() { - Self { + image.to_slides().map_or_else( + |_| Self { + kind: ServiceItemKind::Image(image.clone()), + database_id: image.id, + title: image.title.clone(), + ..Default::default() + }, + |slides| Self { kind: ServiceItemKind::Image(image.clone()), database_id: image.id, title: image.title.clone(), slides, ..Default::default() - } - } else { - Self { - kind: ServiceItemKind::Image(image.clone()), - database_id: image.id, - title: image.title.clone(), - ..Default::default() - } - } + }, + ) } } @@ -368,14 +369,11 @@ impl From<&Presentation> for ServiceItem { } } +#[allow(unused)] impl Service { - fn add_item( - &mut self, - item: impl Into, - ) -> Result<()> { + fn add_item(&mut self, item: impl Into) { let service_item: ServiceItem = item.into(); self.items.push(service_item); - Ok(()) } pub fn to_slides(&self) -> Result> { @@ -391,7 +389,7 @@ impl Service { .collect::>(); let mut final_slides = vec![]; for (index, mut slide) in slides.into_iter().enumerate() { - slide.set_index(index as i32); + slide.set_index(i32::try_from(index).into_diagnostic()?); final_slides.push(slide); } Ok(final_slides) @@ -454,19 +452,15 @@ mod test { let pres = test_presentation(); let pres_item = ServiceItem::from(&pres); let mut service_model = Service::default(); - match service_model.add_item(&song) { - Ok(_) => { - assert_eq!( - ServiceItemKind::Song(song), - service_model.items[0].kind - ); - assert_eq!( - ServiceItemKind::Presentation(pres), - pres_item.kind - ); - assert_eq!(service_item, service_model.items[0]); - } - Err(e) => panic!("Problem adding item: {:?}", e), - } + service_model.add_item(&song); + assert_eq!( + ServiceItemKind::Song(song), + service_model.items[0].kind + ); + assert_eq!( + ServiceItemKind::Presentation(pres), + pres_item.kind + ); + assert_eq!(service_item, service_model.items[0]); } } diff --git a/src/core/slide.rs b/src/core/slide.rs index 048bcb3..721c7fb 100644 --- a/src/core/slide.rs +++ b/src/core/slide.rs @@ -1,6 +1,5 @@ -use cosmic::{ - cosmic_theme::palette::rgb::Rgba, widget::image::Handle, -}; +#![allow(clippy::similar_names, unused)] +use cosmic::widget::image::Handle; // use cosmic::dialog::ashpd::url::Url; use crisp::types::{Keyword, Symbol, Value}; use iced_video_player::Video; @@ -12,7 +11,7 @@ use std::{ }; use tracing::error; -use crate::ui::text_svg::TextSvg; +use crate::ui::text_svg::{Color, Font, Shadow, Stroke, TextSvg}; use super::songs::Song; @@ -23,20 +22,20 @@ pub struct Slide { id: i32, pub(crate) background: Background, text: String, - font: String, + font: Option, font_size: i32, - stroke_size: i32, - stroke_color: Option, + stroke: Option, + shadow: Option, text_alignment: TextAlignment, + text_color: Option, audio: Option, video_loop: bool, video_start_time: f32, video_end_time: f32, pdf_index: u32, + pub text_svg: Option, #[serde(skip)] pdf_page: Option, - #[serde(skip)] - pub text_svg: Option, } #[derive( @@ -50,13 +49,6 @@ pub enum BackgroundKind { Html, } -#[derive(Debug, Clone, Default)] -struct Image { - pub source: String, - pub fit: String, - pub children: Vec, -} - #[derive( Clone, Copy, @@ -144,12 +136,15 @@ impl TryFrom for Background { type Error = ParseError; fn try_from(path: PathBuf) -> Result { let path = if path.starts_with("~") { - let path = path.to_str().unwrap().to_string(); + let path = path + .to_str() + .expect("Should have a string") + .to_string(); let path = path.trim_start_matches("file://"); let home = dirs::home_dir() - .unwrap() + .expect("We should have a home directory") .to_str() - .unwrap() + .expect("Gah") .to_string(); let path = path.replace('~', &home); PathBuf::from(path) @@ -197,16 +192,18 @@ impl TryFrom<&str> for Background { fn try_from(value: &str) -> Result { let value = value.trim_start_matches("file://"); if value.starts_with('~') { - if let Some(home) = dirs::home_dir() { - if let Some(home) = home.to_str() { - let value = value.replace('~', home); - Self::try_from(PathBuf::from(value)) - } else { - Self::try_from(PathBuf::from(value)) - } - } else { - Self::try_from(PathBuf::from(value)) - } + dirs::home_dir().map_or_else( + || Self::try_from(PathBuf::from(value)), + |home| { + home.to_str().map_or_else( + || Self::try_from(PathBuf::from(value)), + |home| { + let value = value.replace('~', home); + Self::try_from(PathBuf::from(value)) + }, + ) + }, + ) } else if value.starts_with("./") { Err(ParseError::CannotCanonicalize) } else { @@ -270,68 +267,116 @@ impl From<&Slide> for Value { } impl Slide { + #[must_use] pub fn set_text(mut self, text: impl AsRef) -> Self { self.text = text.as_ref().into(); self } + #[must_use] pub fn with_text_svg(mut self, text_svg: TextSvg) -> Self { self.text_svg = Some(text_svg); self } + #[must_use] pub fn set_font(mut self, font: impl AsRef) -> Self { - self.font = font.as_ref().into(); + self.font = Some(font.as_ref().into()); self } + #[must_use] pub const fn set_font_size(mut self, font_size: i32) -> Self { self.font_size = font_size; self } + #[must_use] pub fn set_audio(mut self, audio: Option) -> Self { self.audio = audio; self } + #[must_use] pub const fn set_pdf_index(mut self, pdf_index: u32) -> Self { self.pdf_index = pdf_index; self } + #[must_use] + pub const fn set_stroke(mut self, stroke: Stroke) -> Self { + self.stroke = Some(stroke); + self + } + + #[must_use] + pub const fn set_shadow(mut self, shadow: Shadow) -> Self { + self.shadow = Some(shadow); + self + } + + #[must_use] + pub const fn set_text_color(mut self, color: Color) -> Self { + self.text_color = Some(color); + self + } + + #[must_use] pub const fn background(&self) -> &Background { &self.background } + #[must_use] pub fn text(&self) -> String { self.text.clone() } + #[must_use] pub const fn text_alignment(&self) -> TextAlignment { self.text_alignment } + #[must_use] pub const fn font_size(&self) -> i32 { self.font_size } - pub fn font(&self) -> String { + #[must_use] + pub fn font(&self) -> Option { self.font.clone() } + #[must_use] pub const fn video_loop(&self) -> bool { self.video_loop } + #[must_use] pub fn audio(&self) -> Option { self.audio.clone() } + #[must_use] pub fn pdf_page(&self) -> Option { self.pdf_page.clone() } + #[must_use] + pub fn text_color(&self) -> Option { + self.text_color.clone() + } + + #[must_use] + pub fn stroke(&self) -> Option { + self.stroke.clone() + } + + #[must_use] + pub fn shadow(&self) -> Option { + self.shadow.clone() + } + + #[must_use] pub const fn pdf_index(&self) -> u32 { self.pdf_index } @@ -366,10 +411,6 @@ impl Slide { self.id = index; } - pub(crate) fn text_to_image(&self) { - todo!() - } - // pub fn slides_from_item(item: &ServiceItem) -> Result> { // todo!() // } @@ -390,7 +431,8 @@ impl From<&Value> for Slide { } } -fn lisp_to_slide(lisp: &Vec) -> Slide { +#[allow(clippy::option_if_let_else)] +fn lisp_to_slide(lisp: &[Value]) -> Slide { const DEFAULT_BACKGROUND_LOCATION: usize = 1; const DEFAULT_TEXT_LOCATION: usize = 0; @@ -454,6 +496,7 @@ fn lisp_to_slide(lisp: &Vec) -> Slide { } } +#[allow(clippy::option_if_let_else)] fn lisp_to_font_size(lisp: &Value) -> i32 { match lisp { Value::List(list) => { @@ -486,6 +529,7 @@ fn lisp_to_text(lisp: &Value) -> impl Into { // Need to return a Result here so that we can propogate // errors and then handle them appropriately +#[allow(clippy::option_if_let_else)] pub fn lisp_to_background(lisp: &Value) -> Background { match lisp { Value::List(list) => { @@ -544,9 +588,12 @@ pub fn lisp_to_background(lisp: &Value) -> Background { pub struct SlideBuilder { background: Option, text: Option, - font: Option, + font: Option, font_size: Option, audio: Option, + stroke: Option, + shadow: Option, + text_color: Option, text_alignment: Option, video_loop: Option, video_start_time: Option, @@ -585,12 +632,20 @@ impl SlideBuilder { self } + pub(crate) fn text_color( + mut self, + text_color: impl Into, + ) -> Self { + let _ = self.text_color.insert(text_color.into()); + self + } + pub(crate) fn audio(mut self, audio: impl Into) -> Self { let _ = self.audio.insert(audio.into()); self } - pub(crate) fn font(mut self, font: impl Into) -> Self { + pub(crate) fn font(mut self, font: impl Into) -> Self { let _ = self.font.insert(font.into()); self } @@ -600,6 +655,27 @@ impl SlideBuilder { self } + pub(crate) fn color(mut self, color: impl Into) -> Self { + let _ = self.text_color.insert(color.into()); + self + } + + pub(crate) fn stroke( + mut self, + stroke: impl Into, + ) -> Self { + let _ = self.stroke.insert(stroke.into()); + self + } + + pub(crate) fn shadow( + mut self, + shadow: impl Into, + ) -> Self { + let _ = self.shadow.insert(shadow.into()); + self + } + pub(crate) fn text_alignment( mut self, text_alignment: TextAlignment, @@ -657,9 +733,6 @@ impl SlideBuilder { let Some(text) = self.text else { return Err(miette!("No text")); }; - let Some(font) = self.font else { - return Err(miette!("No font")); - }; let Some(font_size) = self.font_size else { return Err(miette!("No font_size")); }; @@ -678,10 +751,13 @@ impl SlideBuilder { Ok(Slide { background, text, - font, + font: self.font, font_size, text_alignment, audio: self.audio, + stroke: self.stroke, + shadow: self.shadow, + text_color: self.text_color, video_loop, video_start_time, video_end_time, @@ -693,12 +769,6 @@ impl SlideBuilder { } } -impl Image { - fn new() -> Self { - Default::default() - } -} - #[cfg(test)] mod test { use pretty_assertions::assert_eq; @@ -711,7 +781,7 @@ mod test { text: "This is frodo".to_string(), background: Background::try_from("~/pics/frodo.jpg") .unwrap(), - font: "Quicksand".to_string(), + font: Some("Quicksand".to_string().into()), font_size: 140, ..Default::default() } @@ -724,30 +794,11 @@ mod test { "~/vids/test/camprules2024.mp4", ) .unwrap(), - font: "Quicksand".to_string(), + font: Some("Quicksand".to_string().into()), ..Default::default() } } - #[test] - fn test_lisp_serialize() { - let lisp = - read_to_string("./test_presentation.lisp").expect("oops"); - let lisp_value = crisp::reader::read(&lisp); - match lisp_value { - Value::List(value) => { - let slide = Slide::from(value[0].clone()); - let test_slide = test_slide(); - assert_eq!(slide, test_slide); - - let second_slide = Slide::from(value[1].clone()); - let second_test_slide = test_second_slide(); - assert_eq!(second_slide, second_test_slide) - } - _ => panic!("this should be a lisp"), - } - } - #[test] fn test_ron_deserialize() { let slide = read_to_string("./test_presentation.ron") diff --git a/src/core/song_search.rs b/src/core/song_search.rs index 3071a6a..e43b441 100644 --- a/src/core/song_search.rs +++ b/src/core/song_search.rs @@ -1,16 +1,136 @@ use itertools::Itertools; -use miette::{IntoDiagnostic, Result}; +use miette::{IntoDiagnostic, Result, miette}; +use reqwest::header; +use serde::{Deserialize, Serialize}; +use serde_json::Value; -#[derive(Clone, Debug, Default, PartialEq, PartialOrd, Ord, Eq)] +#[derive( + Clone, + Debug, + Default, + PartialEq, + PartialOrd, + Ord, + Eq, + Serialize, + Deserialize, +)] pub struct OnlineSong { - lyrics: String, - title: String, - author: String, - site: String, - link: String, + pub lyrics: String, + pub title: String, + pub author: String, + pub site: String, + pub link: String, } -pub async fn search_online_song_links( +pub async fn search_genius_links( + query: impl AsRef + std::fmt::Display, +) -> Result> { + let auth_token = env!("GENIUS_TOKEN"); + let mut headers = header::HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_static(auth_token), + ); + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .into_diagnostic()?; + let response = client + .get(format!("https://api.genius.com/search?q={query}")) + .send() + .await + .into_diagnostic()? + .error_for_status() + .into_diagnostic()? + .text() + .await + .into_diagnostic()?; + let json: Value = + serde_json::from_str(&response).into_diagnostic()?; + let hits = json + .get("response") + .expect("respose") + .get("hits") + .expect("hits") + .as_array() + .expect("array"); + Ok(hits + .iter() + .map(|hit| { + let result = hit.get("result").expect("result"); + let title = result + .get("full_title") + .expect("title") + .as_str() + .expect("title") + .to_string(); + let title = title.replace("\u{a0}", " "); + let author = result + .get("artist_names") + .expect("artists") + .as_str() + .expect("artists") + .to_string(); + let link = result + .get("url") + .expect("url") + .as_str() + .expect("url") + .to_string(); + OnlineSong { + lyrics: String::new(), + title, + author, + site: String::from("https://genius.com"), + link, + } + }) + .collect()) +} + +pub async fn get_genius_lyrics( + mut song: OnlineSong, +) -> Result { + let html = reqwest::get(&song.link) + .await + .into_diagnostic()? + .error_for_status() + .into_diagnostic()? + .text() + .await + .into_diagnostic()?; + let document = scraper::Html::parse_document(&html); + let Ok(lyrics_root_selector) = scraper::Selector::parse( + r#"div[data-lyrics-container="true"]"#, + ) else { + return Err(miette!("error in finding lyrics_root")); + }; + + let lyrics = document + .select(&lyrics_root_selector) + .map(|root| { + // dbg!(&root); + root.inner_html() + }) + .collect::(); + let lyrics = lyrics.find("[").map_or_else( + || { + lyrics.find("").map_or( + lyrics.clone(), + |position| { + lyrics.split_at(position + 18).1.to_string() + }, + ) + }, + |position| lyrics.split_at(position).1.to_string(), + ); + let lyrics = lyrics.replace("
", "\n"); + song.lyrics = lyrics; + Ok(song) +} + +pub async fn search_lyrics_com_links( query: impl AsRef + std::fmt::Display, ) -> Result> { let html = @@ -24,16 +144,18 @@ pub async fn search_online_song_links( .into_diagnostic()?; let document = scraper::Html::parse_document(&html); - let best_matches_selector = - scraper::Selector::parse(".best-matches").unwrap(); - let lyric_selector = scraper::Selector::parse("a").unwrap(); + let Ok(best_matches_selector) = + scraper::Selector::parse(".best-matches") + else { + return Err(miette!("error in finding matches")); + }; + let Ok(lyric_selector) = scraper::Selector::parse("a") else { + return Err(miette!("error in finding a links")); + }; Ok(document .select(&best_matches_selector) - .filter_map(|best_section| { - Some(best_section.select(&lyric_selector)) - }) - .flatten() + .flat_map(|best_section| best_section.select(&lyric_selector)) .map(|a| { a.value().attr("href").unwrap_or("").trim().to_string() }) @@ -47,18 +169,21 @@ pub async fn search_online_song_links( .collect()) } -pub async fn link_to_online_song( +// leaving this lint unfixed because I don't know if we will need this +// id value or not in the future and I'd like to keep the code understanding +// of what this variable might be. +#[allow(clippy::no_effect_underscore_binding)] +pub async fn lyrics_com_link_to_song( links: Vec + std::fmt::Display>, ) -> Result> { let mut songs = vec![]; for link in links { let parts = link - .to_string() + .as_ref() .split('/') .map(std::string::ToString::to_string) .collect::>(); let link = format!("https://www.lyrics.com/lyric/{link}"); - dbg!(&link); let _id = &parts[0]; let author = &parts[1].replace('+', " "); let title = &parts[2].replace('+', " "); @@ -73,19 +198,18 @@ pub async fn link_to_online_song( .into_diagnostic()?; let document = scraper::Html::parse_document(&html); - let lyric_selector = - scraper::Selector::parse(".lyric-body").unwrap(); + let Ok(lyric_selector) = + scraper::Selector::parse(".lyric-body") + else { + return Err(miette!("error in finding lyric-body",)); + }; let lyrics = document .select(&lyric_selector) - .map(|a| { - dbg!(&a); - a.text().collect::() - }) + .map(|a| a.text().collect::()) .dedup() .next(); - dbg!(&lyrics); if let Some(lyrics) = lyrics { let song = OnlineSong { lyrics, @@ -103,11 +227,47 @@ pub async fn link_to_online_song( #[cfg(test)] mod test { + use crate::core::songs::Song; + use super::*; use pretty_assertions::assert_eq; #[tokio::test] - async fn test_search_to_song() { + async fn test_genius() -> Result<(), String> { + let song = OnlineSong { + lyrics: String::new(), + title: "Death Was Arrested by North Point Worship (Ft. Seth Condrey)".to_string(), + author: "North Point Worship (Ft. Seth Condrey)".to_string(), + site: "https://genius.com".to_string(), + link: "https://genius.com/North-point-worship-death-was-arrested-lyrics".to_string(), + }; + let hits = search_genius_links("Death was arrested") + .await + .map_err(|e| e.to_string())?; + + let titles: Vec = + hits.iter().map(|song| song.title.clone()).collect(); + dbg!(titles); + for hit in hits { + let new_song = get_genius_lyrics(hit) + .await + .map_err(|e| e.to_string())?; + dbg!(&new_song); + if !new_song.lyrics.starts_with("[Verse 1]") { + assert!(new_song.lyrics.len() > 10); + } else { + assert!(new_song.lyrics.contains("[Verse 2]")); + if !new_song.lyrics.contains("[Chorus]") { + assert!(new_song.lyrics.contains("[Chorus 1]")) + } + } + } + + Ok(()) + } + + #[tokio::test] + async fn test_search_to_song() -> Result<(), String> { let song = OnlineSong { lyrics: "Alone in my sorrow and dead in my sin\nLost without hope with no place to begin\nYour love Made a way to let mercy come in\nWhen death was arrested and my life began\n\nAsh was redeemed only beauty remains\nMy orphan heart was given a name\nMy mourning grew quiet my feet rose to dance\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nReleased from my chains I'm a prisoner no more\nMy shame was a ransom He faithfully bore\nHe cancelled my debt and He called me His friend\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOur savior displayed on a criminal's cross\nDarkness rejoiced as though heaven had lost\nBut then Jesus arose with our freedom in hand\nThat's when death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nWhen death was arrested and my life began\nWhen death was arrested and my life began".to_string(), title: "Death Was Arrested".to_string(), @@ -115,33 +275,42 @@ mod test { site: "https://www.lyrics.com".to_string(), link: "https://www.lyrics.com/lyric/35090938/North+Point+InsideOut/Death+Was+Arrested".to_string(), }; - let search = - search_online_song_links("Death was arrested").await; - match search { - Ok(links) => { - let songs = link_to_online_song(links).await; - match songs { - Ok(songs) => { - if let Some(first) = - songs.iter().find_or_first(|song| { - song.author - == "North Point InsideOut" - }) - { - assert_eq!(&song, first); - } - } - Err(e) => assert!(false, "{}", e), - } - } - Err(e) => assert!(false, "{}", e), + let links = search_lyrics_com_links("Death was arrested") + .await + .map_err(|e| format!("{e}"))?; + let songs = lyrics_com_link_to_song(links) + .await + .map_err(|e| format!("{e}"))?; + if let Some(first) = songs.iter().find_or_first(|song| { + song.author == "North Point InsideOut" + }) { + assert_eq!(&song, first); + online_song_to_song(song)? } + Ok(()) + } + + fn online_song_to_song(song: OnlineSong) -> Result<(), String> { + let song = Song::from(song); + if let Some(verse_map) = song.verse_map.as_ref() { + if verse_map.len() < 2 { + return Err(format!( + "VerseMap wasn't built right likely: {:?}", + song + )); + } + } else { + return Err(String::from( + "There is no VerseMap in this song", + )); + }; + Ok(()) } #[tokio::test] async fn test_online_search() { let search = - search_online_song_links("Death was arrested").await; + search_lyrics_com_links("Death was arrested").await; match search { Ok(songs) => { assert_eq!( diff --git a/src/core/songs.rs b/src/core/songs.rs index 5c9a45a..3093f2c 100644 --- a/src/core/songs.rs +++ b/src/core/songs.rs @@ -3,8 +3,11 @@ use std::{ }; use cosmic::{ - cosmic_theme::palette::rgb::Rgba, - iced::clipboard::mime::AsMimeTypes, + cosmic_theme::palette::Srgb, + iced::{ + clipboard::mime::AsMimeTypes, + font::{Style, Weight}, + }, }; use crisp::types::{Keyword, Symbol, Value}; use itertools::Itertools; @@ -16,14 +19,17 @@ use sqlx::{ }; use tracing::{debug, error}; -use crate::{Slide, SlideBuilder, core::slide}; - -use super::{ - content::Content, - kinds::ServiceItemKind, - model::{LibraryKind, Model}, - service_items::ServiceTrait, - slide::{Background, TextAlignment}, +use crate::{ + Slide, SlideBuilder, + core::{ + content::Content, + kinds::ServiceItemKind, + model::{LibraryKind, Model}, + service_items::ServiceTrait, + slide::{self, Background, TextAlignment}, + song_search::OnlineSong, + }, + ui::text_svg::{Color, Font, Stroke, shadow, stroke}, }; #[derive( @@ -41,11 +47,14 @@ pub struct Song { pub text_alignment: Option, pub font: Option, pub font_size: Option, - pub stroke_size: Option, - pub stroke_color: Option, - pub shadow_size: Option, - pub shadow_offset: Option<(i32, i32)>, - pub shadow_color: Option, + pub font_weight: Option, + pub font_style: Option", // ); - final_svg.push_str(&format!(""); - let text: String = self - .text - .lines() - .enumerate() - .map(|(index, text)| { - format!( - "{}", - (index as f32).mul_add( - text_and_line_spacing, - starting_y_position - ), - text - ) - }) - .collect(); - final_svg.push_str(&text); + if self.shadow.is_some() { + final_svg.push_str(" style=\"filter:url(#shadow);\""); + } + final_svg.push('>'); + + for (index, text) in self.text.lines().enumerate() { + let _ = write!( + final_svg, + "{}", + (index as f32).mul_add( + text_and_line_spacing, + starting_y_position + ), + text + ); + } final_svg.push_str(""); @@ -389,55 +461,72 @@ impl TextSvg { // text // )); - let hashed_title = rapidhash_v3(final_svg.as_bytes()); - path.push(PathBuf::from(hashed_title.to_string())); - path.set_extension("png"); + if let Some(path) = cache.as_mut() { + let hashed_title = rapidhash_v3(final_svg.as_bytes()); + path.push(PathBuf::from(hashed_title.to_string())); + path.set_extension("png"); - if path.exists() { - // debug!("cached"); - let handle = Handle::from_path(path); - self.handle = Some(handle); - return self; + if path.exists() { + // debug!("cached"); + let handle = Handle::from_path(&path); + self.path = Some(path.clone()); + self.handle = Some(handle); + return self; + } } - // debug!("text string built..."); - let resvg_tree = Tree::from_data( + let Ok(resvg_tree) = Tree::from_data( final_svg.as_bytes(), &resvg::usvg::Options { fontdb: Arc::clone(&self.fontdb), ..Default::default() }, - ) - .expect("Woops mama"); + ) else { + error!("Couldn't parse the svg into a tree"); + return self; + }; // debug!("parsed"); let transform = tiny_skia::Transform::default(); - let mut pixmap = - Pixmap::new(size.width as u32, size.height as u32) - .expect("opops"); + + #[allow(clippy::cast_sign_loss)] + let (size_width, size_height) = + (size.width as u32, size.height as u32); + + let Some(mut pixmap) = Pixmap::new(size_width, size_height) + else { + error!("Couldn't create a new pixmap from size"); + return self; + }; resvg::render(&resvg_tree, transform, &mut pixmap.as_mut()); // debug!("rendered"); - if let Err(e) = pixmap.save_png(&path) { + + if let Some(path) = cache.as_ref() + && let Err(e) = pixmap.save_png(path) + { error!(?e, "Couldn't save a copy of the text"); } + self.path = cache; // debug!("saved"); // let handle = Handle::from_path(path); - let handle = Handle::from_rgba( - size.width as u32, - size.height as u32, - pixmap.take(), - ); + let handle = + Handle::from_rgba(size_width, size_height, pixmap.take()); self.handle = Some(handle); // debug!("stored"); self } pub fn view<'a>(&self) -> Element<'a, Message> { - Image::new(self.handle.clone().unwrap()) - .content_fit(ContentFit::Cover) - .width(Length::Fill) - .height(Length::Fill) - .into() + self.handle.clone().map_or_else( + || Element::from(Space::new(Length::Fill, Length::Fill)), + |handle| { + Image::new(handle) + .content_fit(ContentFit::Cover) + .width(Length::Fill) + .height(Length::Fill) + .into() + }, + ) } } @@ -467,21 +556,91 @@ pub fn color(color: impl AsRef) -> Color { } pub fn text_svg_generator( - slide: &mut crate::core::slide::Slide, - fontdb: Arc, -) { - if !slide.text().is_empty() { + slide: crate::core::slide::Slide, + fontdb: &Arc, +) -> Result { + let Some(mut path) = dirs::cache_dir() else { + error!("Cannot find the cache dir"); + return Err(miette!("Cannot find the cache dir")); + }; + path.push("lumina"); + path.push("text_svg_cache"); + let _ = fs::create_dir_all(&path); + + text_svg_generator_with_cache(slide, fontdb, Some(path)) +} + +pub fn text_svg_generator_with_cache( + mut slide: crate::core::slide::Slide, + fontdb: &Arc, + cache: Option, +) -> Result { + if slide.text().is_empty() { + Err(miette!("There is no slide text")) + } else { + let font = slide.font().unwrap_or_default(); let text_svg = TextSvg::new(slide.text()) .alignment(slide.text_alignment()) - .fill("#fff") - .shadow(shadow(2, 2, 5, "#000000")) - .stroke(stroke(3, "#000")) - .font( - Font::from(slide.font()) - .size(slide.font_size().try_into().unwrap()), - ) - .fontdb(Arc::clone(&fontdb)) - .build(); + .fill( + slide.text_color().unwrap_or_else(|| "#fff".into()), + ); + let text_svg = if let Some(stroke) = slide.stroke() { + text_svg.stroke(stroke) + } else { + text_svg + }; + let text_svg = if let Some(shadow) = slide.shadow() { + text_svg.shadow(shadow) + } else { + text_svg + }; + let text_svg = text_svg.font(font).fontdb(Arc::clone(fontdb)); + // debug!(fill = ?text_svg.fill, font = ?text_svg.font, stroke = ?text_svg.stroke, shadow = ?text_svg.shadow, text = ?text_svg.text); + let text_svg = + text_svg.build(Size::new(1280.0, 720.0), cache); slide.text_svg = Some(text_svg); + Ok(slide) + } +} + +#[cfg(test)] +mod tests { + use crate::core::slide::Slide; + + use super::*; + use rayon::iter::{IntoParallelIterator, ParallelIterator}; + use resvg::usvg::fontdb::Database; + use tracing::debug; + + #[test] + fn test_generator() { + let slide = Slide::default(); + debug!("test"); + let mut fontdb = Database::new(); + fontdb.load_system_fonts(); + let fontdb = Arc::new(fontdb); + (0..400).into_par_iter().for_each(|_| { + let slide = slide + .clone() + .set_font_size(120) + .set_font("") + .set_shadow(shadow(5, 5, 5, "#000")) + .set_stroke(stroke(9, "#000")) + .set_text("This is the first slide of text\nAnd we are singing\nTo save the world!"); + match text_svg_generator_with_cache( + slide, + &fontdb, + None, + ) { + Ok(slide) => { + assert!( + slide + .text_svg + .is_some_and(|svg| svg.handle.is_some()) + ) + }, + Err(e) => assert!(false, "There was an issue creating the TextSvg: {e}"), + }; + }); } } diff --git a/src/ui/video_editor.rs b/src/ui/video_editor.rs index c1941bf..6295ed6 100644 --- a/src/ui/video_editor.rs +++ b/src/ui/video_editor.rs @@ -59,7 +59,7 @@ impl VideoEditor { self.update_entire_video(&video); } Message::ChangeTitle(title) => { - self.title = title.clone(); + self.title.clone_from(&title); if let Some(video) = &self.core_video { let mut video = video.clone(); video.title = title; @@ -89,14 +89,12 @@ impl VideoEditor { let task = Task::perform( pick_video(), move |video_result| { - if let Ok(video) = video_result { + video_result.map_or(Message::None, |video| { let mut video = videos::Video::from(video); video.id = video_id; Message::UpdateVideoFile(video) - } else { - Message::None - } + }) }, ); return Action::Task(task); @@ -111,36 +109,35 @@ impl VideoEditor { } pub fn view(&self) -> Element { - let video_elements = if let Some(video) = &self.video { - let play_button = button::icon(if video.paused() { - icon::from_name("media-playback-start") - } else { - icon::from_name("media-playback-pause") - }) - .on_press(Message::PauseVideo); - let video_track = progress_bar( - 0.0..=video.duration().as_secs_f32(), - video.position().as_secs_f32(), - ) - .height(cosmic::theme::spacing().space_s) - .width(Length::Fill); - container( - row![play_button, video_track] - .align_y(Vertical::Center) - .spacing(cosmic::theme::spacing().space_m), - ) - .padding(cosmic::theme::spacing().space_s) - .center_x(Length::FillPortion(2)) - } else { - container(horizontal_space()) - }; + let video_elements = self.video.as_ref().map_or_else( + || container(horizontal_space()), + |video| { + let play_button = button::icon(if video.paused() { + icon::from_name("media-playback-start") + } else { + icon::from_name("media-playback-pause") + }) + .on_press(Message::PauseVideo); + let video_track = progress_bar( + 0.0..=video.duration().as_secs_f32(), + video.position().as_secs_f32(), + ) + .height(cosmic::theme::spacing().space_s) + .width(Length::Fill); + container( + row![play_button, video_track] + .align_y(Vertical::Center) + .spacing(cosmic::theme::spacing().space_m), + ) + .padding(cosmic::theme::spacing().space_s) + .center_x(Length::FillPortion(2)) + }, + ); - let video_player = self - .video - .as_ref() - .map_or(Element::from(Space::new(0, 0)), |video| { - Element::from(VideoPlayer::new(video)) - }); + let video_player = self.video.as_ref().map_or_else( + || Element::from(Space::new(0, 0)), + |video| Element::from(VideoPlayer::new(video)), + ); let video_section = column![video_player, video_elements] .spacing(cosmic::theme::spacing().space_s); @@ -185,13 +182,13 @@ impl VideoEditor { .map(|url| Video::new(&url).expect("Should be here")) else { self.video = None; - self.title = video.title.clone(); + self.title.clone_from(&video.title); self.core_video = Some(video.clone()); return; }; player_video.set_paused(true); self.video = Some(player_video); - self.title = video.title.clone(); + self.title.clone_from(&video.title); self.core_video = Some(video.clone()); } } @@ -217,7 +214,9 @@ async fn pick_video() -> Result { error!(?e); VideoError::DialogClosed }) - .map(|file| file.url().to_file_path().unwrap()) + .map(|file| { + file.url().to_file_path().expect("Should be a file here") + }) // rfd::AsyncFileDialog::new() // .set_title("Choose a background...") // .add_filter( diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 46325a7..735deaf 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -1,3 +1,6 @@ +#[allow(clippy::unwrap_used)] +#[allow(clippy::nursery)] +#[allow(clippy::pedantic)] pub mod draggable; pub mod slide_text; pub mod verse_editor; diff --git a/src/ui/widgets/slide_text.rs b/src/ui/widgets/slide_text.rs index efdaa54..97d8165 100644 --- a/src/ui/widgets/slide_text.rs +++ b/src/ui/widgets/slide_text.rs @@ -8,7 +8,7 @@ use cosmic::iced_wgpu::Primitive; use cosmic::iced_wgpu::primitive::Renderer as PrimitiveRenderer; pub struct SlideText { - text: String, + _text: String, font_size: f32, } @@ -16,7 +16,7 @@ impl SlideText { pub fn new(text: impl AsRef) -> Self { let text = text.as_ref(); Self { - text: text.to_string(), + _text: text.to_string(), font_size: 50.0, } } @@ -88,8 +88,8 @@ where #[derive(Debug, Clone)] pub(crate) struct TextPrimitive { - text_id: u64, - size: (u32, u32), + _text_id: u64, + _size: (u32, u32), } impl TextPrimitive { diff --git a/src/ui/widgets/verse_editor.rs b/src/ui/widgets/verse_editor.rs index 29016dd..1d644af 100644 --- a/src/ui/widgets/verse_editor.rs +++ b/src/ui/widgets/verse_editor.rs @@ -1,12 +1,12 @@ use cosmic::{ Element, Task, cosmic_theme::palette::WithAlpha, - iced::{Background, Border, Color, Point}, + iced::{Background, Border}, iced_widget::{column, row}, theme, widget::{ - button, combo_box, container, horizontal_space, icon, text, - text_editor, text_input, + button, combo_box, container, horizontal_space, icon, + text_editor, }, }; @@ -35,16 +35,17 @@ pub enum Action { UpdateVerse((VerseName, String)), UpdateVerseName(String), DeleteVerse(VerseName), + ScrollVerses(f32), None, } impl VerseEditor { #[must_use] - pub fn new(verse: VerseName, lyric: String) -> Self { + pub fn new(verse: VerseName, lyric: &str) -> Self { Self { verse_name: verse, - lyric: lyric.clone(), - content: text_editor::Content::with_text(&lyric), + lyric: lyric.to_string(), + content: text_editor::Content::with_text(lyric), editing_verse_name: false, verse_name_combo: combo_box::State::new( VerseName::all_names(), @@ -53,18 +54,27 @@ impl VerseEditor { } pub fn update(&mut self, message: Message) -> Action { match message { - Message::UpdateLyric(action) => { - self.content.perform(action.clone()); - match action { - text_editor::Action::Edit(_edit) => { - let lyrics = self.content.text(); - self.lyric = lyrics.clone(); - let verse = self.verse_name; - Action::UpdateVerse((verse, lyrics)) - } - _ => Action::None, + Message::UpdateLyric(action) => match action { + text_editor::Action::Edit(ref _edit) => { + self.content.perform(action); + let lyrics = self.content.text(); + self.lyric.clone_from(&lyrics); + let verse = self.verse_name; + Action::UpdateVerse((verse, lyrics)) } - } + text_editor::Action::Scroll { pixels } => { + if self.content.line_count() > 6 { + self.content.perform(action); + Action::None + } else { + Action::ScrollVerses(pixels) + } + } + _ => { + self.content.perform(action); + Action::None + } + }, Message::UpdateVerseName(verse_name) => { Action::UpdateVerseName(verse_name) } @@ -79,7 +89,7 @@ impl VerseEditor { pub fn view(&self) -> Element { let cosmic::cosmic_theme::Spacing { - space_xxs, + space_xxs: _, space_s, space_m, .. @@ -142,11 +152,8 @@ impl VerseEditor { .color(t.cosmic().accent.hover); match s { text_editor::Status::Active => base_style, - text_editor::Status::Hovered => { - base_style.border = hovered_border; - base_style - } - text_editor::Status::Focused => { + text_editor::Status::Hovered + | text_editor::Status::Focused => { base_style.border = hovered_border; base_style } @@ -165,13 +172,4 @@ impl VerseEditor { .class(theme::Container::Card) .into() } - - // TODO not done yet. This doesn't work, need to find a way to either reset the - // cursor position or not make new widgets - pub fn set_cursor_position(&mut self, position: (usize, usize)) { - self.content.perform(text_editor::Action::Click(Point::new( - position.0 as f32, - position.1 as f32, - ))); - } } diff --git a/test.db b/test.db new file mode 100644 index 0000000..64f3fbf Binary files /dev/null and b/test.db differ diff --git a/test_presentation.ron b/test_presentation.ron index f7b37ad..f3865d1 100644 --- a/test_presentation.ron +++ b/test_presentation.ron @@ -6,10 +6,15 @@ kind: Image ), text: "This is Frodo", - font: "Quicksand", - font_size: 50, - stroke_size: 0, - stroke_color: None, + font: Some(Font( + name: "Quicksand", + weight: Normal, + style: Normal, + size: 130, + )), + font_size: 130, + stroke: None, + shadow: None, text_alignment: MiddleCenter, video_loop: false, video_start_time: 0.0, @@ -24,10 +29,15 @@ kind: Video ), text: "This is Frodo", - font: "Quicksand", - font_size: 50, - stroke_size: 0, - stroke_color: None, + font: Some(Font( + name: "Quicksand", + weight: Normal, + style: Normal, + size: 130, + )), + font_size: 130, + stroke: None, + shadow: None, text_alignment: MiddleCenter, video_loop: false, video_start_time: 0.0,