Compare commits

..

6 commits

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

9
.gitignore vendored
View file

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

2001
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

52
flake.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,12 +3,6 @@
Lumina is a presentation app that works from a cli or a UI. The goal is that through a simple text file, you can describe an entire presentation and then load and control it either from the command line, or a UI. The UI also provides user friendly ways of creating the presentation to allow for flexibility for users to make something that works for regular folk as well as developers and nerds.
[[file:./res/images/screenshot_2026-05-03_08-23-08.png]]
[[file:./res/images/screenshot_2026-05-03_08-23-23.png]]
[[file:./res/images/screenshot_2026-05-03_08-16-59.png]]
* Why build this?
Primarily, I don't think there is a good tool for this kind of thing on Linux. On Windows and Mac there is ProPresenter or Proclaim. Both amazing presentation software built for churches or worship centers and can be used by others for other things too, but incredible tools. I want to have a similar tool on Linux. The available tools out there now are often old, broken, or very difficult to use. I want something incredibly easy, with very sane or at least very customizable keyboard controls that allow me to quickly build a presentation and make it VERY easy to run it too.

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 832 B

View file

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

Before

Width:  |  Height:  |  Size: 839 B

View file

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

Before

Width:  |  Height:  |  Size: 849 B

View file

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

Before

Width:  |  Height:  |  Size: 737 B

View file

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

Before

Width:  |  Height:  |  Size: 822 B

View file

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

Before

Width:  |  Height:  |  Size: 834 B

View file

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

Before

Width:  |  Height:  |  Size: 822 B

View file

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

Before

Width:  |  Height:  |  Size: 831 B

View file

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

Before

Width:  |  Height:  |  Size: 841 B

View file

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

Before

Width:  |  Height:  |  Size: 621 B

View file

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

Before

Width:  |  Height:  |  Size: 660 B

View file

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

Before

Width:  |  Height:  |  Size: 398 B

View file

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

Before

Width:  |  Height:  |  Size: 617 B

View file

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 100"><path fill="#000000" d="M685 35h30v30h-30zM465 15h70v70h-70zM585 25h50v50h-50zM365 25h50v50h-50zM285 35h30v30h-30z"></path></svg>

Before

Width:  |  Height:  |  Size: 331 B

View file

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

Before

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View file

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

Before

Width:  |  Height:  |  Size: 5.4 KiB

View file

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

Before

Width:  |  Height:  |  Size: 348 B

View file

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 799 B

View file

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

Before

Width:  |  Height:  |  Size: 378 B

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<rect x="0" fill="none" width="20" height="20"/>
<g>
<path d="M5 14V6h10v8H5zm-3-1V7h2v6H2zm4-6v6h8V7H6zm10 0h2v6h-2V7zm-3 2V8H7v1h6zm0 3v-2H7v2h6z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

View file

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

Before

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

View file

Before

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

After

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

Before After
Before After

View file

Before

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

After

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

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 573 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 976 B

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,22 @@
use crate::core::kinds::ServiceItemKind;
use crate::core::service_items::ServiceItem;
use crate::core::slide::Background;
use crate::core::{
kinds::ServiceItemKind, service_items::ServiceItem,
slide::Background,
};
use cosmic::widget::image::Handle;
use miette::{IntoDiagnostic, Result, miette};
use std::fs::{self, File};
use std::io::Write;
use std::iter;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{
fs::{self, File},
io::Write,
iter,
path::{Path, PathBuf},
};
use tar::{Archive, Builder};
use tracing::{debug, error};
use zstd::{Decoder, Encoder};
#[allow(clippy::too_many_lines)]
pub fn save(
list: &Arc<Vec<ServiceItem>>,
list: Vec<ServiceItem>,
path: impl AsRef<Path>,
overwrite: bool,
) -> Result<()> {
@ -24,7 +26,8 @@ pub fn save(
}
let save_file = File::create(path).into_diagnostic()?;
let ron_pretty = ron::ser::PrettyConfig::default();
let ron = ron::ser::to_string_pretty(&list, ron_pretty).into_diagnostic()?;
let ron = ron::ser::to_string_pretty(&list, ron_pretty)
.into_diagnostic()?;
let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail")
@ -34,7 +37,8 @@ pub fn save(
"there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
);
temp_dir.push("lumina");
let mut s: String = iter::repeat_with(fastrand::alphanumeric).take(5).collect();
let mut s: String =
iter::repeat_with(fastrand::alphanumeric).take(5).collect();
s.insert_str(0, "temp_");
temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?;
@ -58,7 +62,9 @@ pub fn save(
}
match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => {
debug!("should have added serviceitems.ron to the file");
debug!(
"should have added serviceitems.ron to the file"
);
}
Err(e) => {
error!(?e);
@ -79,7 +85,7 @@ pub fn save(
Ok(())
};
for item in list.iter() {
for item in list {
let background;
let audio: Option<PathBuf>;
match &item.kind {
@ -88,18 +94,23 @@ pub fn save(
audio = song.audio.clone();
}
ServiceItemKind::Image(image) => {
background =
Some(Background::try_from(image.path.clone()).into_diagnostic()?);
background = Some(
Background::try_from(image.path.clone())
.into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Video(video) => {
background =
Some(Background::try_from(video.path.clone()).into_diagnostic()?);
background = Some(
Background::try_from(video.path.clone())
.into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Presentation(presentation) => {
background = Some(
Background::try_from(presentation.path.clone()).into_diagnostic()?,
Background::try_from(presentation.path.clone())
.into_diagnostic()?,
);
audio = None;
}
@ -120,11 +131,11 @@ pub fn save(
debug!(?path);
append_file(path)?;
}
for slide in &item.slides {
if let Some(svg) = &slide.text_svg
&& let Some(path) = &svg.path
for slide in item.slides {
if let Some(svg) = slide.text_svg
&& let Some(path) = svg.path
{
append_file(path.clone())?;
append_file(path)?;
}
}
}
@ -142,10 +153,12 @@ pub fn save(
#[allow(clippy::too_many_lines)]
pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let decoder =
Decoder::new(fs::File::open(&path).into_diagnostic()?).into_diagnostic()?;
Decoder::new(fs::File::open(&path).into_diagnostic()?)
.into_diagnostic()?;
let mut tar = Archive::new(decoder);
let mut cache_dir = dirs::cache_dir().expect("Should be a cache dir");
let mut cache_dir =
dirs::cache_dir().expect("Should be a cache dir");
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
@ -162,7 +175,8 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.to_os_string()
.into_string()
.expect("Should be fine");
let save_name = save_name_string.trim_end_matches(&format!(".{save_name_ext}"));
let save_name = save_name_string
.trim_end_matches(&format!(".{save_name_ext}"));
cache_dir.push(save_name);
if let Err(e) = fs::remove_dir_all(&cache_dir) {
@ -178,7 +192,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?;
let ron_file = dir
.find_map(|file| {
if file.as_ref().ok()?.path().extension()?.to_str()? == "ron" {
if file.as_ref().ok()?.path().extension()?.to_str()?
== "ron"
{
Some(file.ok()?.path())
} else {
None
@ -186,10 +202,12 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
})
.expect("Should have a ron file");
let ron_string = fs::read_to_string(ron_file).into_diagnostic()?;
let ron_string =
fs::read_to_string(ron_file).into_diagnostic()?;
let mut items =
ron::de::from_str::<Vec<ServiceItem>>(&ron_string).into_diagnostic()?;
ron::de::from_str::<Vec<ServiceItem>>(&ron_string)
.into_diagnostic()?;
for item in &mut items {
let dir = fs::read_dir(&cache_dir).into_diagnostic()?;
@ -197,20 +215,33 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
for slide in &mut item.slides {
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() {
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()));
} 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()
== 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()));
svg.handle =
Some(Handle::from_path(file.path()));
}
}
}
@ -219,7 +250,8 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Song(song) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
let audio_path = song.audio.clone().unwrap_or_default();
let audio_path =
song.audio.clone().unwrap_or_default();
if Some(file_name.as_os_str())
== song
.background
@ -229,11 +261,14 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.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.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());
}
}
@ -241,7 +276,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
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() {
if Some(file_name.as_os_str())
== video.path.file_name()
{
video.path = file.path();
}
}
@ -249,7 +286,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
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() {
if Some(file_name.as_os_str())
== image.path.file_name()
{
image.path = file.path();
}
}
@ -257,7 +296,9 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
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() {
if Some(file_name.as_os_str())
== presentation.path.file_name()
{
presentation.path = file.path();
}
}
@ -275,18 +316,20 @@ mod test {
use resvg::usvg::fontdb;
use super::*;
use crate::core::service_items::ServiceTrait;
use crate::core::slide::{Slide, TextAlignment};
use crate::core::songs::{Song, VerseName};
use crate::ui::text_svg::text_svg_generator;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::{
core::{
service_items::ServiceTrait,
slide::{Slide, TextAlignment},
songs::{Song, VerseName},
},
ui::text_svg::text_svg_generator,
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
fn test_song() -> Song {
let lyrics = "Some({Verse(number:4):\"Our Savior displayed\\nOn a criminal\\'s cross\\n\\nDarkness rejoiced as though\\nHeaven had lost\\n\\nBut then Jesus arose\\nWith our freedom in hand\\n\\nThat\\'s when death was arrested\\nAnd my life began\\n\\nThat\\'s when death was arrested\\nAnd my life began\",Intro(number:1):\"Death Was Arrested\\nNorth Point Worship\",Verse(number:3):\"Released from my chains,\\nI\\'m a prisoner no more\\n\\nMy shame was a ransom\\nHe faithfully bore\\n\\nHe cancelled my debt and\\nHe called me His friend\\n\\nWhen death was arrested\\nAnd my life began\",Bridge(number:1):\"Oh, we\\'re free, free,\\nForever we\\'re free\\n\\nCome join the song\\nOf all the redeemed\\n\\nYes, we\\'re free, free,\\nForever amen\\n\\nWhen death was arrested\\nAnd my life began\\n\\nOh, we\\'re free, free,\\nForever we\\'re free\\n\\nCome join the song\\nOf all the redeemed\\n\\nYes, we\\'re free, free,\\nForever amen\\n\\nWhen death was arrested\\nAnd my life began\",Other(number:99):\"When death was arrested\\nAnd my life began\\n\\nThat\\'s when death was arrested\\nAnd my life began\",Verse(number:2):\"Ash was redeemed\\nOnly beauty remains\\n\\nMy orphan heart\\nWas given a name\\n\\nMy mourning grew quiet,\\nMy feet rose to dance\\n\\nWhen death was arrested\\nAnd my life began\",Verse(number:1):\"Alone in my sorrow\\nAnd dead in my sin\\n\\nLost without hope\\nWith no place to begin\\n\\nYour love made a way\\nTo let mercy come in\\n\\nWhen death was arrested\\nAnd my life began\",Chorus(number:1):\"Oh, Your grace so free,\\nWashes over me\\n\\nYou have made me new,\\nNow life begins with You\\n\\nIt\\'s Your endless love,\\nPouring down on us\\n\\nYou have made us new,\\nNow life begins with You\"})".to_string();
let verse_map: Option<HashMap<VerseName, String>> =
ron::from_str(&lyrics).expect("");
ron::from_str(&lyrics).unwrap();
Song {
id: 7,
title: "Death Was Arrested".to_string(),
@ -297,7 +340,7 @@ mod test {
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/presentations/mb/Geo Square.mp4").expect("")),
background: Some(Background::try_from("/home/chris/nc/tfc/openlp/Flood/motions/Ocean_Floor_HD.mp4").unwrap()),
text_alignment: Some(TextAlignment::MiddleCenter),
font: None,
font_size: Some(120),
@ -319,12 +362,20 @@ mod test {
let fontdb = Arc::new(fontdb);
let slides = song
.to_slides()
.expect("")
.unwrap()
.into_par_iter()
.map(|slide| {
text_svg_generator(slide, &Arc::clone(&fontdb)).unwrap_or_else(|e| {
panic!("Couldn't create svg: {e}");
})
text_svg_generator(
slide.clone(),
&Arc::clone(&fontdb),
)
.map_or_else(
|e| {
assert!(false, "Couldn't create svg: {e}");
slide
},
|slide| slide,
)
})
.collect::<Vec<Slide>>();
let items = vec![
@ -340,7 +391,7 @@ mod test {
kind: ServiceItemKind::Song(song),
id: 1,
title: "Death was Arrested".into(),
slides,
slides: slides,
},
];
items
@ -353,7 +404,7 @@ mod test {
let result = load(&path);
match result {
Ok(items) => {
assert!(!items.is_empty());
assert!(items.len() > 0);
// assert_eq!(items, get_items());
let cache_dir = cache_dir();
assert!(fs::read_dir(&cache_dir).is_ok());
@ -364,58 +415,37 @@ mod test {
find_svgs(&items)?;
Ok(())
}
Err(e) => Err(format!("Error in the loading process: {e}")),
Err(e) => Err(e.to_string()),
}
}
fn test_size_and_cache(mut path: PathBuf) -> Result<(), String> {
fn find_svgs(items: &Vec<ServiceItem>) -> Result<(), String> {
let cache_dir = cache_dir();
if path.metadata().expect("").len() < 15000 {
return Err(String::from(
"SVG text is too small, maybe the svg didn't generate properly",
));
}
if path.pop() && path == cache_dir {
Ok(())
} else {
Err(String::from(
"The path of the TextSvg isn't in the load directory",
))
}
}
fn find_svgs(items: &[ServiceItem]) -> Result<(), String> {
items.iter().try_for_each(|item| {
if let ServiceItemKind::Song(..) = item.kind {
item.slides.iter().try_for_each(|slide| {
slide.text_svg.as_ref().map_or_else(
|| Err(String::from("There is no TextSvg for this song")),
|text_svg| {
if text_svg.handle.is_none() {
return Err(String::from(
"There is no handle in this song's TextSvg",
));
}
slide.text_svg.as_ref().map_or(Err(String::from("There is no TextSvg for this song")), |text_svg| {
text_svg.path.as_ref().map_or_else(
|| {
Err(String::from(
"There is no path in this song's TextSvg",
))
},
|path| {
if path.exists() {
test_size_and_cache(path.clone())
} else {
Err(String::from(
"The path in this TextSvg doesn't exist",
))
}
},
)
},
)
if text_svg.handle.is_none() {
return Err(String::from("There is no handle in this song's TextSvg"));
};
text_svg.path.as_ref().map_or(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(())
@ -424,20 +454,20 @@ mod test {
}
// checks to make sure all paths in slides and items point to cache_dir
fn find_paths(items: &[ServiceItem]) -> bool {
fn find_paths(items: &Vec<ServiceItem>) -> bool {
let cache_dir = cache_dir();
items.iter().all(|item| {
match &item.kind {
ServiceItemKind::Song(song) => {
if let Some(bg) = &song.background
&& !bg.path.starts_with(&cache_dir)
{
return false;
if let Some(bg) = &song.background {
if !bg.path.starts_with(&cache_dir) {
return false;
}
}
if let Some(audio) = &song.audio
&& !audio.starts_with(&cache_dir)
{
return false;
if let Some(audio) = &song.audio {
if !audio.starts_with(&cache_dir) {
return false;
}
}
}
ServiceItemKind::Video(video) => {
@ -461,10 +491,9 @@ mod test {
if !slide.background().path.starts_with(&cache_dir) {
return false;
}
if !slide
.audio()
.is_none_or(|audio| audio.starts_with(&cache_dir))
{
if !slide.audio().map_or(true, |audio| {
audio.starts_with(&cache_dir)
}) {
return false;
}
}
@ -473,7 +502,7 @@ mod test {
}
fn cache_dir() -> PathBuf {
let mut cache_dir = dirs::cache_dir().expect("");
let mut cache_dir = dirs::cache_dir().unwrap();
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
cache_dir.push("test");
@ -484,18 +513,22 @@ mod test {
fn test_save() {
let path = PathBuf::from("./test.pres");
let list = get_items();
match save(&Arc::new(list), &path, true) {
Ok(()) => {
match save(list, &path, true) {
Ok(_) => {
assert!(path.is_file());
let Ok(file) = fs::File::open(path) else {
panic!("couldn't open file");
return assert!(false, "couldn't open file");
};
let Ok(size) = file.metadata().map(|data| data.len()) else {
panic!("couldn't get file metadata");
let Ok(size) = file.metadata().map(|data| data.len())
else {
return assert!(
false,
"couldn't get file metadata"
);
};
assert!(size > 0);
}
Err(e) => panic!("{e}"),
Err(e) => assert!(false, "{e}"),
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

2249
src/main.rs Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,10 @@ pub mod library;
pub mod presentation_editor;
pub mod presenter;
// pub mod service;
pub mod gst_video;
pub mod image_loader;
pub mod slide_editor;
pub mod song_editor;
pub mod text_svg;
pub mod video;
pub mod video_editor;
pub mod widgets;

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

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

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

BIN
test.db

Binary file not shown.

View file

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