Compare commits

..

4 commits

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

View file

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

2
.envrc
View file

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

View file

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

View file

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

View file

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

18
.gitignore vendored
View file

@ -3,21 +3,3 @@
.sqlx
.env
data.db
/flamegraph.svg
/.zed/
/perf.data
/perf.data.old
.aider*
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/

7157
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,137 +7,56 @@ description = "A cli presentation system"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.5.20", features = ["derive"] }
clap = { version = "4.5.20", features = ["debug", "derive"] }
# libcosmic = { git = "https://github.com/pop-os/libcosmic", default-features = false, features = ["debug", "winit", "desktop", "winit_wgpu", "winit_tokio", "tokio", "rfd", "dbus-config", "a11y", "wgpu", "multi-window"] }
lexpr = "0.2.7"
miette = { version = "7.2.0", features = ["fancy"] }
pretty_assertions = "1.4.1"
serde = { version = "1.0.213", features = ["derive"] }
serde-lexpr = "0.1.3"
tracing = "0.1.40"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "chrono", "time", "local-time", "env-filter"] }
strum = "0.26.3"
strum_macros = "0.26.4"
ron = "0.8.1"
sqlx = { version = "0.9", features = ["sqlite", "sqlite-deserialize", "runtime-tokio", "chrono"] }
dirs = "6.0.0"
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio"] }
dirs = "5.0.1"
tokio = "1.41.1"
crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" }
rodio = { version = "0.20.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"] }
# colors-transform = "0.2.11"
url = "2"
colors-transform = "0.2.11"
rayon = "1.11.0"
resvg_exposed = "0.47.0"
image = "0.25.8"
rapidhash = "4.0.0"
rapidfuzz = "0.5.0"
# dragking = { git = "https://github.com/airstrike/dragking" }
# resvg = "0.45.1"
# 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"] }
tar = "0.4.44"
zstd = "0.13.3"
fastrand = "2.3.0"
obws = "0.14.0"
derive_more = { version = "2.1.1", features = ["debug"] }
reqwest = "0.13.1"
scraper = "0.25.0"
itertools = "0.14.0"
serde_json = "1.0.149"
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.12.1", features = ["xdg-portal"], default-features = false }
derive_setters = "0.1.8"
freedesktop-icons = "0.4.0"
# 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",]
[dependencies.iced]
git = "https://github.com/iced-rs/iced"
branch = "master"
features = ["wgpu", "image", "advanced", "svg", "canvas", "hot", "debug", "lazy", "tokio"]
[dependencies.iced_video_player]
git = "https://github.com/wash2/iced_video_player.git"
branch = "iced-rebase"
features = ["wgpu"]
git = "https://git.tfcconnection.org/chris/iced_video_player"
branch = "master"
# branch = "cosmic"
# [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"

208
TODO.org
View file

@ -1,208 +0,0 @@
#+TITLE: The Task list for Lumina
#+FILETAGS: :project:dev:
#+CATEGORY: dev
* TODO [#A] Deployment pipeline and get a MVP going
* 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.
* 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 [#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 [#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.
After looking more and more at how the orgize docs describe things and the testing platform found at: https://poiscript.github.io/orgize/ I believe this will work. The main things are that I can possibly decide how to interpret certain pieces of orgdown to mean certain things in lumina. Essentially a properties drawer or tag can indicate backgrounds and other info for the slides or songs and then the notes blocks can indicate text that shouldn't be printed into the slide, thus allowing a single orgdown document to illustrate both an entire presentation, but also the notes and plan for the presenter.
I could potentially do the same with markdown, but since this is for me first, I'll use orgdown because I enjoy the syntax a lot more.
* TODO [#C] Allow for a way to split the presentation up with a right click menu for the presentation preview row.
* TODO [#C] Text could be built by using SVG instead of the text element. Maybe I could construct my own text element even
This does almost work. There is a clear amount of lag or rather hang up since switching to the =text_svg= element. I think I may only keep it till I can figure out how to do strokes and shadows in iced's normal text element.
Actually, what if we just made the svg at load/creation time and stored it in the file system for later, then load the entire songs svg's into memory during the presentation to speed things up? Would that be faster than creating them at on the fly? Is it the creation of them that is slow or the rendering?
** SVG performs badly
Since SVG's apparently run poorly in iced, instead I'll need to see about either creating a new text element, or teaching Iced to render strokes and shadows on text.
** Fork Cryoglyph
This fork will render text 3 times. Once for the text, once for the stroke, once for the shadow. This will only be used in the slides and therefore should not be much of a performance hit since we will only be render 3 copies of the given text. This should not be bad performance since it's not a large amount of text.
This also means in our custom widget with our custom fork, we can animate each individually perhaps.
** Actually.....
I tried out a way of generating the svg and rasterizing it ahead of time and then storing it in the file system to be cached. This works out very well. The text is one whole image for a slides text that gets layered on top of the background, but it works out well for now.
The problem with this approach is that every change to a song's text or font metrics means we need to rebuild all the text items for that song. I need to think of a way for the text generation to be done asynchronously so that the ui isn't locked up.
I bet this is tricking up the loading mechanism. Loading only grabs all the backgrounds and audio pieces, not the text_svg pieces. So maybe it should so that the generator can run again and grab the same pieces from the filesystem rather than recreate them. This gets extra tricky because we may have fonts that are missing when loading a file. In such a case the loading mechanism ought to suggest to the user to grab those fonts and then perhaps load the cached file while being extra clear that any changes will mess up the text since they no longer possess the font that is in the loaded file. Maybe what we can do is during save, save a copy of all the fonts as well and then during load check to see if the computer has them, if they don't offer to install them on the spot such that they can use the font as is. I wonder if we are allowed to pass fonts around that way.
** Made this slightly faster
Since strings are allocated on the heap, I've changed how to construct the svg string a bit, but honestly, it doesn't matter too much because most of the performance cost seems to be in rendering the string using resvg. So, this can still be something that get's fixed later, and I believe that fix will come in the form of a multi-channel signed distance field wgpu rendered text eventually. We can work on this much later though.
* TODO [#C] Make the presenter more modular so things are easier to change. This is vague...
* 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.
* DONE [#A] Create a view of all slides in a PDF presenation
* DONE [#A] Develop DnD for library items
This is limited by the fact that I need to develop this in cosmic. I am honestly thinking that I'll need to build my own drag and drop system or at least work with system76 to fix their dnd system on other systems.
This needs lots more attention
* DONE [#A] File saving and loading
Need to make sure we can save a file with all files archived in it and load it back up.
This is giving me a lot of thoughts...
1. That saving and loading needs to know about fonts as well.
2. That TextSvgs should likely be saved as well since the other machines may not always have the same fonts.
3. That means that TextSvg should have a path option that could hold the cached svg that has already been rendered and that this gets changed to the loaded files directory rather than using the default cache directory.
* DONE [#A] Add removal and reordering of service_items
Reordering is finished
* DONE [#A] Change return type of all components to an Action enum instead of the Task<Message> type [0%] [0/0]
** DONE Library
** DONE SongEditor
** DONE Presenter
* DONE [#A] Need to fix tests now that the basic app is working
Lots of them have been tweaked to be completing now, but there is more work to do and several need to likely be a lot more robust.
Still failing 4 tests, all to do with the db or lisp. I might throw out the lisp code at some point tho. I keep thinking that a better alternative would be to have a markdown serialization system such that you can write slides in markdown somehow and they would be able to be loaded.
* DONE [#A] Make sure updating verse updates the lyrics too
[[file:~/dev/lumina-iced/src/core/songs.rs::old_verse = verse;]]
This is necessary so that the entire song gets changed and we can propogate those changes then back to the db.
There is likely some work that still needs to be done here, I believe I am somehow deleting some of my verses.
* DONE [#A] Need to fixup how songs are edited in the editors
Currently the song is cloned many times to pass around and then finally get updated in DB. Instead, we need to edit the song directly in the editor and after it's been changed appropriatel, run the update_song method to get the current song and create slides from it and then update it in the DB.
* DONE 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
* DONE Move text_generation function to be asynchronous so that UI doesn't lock up during song editing.
* DONE Build a presentation editor
* DONE Build library to see all available songs, images, videos, presentations, and slides
** DONE Develop ui for libraries
I've got the library basic layer done, I need to develop a way to open the libraries accordion button and then show the list of items in the library
** DONE Need to do search and creation systems yet
* DONE [#B] Build editors for each possible item
** DONE Develop ui for editors
* DONE [#B] Find a way to load and discover every font on the system for slide building
This may not be necessary since it is possible to create a font using =Box::leak()=.
#+begin_src rust
let font = self.current_slide.font().into_boxed_str();
let family = Family::Name(Box::leak(font));
let weight = Weight::Normal;
let stretch = Stretch::Normal;
let style = Style::Normal;
let font = Font {
family,
weight,
stretch,
style,
};
#+end_src
This code creates a font by leaking the Box to a ='static &str=. I just am not sure if the &str stays around in memory after the view function. If it does, then it's not on the stack anymore and should be fine, but if it isn't cleaned up then we will have a memory leak.
Krimzin on Discord told me that maybe the =update= method is a better place for this Box to be created or updated and then maybe I could generate the view from there.
* DONE Build an image editor
* DONE Use Rich Text instead of normal text for slides
This will make it so that we can add styling to the text like borders and backgrounds or highlights. Maybe in the future it'll add shadows too.
* DONE Build a video editor
* DONE Check into =mupdf-rs= for loading PDF's.
* DONE Build Menu
* DONE Find a way for text to pass through a service item to a slide i.e. content piece
This proved easier by just creating the =Slide= first and inserting it into the =ServiceItem=.
* 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

83
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": 1755585599,
"narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=",
"owner": "nix-community",
"repo": "fenix",
"rev": "5c80141c6215ed0a1cdc06ddb68e9bb55e9edfca",
"rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42",
"type": "github"
},
"original": {
@ -80,11 +65,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1778151388,
"narHash": "sha256-lldMJPUeouEjO8/7aLuwhcsIw29vVihm2ZALzjiqfec=",
"lastModified": 1752689277,
"narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=",
"owner": "nix-community",
"repo": "naersk",
"rev": "efdddff9ff4d8e7d0056d57ec67dac50f75ab8f6",
"rev": "0e72363d0938b0208d6c646d10649164c43f4d64",
"type": "github"
},
"original": {
@ -95,11 +80,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"lastModified": 1755186698,
"narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c",
"type": "github"
},
"original": {
@ -127,11 +112,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"lastModified": 1755615617,
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"rev": "20075955deac2583bb12f07151c2df830ef346b4",
"type": "github"
},
"original": {
@ -141,40 +126,22 @@
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
"nixpkgs": "nixpkgs_3"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1778611623,
"narHash": "sha256-oNgaKN3iKM1Cud3bKhEXFHXNRRc+j/JDl05d2jYa2Sg=",
"lastModified": 1755504847,
"narHash": "sha256-VX0B9hwhJypCGqncVVLC+SmeMVd/GAYbJZ0MiiUn2Pk=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "7c28934677b1e7a1c6ef952422e6ef730540f85f",
"rev": "a905e3b21b144d77e1b304e49f3264f6f8d4db75",
"type": "github"
},
"original": {
@ -201,24 +168,6 @@
"type": "github"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1778642276,
"narHash": "sha256-bhk4lawR4ZnFhPtamB5WkCyvfgyZmsEUbWfT/3FRxFY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "77265d2dc1e61b2abfd3b1d6609dbb66fe75e0a5",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,

252
flake.nix
View file

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

View file

@ -1,94 +1,24 @@
verbose := "-v"
ui := "-i"
file := "~/dev/lumina-iced/test_presentation.lisp"
sdk-version := "25.08"
# export RUSTC_WRAPPER := "sccache"
# export RUST_LOG := "debug"
default:
just --list
build:
cargo build
build-release:
cargo build --release
build-offline:
cargo build --release --offline
RUST_LOG=debug cargo build
sbuild:
RUST_LOG=debug sccache cargo build
run:
cargo run -- {{verbose}}
run-release:
cargo run --release -- {{verbose}}
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
RUST_LOG=debug cargo run -- {{ui}} {{file}}
srun:
RUST_LOG=debug sccache cargo run -- {{ui}} {{file}}
clean:
cargo clean
watch-clippy:
cargo watch --why -x "clippy --all-targets --all-features"
RUST_LOG=debug cargo clean
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
bench:
export NEXTEST_EXPERIMENTAL_BENCHMARKS=1
cargo nextest bench
RUST_LOG=debug cargo test --benches --tests --all-features -- --nocapture
profile:
samply record cargo run --release -- {{verbose}}
cargo flamegraph --image-width 8000 -- {{ui}} {{file}}
alias b := build
alias r := run
alias br := build-release
alias rr := run-release
alias rf := run-file
alias sr := srun
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,3 +0,0 @@
-- Add migration script here
ALTER TABLE presentations
DROP COLUMN pageCount;

View file

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

View file

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

View file

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

View file

@ -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,19 +3,15 @@
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?
Well for one, I want more experience developing things and I don't have a good tool for this kind of thing on Linux.
Primarily, I don't think there is a good tool for this kind of thing on Linux. On Windows and Mac there is ProPresenter or Proclaim. Both amazing presentation software built for churches or worship centers and can be used by others for other things too, but incredible tools. I want to have a similar tool on Linux. The available tools out there now are often old, broken, or very difficult to use. I want something incredibly easy, with very sane or at least very customizable keyboard controls that allow me to quickly build a presentation and make it VERY easy to run it too.
** Features (planned are in parentheses)
- Presents songs lyrics with image and video backgrounds
- Simple song creation with a powerful text parser
- Present Slides. PDF works. (PowerPoint, and Impress are in not implemented yet)
- Present Slides (PDF, PowerPoint, and Impress are in not implemented yet)
- (Present Reveal.js slides)
- (Custom slide builder)
- (an intuitive UI) - Still needs A LOT of polish

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

View file

@ -1,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

View file

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

Before

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

View file

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

Before

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

Binary file not shown.

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 573 B

View file

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

Before

Width:  |  Height:  |  Size: 976 B

View file

@ -1 +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

Binary file not shown.

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
style_edition = "2024"
# version = "Two"
imports_granularity = "Module"
max_width = 70
# style_edition = "2018"
# 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,501 +1,356 @@
use crate::core::kinds::ServiceItemKind;
use crate::core::service_items::ServiceItem;
use crate::core::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 tar::{Archive, Builder};
use tracing::{debug, error};
use zstd::{Decoder, Encoder};
use tracing::error;
use zstd::Encoder;
use std::{fs::{self, File}, iter, path::{Path, PathBuf}};
use color_eyre::eyre::{eyre, Context, Result};
use serde_json::Value;
use sqlx::{query, query_as, FromRow, SqliteConnection};
use crate::{images::{get_image_from_db, Image}, kinds::ServiceItemKind, model::get_db, presentations::{get_presentation_from_db, PresKind, Presentation}, service_items::ServiceItem, slides::Background, songs::{get_song_from_db, Song}, videos::{get_video_from_db, Video}};
#[allow(clippy::too_many_lines)]
pub fn save(
list: &Arc<Vec<ServiceItem>>,
path: impl AsRef<Path>,
overwrite: bool,
) -> Result<()> {
pub async fn save(list: Vec<ServiceItem>, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if overwrite && path.exists() {
fs::remove_file(path).into_diagnostic()?;
}
let save_file = File::create(path).into_diagnostic()?;
let ron_pretty = ron::ser::PrettyConfig::default();
let ron = ron::ser::to_string_pretty(&list, ron_pretty).into_diagnostic()?;
let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail")
.auto_finish();
let mut tar = Builder::new(encoder);
let mut temp_dir = dirs::data_dir().expect(
"there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
);
temp_dir.push("lumina");
let mut s: String = iter::repeat_with(fastrand::alphanumeric).take(5).collect();
s.insert_str(0, "temp_");
temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?;
let service_file = temp_dir.join("serviceitems.ron");
debug!(?service_file);
fs::File::create(&service_file).into_diagnostic()?;
match fs::File::options()
.read(true)
.write(true)
.open(service_file)
{
Ok(mut f) => {
match f.write(ron.as_bytes()) {
Ok(size) => {
debug!(size);
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => {
debug!("should have added serviceitems.ron to the file");
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
}
Err(e) => {
error!("There were problems making a file i guess: {e}");
return Err(miette!("There was a problem: {e}"));
}
}
let mut append_file = |path: PathBuf| -> Result<()> {
let file_name = path.file_name().unwrap_or_default();
let mut file = fs::File::open(&path).into_diagnostic()?;
tar.append_file(file_name, &mut file).into_diagnostic()?;
Ok(())
};
for item in list.iter() {
let background;
let audio: Option<PathBuf>;
match &item.kind {
ServiceItemKind::Song(song) => {
background = song.background.clone();
audio = song.audio.clone();
}
ServiceItemKind::Image(image) => {
background =
Some(Background::try_from(image.path.clone()).into_diagnostic()?);
audio = None;
}
ServiceItemKind::Video(video) => {
background =
Some(Background::try_from(video.path.clone()).into_diagnostic()?);
audio = None;
}
ServiceItemKind::Presentation(presentation) => {
background = Some(
Background::try_from(presentation.path.clone()).into_diagnostic()?,
);
audio = None;
}
ServiceItemKind::Content(_slide) => {
todo!()
}
}
if let Some(path) = audio
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
if let Some(background) = background
&& let path = background.path
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
for slide in &item.slides {
if let Some(svg) = &slide.text_svg
&& let Some(path) = &svg.path
{
append_file(path.clone())?;
}
}
}
match tar.finish() {
Ok(()) => (),
Err(e) => {
error!(?e);
return Err(miette!("tar error: {e}"));
}
}
fs::remove_dir_all(temp_dir).into_diagnostic()
let save_file = File::create(path)?;
let mut db = get_db().await;
let json = process_service_items(&list, &mut db).await?;
let archive = store_service_items(&list, &mut db, &save_file, &json).await?;
Ok(())
}
#[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()?;
let mut tar = Archive::new(decoder);
let mut cache_dir = dirs::cache_dir().expect("Should be a cache dir");
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
let save_name_ext = path
.as_ref()
.extension()
.expect("Should have extension")
.to_str()
.expect("Should be fine");
let save_name_string = path
.as_ref()
.file_name()
.expect("Should be a name")
.to_os_string()
.into_string()
.expect("Should be fine");
let save_name = save_name_string.trim_end_matches(&format!(".{save_name_ext}"));
cache_dir.push(save_name);
if let Err(e) = fs::remove_dir_all(&cache_dir) {
debug!("There is no dir here: {e}");
}
fs::create_dir_all(&cache_dir).into_diagnostic()?;
for entry in tar.entries().into_diagnostic()? {
let mut entry = entry.into_diagnostic()?;
entry.unpack_in(&cache_dir).into_diagnostic()?;
}
let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?;
let ron_file = dir
.find_map(|file| {
if file.as_ref().ok()?.path().extension()?.to_str()? == "ron" {
Some(file.ok()?.path())
async fn store_service_items(items: &Vec<ServiceItem>, db: &mut SqliteConnection, save_file: &File, json: &Value) -> Result<()> {
let encoder = Encoder::new(save_file, 3).unwrap();
let mut tar = Builder::new(encoder);
let mut temp_dir = dirs::data_dir().unwrap();
temp_dir.push("lumina");
let mut s: String =
iter::repeat_with(fastrand::alphanumeric)
.take(5)
.collect();
s.insert_str(0, "temp_");
temp_dir.push(s);
fs::create_dir_all(&temp_dir)?;
let service_file = temp_dir.join("serviceitems.json");
fs::File::create(&service_file)?;
match fs::File::options().read(true).write(true).open(service_file) {
Ok(f) => {
serde_json::to_writer_pretty(f, json)?;
},
Err(e) => error!("There were problems making a file i guess: {e}"),
};
for item in items {
let background;
let audio: Option<PathBuf>;
match item.kind {
ServiceItemKind::Song => {
let song = get_song_from_db(item.database_id, db).await?;
background = song.background;
audio = song.audio;
},
ServiceItemKind::Image => {
let image = get_image_from_db(item.database_id, db).await?;
background = Some(Background::try_from(image.path)?);
audio = None;
},
ServiceItemKind::Video => {
let video = get_video_from_db(item.database_id, db).await?;
background = Some(Background::try_from(video.path)?);
audio = None;
},
ServiceItemKind::Presentation(_) => {
let presentation = get_presentation_from_db(item.database_id, db).await?;
background = Some(Background::try_from(presentation.path)?);
audio = None;
},
ServiceItemKind::Content => {
todo!()
},
};
if let Some(file) = audio {
let audio_file = temp_dir.join(file.file_name().expect("Audio file couldn't be added to temp_dir"));
if let Ok(file) = file.strip_prefix("file://") {
fs::File::create(&audio_file).wrap_err("Couldn't create audio file")?;
fs::copy(file, audio_file).wrap_err("Audio file could not be copied, the source file doesn't exist not be found");
} else {
None
fs::File::create(&audio_file).wrap_err("Couldn't create audio file")?;
fs::copy(file, audio_file).wrap_err("Audio file could not be copied, the source file doesn't exist not be found");
}
})
.expect("Should have a ron file");
let ron_string = fs::read_to_string(ron_file).into_diagnostic()?;
let mut items =
ron::de::from_str::<Vec<ServiceItem>>(&ron_string).into_diagnostic()?;
for item in &mut items {
let dir = fs::read_dir(&cache_dir).into_diagnostic()?;
for file in dir {
for slide in &mut item.slides {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
let audio_path = slide.audio().clone().unwrap_or_default();
let text_path =
slide.text_svg.as_ref().and_then(|svg| svg.path.clone());
if Some(file_name.as_os_str()) == slide.background.path.file_name() {
slide.background.path = file.path();
} else if Some(file_name.as_os_str()) == audio_path.file_name() {
let new_slide = slide.clone().set_audio(Some(file.path()));
*slide = new_slide;
} else if Some(file_name.as_os_str())
== text_path.clone().unwrap_or_default().file_name()
&& let Some(svg) = slide.text_svg.as_mut()
{
svg.path = Some(file.path());
svg.handle = Some(Handle::from_path(file.path()));
}
}
}
match &mut item.kind {
ServiceItemKind::Song(song) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
let audio_path = song.audio.clone().unwrap_or_default();
if Some(file_name.as_os_str())
== song
.background
.clone()
.unwrap_or_default()
.path
.file_name()
{
let background = song.background.clone();
song.background = background.map(|mut background| {
background.path = file.path();
background
});
} else if Some(file_name.as_os_str()) == audio_path.file_name() {
song.audio = Some(file.path());
}
}
}
ServiceItemKind::Video(video) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str()) == video.path.file_name() {
video.path = file.path();
}
}
}
ServiceItemKind::Image(image) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str()) == image.path.file_name() {
image.path = file.path();
}
}
}
ServiceItemKind::Presentation(presentation) => {
if let Ok(file) = file.as_ref() {
let file_name = file.file_name();
if Some(file_name.as_os_str()) == presentation.path.file_name() {
presentation.path = file.path();
}
}
}
ServiceItemKind::Content(_slide) => todo!(),
};
if let Some(file) = background {
let background_file = temp_dir.join(file.path.file_name().expect("Background file couldn't be added to temp_dir"));
if let Ok(file) = file.path.strip_prefix("file://") {
fs::File::create(&background_file).wrap_err("Couldn't create background file")?;
fs::copy(file, background_file).wrap_err("Background file could not be copied, the source file doesn't exist not be found");
} else {
fs::File::create(&background_file).wrap_err("Couldn't create background file")?;
fs::copy(file.path, background_file).wrap_err("Background file could not be copied, the source file doesn't exist not be found");
}
}
}
Ok(items)
Ok(())
}
async fn clear_temp_dir(temp_dir: &Path) -> Result<()> {
todo!()
}
async fn process_service_items(items: &Vec<ServiceItem>, db: &mut SqliteConnection) -> Result<Value> {
let mut values: Vec<Value> = vec![];
for item in items {
match item.kind {
ServiceItemKind::Song => {
let value = process_song(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Image => {
let value = process_image(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Video => {
let value = process_video(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Presentation(_) => {
let value = process_presentation(item.database_id, db).await?;
values.push(value);
},
ServiceItemKind::Content => {
todo!()
},
}
}
let json = Value::from(values);
Ok(json)
}
async fn process_song(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let song = get_song_from_db(database_id, db).await?;
let song_json = serde_json::to_value(&song)?;
let kind_json = serde_json::to_value(ServiceItemKind::Song)?;
let json = serde_json::json!({"item": song_json, "kind": kind_json});
Ok(json)
}
async fn process_image(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let image = get_image_from_db(database_id, db).await?;
let image_json = serde_json::to_value(&image)?;
let kind_json = serde_json::to_value(ServiceItemKind::Image)?;
let json = serde_json::json!({"item": image_json, "kind": kind_json});
Ok(json)
}
async fn process_video(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let video = get_video_from_db(database_id, db).await?;
let video_json = serde_json::to_value(&video)?;
let kind_json = serde_json::to_value(ServiceItemKind::Video)?;
let json = serde_json::json!({"item": video_json, "kind": kind_json});
Ok(json)
}
async fn process_presentation(database_id: i32, db: &mut SqliteConnection) -> Result<Value> {
let presentation = get_presentation_from_db(database_id, db).await?;
let presentation_json = serde_json::to_value(&presentation)?;
let kind_json = match presentation.kind {
PresKind::Html => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Html))?,
PresKind::Pdf => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Pdf))?,
PresKind::Generic => serde_json::to_value(ServiceItemKind::Presentation(PresKind::Generic))?,
};
let json = serde_json::json!({"item": presentation_json, "kind": kind_json});
Ok(json)
}
#[cfg(test)]
mod test {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
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;
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("");
Song {
id: 7,
title: "Death Was Arrested".to_string(),
lyrics: Some(lyrics),
author: Some(
"North Point Worship".to_string(),
),
ccli: None,
audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()),
verse_order: Some(vec!["Some([Chorus(number:1),Intro(number:1),Other(number:99),Bridge(number:1),Verse(number:4),Verse(number:2),Verse(number:3),Verse(number:1)])".to_string()]),
background: Some(Background::try_from("/home/chris/nc/tfc/presentations/mb/Geo Square.mp4").expect("")),
text_alignment: Some(TextAlignment::MiddleCenter),
font: None,
font_size: Some(120),
font_style: None,
font_weight: None,
text_color: None,
stroke_size: None,
verses: Some(vec![VerseName::Chorus { number: 1 }, VerseName::Intro { number: 1 }, VerseName::Other { number: 99 }, VerseName::Bridge { number: 1 }, VerseName::Verse { number: 4 }, VerseName::Verse { number: 2 }, VerseName::Verse { number: 3 }, VerseName::Verse { number: 1 }
]),
verse_map,
..Default::default()
use fs::canonicalize;
use sqlx::Connection;
use pretty_assertions::assert_eq;
use tracing::debug;
use super::*;
async fn get_db() -> SqliteConnection {
let mut data = dirs::data_local_dir().unwrap();
data.push("lumina");
data.push("library-db.sqlite3");
let mut db_url = String::from("sqlite://");
db_url.push_str(data.to_str().unwrap());
SqliteConnection::connect(&db_url)
.await
.expect("problems")
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_song() {
let mut db = get_db().await;
let result = process_song(7, &mut db).await;
let json_song_file = PathBuf::from("./test/test_song.json");
if let Ok(path) = canonicalize(json_song_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the song: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_song.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_image() {
let mut db = get_db().await;
let result = process_image(3, &mut db).await;
let json_image_file = PathBuf::from("./test/test_image.json");
if let Ok(path) = canonicalize(json_image_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the image: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_image.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_video() {
let mut db = get_db().await;
let result = process_video(73, &mut db).await;
let json_video_file = PathBuf::from("./test/test_video.json");
if let Ok(path) = canonicalize(json_video_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the video: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_video.json");
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_process_presentation() {
let mut db = get_db().await;
let result = process_presentation(54, &mut db).await;
let json_presentation_file = PathBuf::from("./test/test_presentation.json");
if let Ok(path) = canonicalize(json_presentation_file) {
debug!(file = ?&path);
if let Ok(s) = fs::read_to_string(path) {
debug!(s);
match result {
Ok(json) => assert_eq!(json.to_string(), s),
Err(e) => panic!("There was an error in processing the presentation: {e}"),
}
} else {
panic!("String wasn't read from file");
}
} else {
panic!("Cannot find absolute path to test_presentation.json");
}
}
fn get_items() -> Vec<ServiceItem> {
let song = test_song();
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let fontdb = Arc::new(fontdb);
let slides = song
.to_slides()
.expect("")
.into_par_iter()
.map(|slide| {
text_svg_generator(slide, &Arc::clone(&fontdb)).unwrap_or_else(|e| {
panic!("Couldn't create svg: {e}");
})
})
.collect::<Vec<Slide>>();
let items = vec![
ServiceItem {
database_id: 7,
kind: ServiceItemKind::Song(song.clone()),
kind: ServiceItemKind::Song,
id: 0,
title: "Death was Arrested".into(),
slides: slides.clone(),
},
ServiceItem {
database_id: 7,
kind: ServiceItemKind::Song(song),
id: 1,
title: "Death was Arrested".into(),
slides,
database_id: 54,
kind: ServiceItemKind::Presentation(PresKind::Html),
id: 0,
},
ServiceItem {
database_id: 73,
kind: ServiceItemKind::Video,
id: 0,
},
];
items
}
#[test]
fn test_load() -> Result<(), String> {
test_save();
let path = PathBuf::from("./test.pres");
let result = load(&path);
match result {
Ok(items) => {
assert!(!items.is_empty());
// assert_eq!(items, get_items());
let cache_dir = cache_dir();
assert!(fs::read_dir(&cache_dir).is_ok());
assert!(
find_paths(&items),
"Some paths must not have the cache_dir in it's path"
);
find_svgs(&items)?;
Ok(())
#[tokio::test]
async fn test_service_items() {
let mut db = get_db().await;
let items = get_items();
let json_item_file = PathBuf::from("./test/test_service_items.json");
let result = process_service_items(&items, &mut db).await;
if let Ok(path) = canonicalize(json_item_file) {
if let Ok(s) = fs::read_to_string(path) {
match result {
Ok(strings) => assert_eq!(strings.to_string(), s),
Err(e) => panic!("There was an error: {e}"),
}
}
Err(e) => Err(format!("Error in the loading process: {e}")),
}
}
fn test_size_and_cache(mut path: PathBuf) -> Result<(), String> {
let cache_dir = cache_dir();
// #[tokio::test]
// async fn test_save() {
// let path = PathBuf::from("~/dev/lumina/src/rust/core/test.pres");
// let list = get_items();
// match save(list, path).await {
// Ok(_) => assert!(true),
// Err(e) => panic!("There was an error: {e}"),
// }
// }
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",
));
}
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",
))
}
},
)
},
)
})
} else {
Ok(())
}
})
}
// checks to make sure all paths in slides and items point to cache_dir
fn find_paths(items: &[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(audio) = &song.audio
&& !audio.starts_with(&cache_dir)
{
return false;
}
}
ServiceItemKind::Video(video) => {
if !video.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Image(image) => {
if !image.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Presentation(presentation) => {
if !presentation.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Content(_slide) => todo!(),
}
for slide in &item.slides {
if !slide.background().path.starts_with(&cache_dir) {
return false;
}
if !slide
.audio()
.is_none_or(|audio| audio.starts_with(&cache_dir))
{
return false;
}
}
true
})
}
fn cache_dir() -> PathBuf {
let mut cache_dir = dirs::cache_dir().expect("");
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
cache_dir.push("test");
cache_dir
}
#[test]
fn test_save() {
let path = PathBuf::from("./test.pres");
#[tokio::test]
async fn test_store() {
let path = PathBuf::from("/home/chris/dev/lumina/src/rust/core/test.pres");
let save_file = match File::create(path) {
Ok(f) => f,
Err(e) => panic!("Couldn't create save_file: {e}"),
};
let mut db = get_db().await;
let list = get_items();
match save(&Arc::new(list), &path, true) {
Ok(()) => {
assert!(path.is_file());
let Ok(file) = fs::File::open(path) else {
panic!("couldn't open file");
};
let Ok(size) = file.metadata().map(|data| data.len()) else {
panic!("couldn't get file metadata");
};
assert!(size > 0);
if let Ok(json) = process_service_items(&list, &mut db).await {
println!("{:?}", json);
match store_service_items(&list, &mut db, &save_file, &json).await {
Ok(_) => assert!(true),
Err(e) => panic!("There was an error: {e}"),
}
Err(e) => panic!("{e}"),
} else {
panic!("There was an error getting the json value");
}
}
// #[tokio::test]
// async fn test_things() {
// let mut temp_dir = dirs::data_dir().unwrap();
// temp_dir.push("lumina");
// let mut s: String =
// iter::repeat_with(fastrand::alphanumeric)
// .take(5)
// .collect();
// s.insert_str(0, "temp_");
// temp_dir.push(s);
// let _ = fs::create_dir_all(&temp_dir);
// let mut db = get_db().await;
// let service_file = temp_dir.join("serviceitems.json");
// let list = get_items();
// if let Ok(json) = process_service_items(&list, &mut db).await {
// let _ = fs::File::create(&service_file);
// match fs::write(service_file, json.to_string()) {
// Ok(_) => assert!(true),
// Err(e) => panic!("There was an error: {e}"),
// }
// } else {
// panic!("There was an error getting the json value");
// }
// }
}

View file

@ -1,58 +1,32 @@
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 std::path::{Path, PathBuf};
use std::sync::Arc;
use sqlx::{
pool::PoolConnection, query, query_as, Sqlite, SqliteConnection,
SqlitePool,
};
use std::path::PathBuf;
use tracing::error;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Clone, Debug, Default, PartialEq, 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 {
fn from(value: PathBuf) -> Self {
let title = value
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
Self {
id: 0,
title,
path: value.canonicalize().unwrap_or(value),
created_at: Local::now(),
accessed_at: Local::now(),
}
}
}
impl From<&Path> for Image {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl From<&Image> for Value {
fn from(_value: &Image) -> Self {
fn from(value: &Image) -> Self {
Self::List(vec![Self::Symbol(Symbol("image".into()))])
}
}
@ -76,10 +50,10 @@ impl Content for Image {
fn subtext(&self) -> String {
if self.path.exists() {
self.path.file_name().map_or_else(
|| "Missing image".into(),
|f| f.to_string_lossy().to_string(),
)
self.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or("Missing image".into())
} else {
"Missing image".into()
}
@ -92,24 +66,26 @@ impl From<Value> for Image {
}
}
#[allow(clippy::option_if_let_else)]
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 +110,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 +133,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,161 +148,50 @@ 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;
match result {
Ok(v) => {
for image in v {
for image in v.into_iter() {
let _ = self.add_item(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>,
id: i32,
) -> Result<Vec<Image>> {
query!("DELETE FROM images WHERE id = $1", id)
.execute(&*db)
.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)
}
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 update_image_in_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)
.map(|s| s.to_string())
.unwrap_or_default();
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.detach())
.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)
Ok(())
}
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 +202,7 @@ 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("~/pics/camprules2024.mp4"),
..Default::default()
}
}
@ -345,20 +212,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 = crate::core::model::get_db().await;
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());
if let Some(image) = image_model.find(|i| i.id == 3) {
let test_image = test_image("nccq5".into());
assert_eq!(test_image.title, image.title);
} else {
panic!();
assert!(false);
}
}
@ -368,23 +229,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
),
}
}
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
}

View file

@ -1,17 +1,13 @@
use std::error::Error;
use std::fmt::Display;
use std::path::PathBuf;
use std::{error::Error, fmt::Display};
use serde::{Deserialize, Serialize};
use crate::Slide;
use crate::core::content::Content;
use crate::core::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 {
@ -22,55 +18,14 @@ pub enum ServiceItemKind {
Content(Slide),
}
impl TryFrom<PathBuf> for ServiceItemKind {
type Error = miette::Error;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or_else(|| miette::miette!("There isn't an extension on this file"))?;
match ext {
"png" | "jpg" | "jpeg" => Ok(Self::Image(Image::from(path))),
"mp4" | "mkv" | "webm" => Ok(Self::Video(Video::from(path))),
"pdf" => Ok(Self::Presentation(Presentation::from(path))),
_ => Err(miette::miette!("Unknown item")),
}
}
}
impl ServiceItemKind {
pub fn title(&self) -> String {
match self {
Self::Song(song) => song.title.clone(),
Self::Video(video) => video.title.clone(),
Self::Image(image) => image.title.clone(),
Self::Presentation(presentation) => presentation.title.clone(),
Self::Content(_slide) => todo!(),
}
}
pub fn to_service_item(&self) -> ServiceItem {
match self {
Self::Song(song) => song.to_service_item(),
Self::Video(video) => video.to_service_item(),
Self::Image(image) => image.to_service_item(),
Self::Presentation(presentation) => presentation.to_service_item(),
Self::Content(_slide) => {
todo!()
}
}
}
}
impl std::fmt::Display for ServiceItemKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let s = match self {
Self::Song(_) => "song".to_owned(),
Self::Image(_) => "image".to_owned(),
Self::Video(_) => "video".to_owned(),
Self::Presentation(_) => "html".to_owned(),
Self::Content(_) => "content".to_owned(),
Self::Song(s) => "song".to_owned(),
Self::Image(i) => "image".to_owned(),
Self::Video(v) => "video".to_owned(),
Self::Presentation(p) => "html".to_owned(),
Self::Content(s) => "content".to_owned(),
};
write!(f, "{s}")
}
@ -95,12 +50,14 @@ impl std::fmt::Display for ServiceItemKind {
// }
impl From<ServiceItemKind> for String {
fn from(val: ServiceItemKind) -> Self {
fn from(val: ServiceItemKind) -> String {
match val {
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,11 +71,12 @@ 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'"
}
Self::UnknownType => "The type does not exist. It needs to be one of 'song', 'video', 'image', 'presentation', or 'content'",
};
write!(f, "Error: {message}")
}
@ -128,6 +86,6 @@ impl Display for ParseError {
mod test {
#[test]
pub fn test_kinds() {
assert_eq!(true, true);
assert_eq!(true, true)
}
}

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

@ -0,0 +1,157 @@
use lexpr::Value;
use strum_macros::EnumString;
#[derive(Debug, Clone, Default, PartialEq, Eq, EnumString)]
pub(crate) enum Symbol {
#[strum(ascii_case_insensitive)]
Slide,
#[strum(ascii_case_insensitive)]
Image,
#[strum(ascii_case_insensitive)]
Text,
#[strum(ascii_case_insensitive)]
Video,
#[strum(ascii_case_insensitive)]
Song,
#[strum(disabled)]
ImageFit(ImageFit),
#[strum(disabled)]
VerseOrder(VerseOrder),
#[strum(disabled)]
#[default]
None,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumString)]
pub(crate) enum Keyword {
ImageFit(ImageFit),
}
#[derive(Debug, Default, Clone, PartialEq, Eq, EnumString)]
pub(crate) enum ImageFit {
#[strum(ascii_case_insensitive)]
#[default]
Cover,
#[strum(ascii_case_insensitive)]
Fill,
#[strum(ascii_case_insensitive)]
Crop,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, EnumString)]
pub(crate) enum VerseOrder {
#[strum(ascii_case_insensitive)]
#[default]
V1,
#[strum(ascii_case_insensitive)]
V2,
#[strum(ascii_case_insensitive)]
V3,
#[strum(ascii_case_insensitive)]
V4,
#[strum(ascii_case_insensitive)]
V5,
#[strum(ascii_case_insensitive)]
V6,
#[strum(ascii_case_insensitive)]
C1,
#[strum(ascii_case_insensitive)]
C2,
#[strum(ascii_case_insensitive)]
C3,
#[strum(ascii_case_insensitive)]
C4,
#[strum(ascii_case_insensitive)]
B1,
#[strum(ascii_case_insensitive)]
B2,
#[strum(ascii_case_insensitive)]
B3,
#[strum(ascii_case_insensitive)]
B4,
#[strum(ascii_case_insensitive)]
O1,
#[strum(ascii_case_insensitive)]
O2,
#[strum(ascii_case_insensitive)]
O3,
#[strum(ascii_case_insensitive)]
O4,
#[strum(ascii_case_insensitive)]
E1,
#[strum(ascii_case_insensitive)]
E2,
#[strum(ascii_case_insensitive)]
I1,
#[strum(ascii_case_insensitive)]
I2,
}
#[derive(Clone, Debug, PartialEq, Eq, EnumString)]
pub(crate) enum SongKeyword {
#[strum(ascii_case_insensitive)]
Title,
#[strum(ascii_case_insensitive)]
Author,
#[strum(ascii_case_insensitive)]
Ccli,
#[strum(ascii_case_insensitive)]
Audio,
#[strum(ascii_case_insensitive)]
Font,
#[strum(ascii_case_insensitive)]
FontSize,
#[strum(ascii_case_insensitive)]
Background,
#[strum(ascii_case_insensitive)]
VerseOrder(VerseOrder),
}
#[derive(Clone, Debug, PartialEq, Eq, EnumString)]
pub(crate) enum ImageKeyword {
#[strum(ascii_case_insensitive)]
Source,
#[strum(ascii_case_insensitive)]
Fit,
}
#[derive(Clone, Debug, Eq, PartialEq, EnumString)]
pub(crate) enum VideoKeyword {
#[strum(ascii_case_insensitive)]
Source,
#[strum(ascii_case_insensitive)]
Fit,
}
pub(crate) fn get_lists(exp: &Value) -> Vec<Value> {
if exp.is_cons() {
exp.as_cons().unwrap().to_vec().0
} else {
vec![]
}
}
#[cfg(test)]
mod test {
// #[test]
// fn test_list() {
// let lisp =
// read_to_string("./test_presentation.lisp").expect("oops");
// // println!("{lisp}");
// let mut parser =
// Parser::from_str_custom(&lisp, Options::elisp());
// for atom in parser.value_iter() {
// match atom {
// Ok(atom) => {
// // println!("{atom}");
// let lists = get_lists(&atom);
// assert_eq!(lists, vec![Value::Null])
// }
// Err(e) => {
// panic!("{e}");
// }
// }
// }
// }
}

View file

@ -1,15 +1,11 @@
pub mod content;
pub mod file;
pub mod images;
pub mod kinds;
pub mod lisp;
pub mod model;
pub mod presentations;
pub mod service_items;
pub mod settings;
pub mod slide;
pub mod slide_actions;
pub mod song_search;
pub mod songs;
pub mod thumbnail;
pub mod videos;
pub mod ytdl;

View file

@ -1,22 +1,15 @@
use std::borrow::Cow;
use std::fs;
use std::mem::replace;
use std::path::PathBuf;
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize};
use miette::{miette, Result};
use sqlx::{Connection, SqliteConnection};
use tracing::debug;
#[derive(Debug, Clone)]
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, Copy)]
pub enum LibraryKind {
Song,
Video,
@ -24,107 +17,36 @@ 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)]
pub struct KindWrapper(pub (LibraryKind, i32));
impl From<PathBuf> for LibraryKind {
fn from(_value: PathBuf) -> Self {
todo!()
}
}
impl TryFrom<(Vec<u8>, String)> for KindWrapper {
type Error = miette::Error;
fn try_from(value: (Vec<u8>, String)) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value;
match mime.as_str() {
"application/service-item" => ron::de::from_bytes(&data).into_diagnostic(),
_ => Err(miette!("Wrong mime type: {mime}")),
}
}
}
impl AllowedMimeTypes for KindWrapper {
fn allowed() -> Cow<'static, [String]> {
Cow::from(vec!["application/service-item".to_string()])
}
}
impl AsMimeTypes for KindWrapper {
fn available(&self) -> Cow<'static, [String]> {
debug!(?self);
Cow::from(vec!["application/service-item".to_string()])
}
fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
debug!(?self);
debug!(mime_type);
let ron = ron::ser::to_string(self).ok()?;
debug!(ron);
Some(Cow::from(ron.into_bytes()))
}
}
impl<T> Model<T> {
pub fn add_item(&mut self, item: T) -> Result<()> {
self.items.push(item);
Ok(())
}
pub fn add_to_db(&mut self, _item: T) -> Result<()> {
pub fn add_to_db(&mut self, item: T) -> Result<()> {
todo!()
}
pub fn update_item<P>(&mut self, item: T, 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
.get_mut(index)
.expect("Since we found position this should always exist")
})
.map(|current_item| {
let _old_item = replace(current_item, item);
})
pub fn update_item(&mut self, item: T, index: i32) -> Result<()> {
if let Some(current_item) = self.items.get_mut(index as usize)
{
let _old_item = replace(current_item, item);
Ok(())
} else {
Err(miette!(
"Item doesn't exist in model. Id was {}",
index
))
}
}
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(index as usize);
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(index as usize)
}
pub fn find<P>(&self, f: P) -> Option<&T>
@ -134,8 +56,8 @@ 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(index as usize, item);
Ok(())
}
}
@ -152,12 +74,11 @@ 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().unwrap();
data.push("lumina");
let _ = fs::create_dir_all(&data);
data.push("library-db.sqlite3");
let mut db_url = String::from("sqlite://");
db_url.push_str(data.to_str().expect("Should be there"));
db_url.push_str(data.to_str().unwrap());
SqliteConnection::connect(&db_url).await.expect("problems")
}

View file

@ -1,114 +1,44 @@
use cosmic::widget::image::Handle;
use crisp::types::{Keyword, Symbol, Value};
use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette};
use mupdf::{Colorspace, Document, Matrix};
use miette::{IntoDiagnostic, Result};
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 std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, error};
use sqlx::{
pool::PoolConnection, prelude::FromRow, query, sqlite::SqliteRow,
Row, Sqlite, SqliteConnection, SqlitePool,
};
use std::path::PathBuf;
use tracing::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 {
starting_index: i32,
ending_index: i32,
},
#[default]
Pdf,
Generic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub struct Presentation {
pub id: i32,
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 {}
impl PartialEq for Presentation {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.title == other.title
&& self.path == other.path
&& self.kind == other.kind
}
}
impl From<PathBuf> for Presentation {
fn from(value: PathBuf) -> Self {
let title = value
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
let kind = match value
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
{
"pdf" => Document::open(&value.to_str().unwrap_or_default()).map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|count| PresKind::Pdf {
starting_index: 0,
ending_index: count - 1,
},
)
},
),
"html" => PresKind::Html,
_ => PresKind::Generic,
};
Self {
id: 0,
title,
path: value.canonicalize().unwrap_or(value),
kind,
created_at: Local::now(),
accessed_at: Local::now(),
}
}
}
impl From<&Path> for Presentation {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl From<&Presentation> for Value {
fn from(_value: &Presentation) -> Self {
fn from(value: &Presentation) -> Self {
Self::List(vec![Self::Symbol(Symbol("presentation".into()))])
}
}
@ -132,10 +62,10 @@ impl Content for Presentation {
fn subtext(&self) -> String {
if self.path.exists() {
self.path.file_name().map_or_else(
|| "Missing presentation".into(),
|f| f.to_string_lossy().to_string(),
)
self.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or("Missing presentation".into())
} else {
"Missing presentation".into()
}
@ -148,24 +78,24 @@ impl From<Value> for Presentation {
}
}
#[allow(clippy::option_if_let_else)]
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(),
@ -187,68 +117,22 @@ impl ServiceTrait for Presentation {
}
fn to_slides(&self) -> Result<Vec<Slide>> {
debug!(?self);
let PresKind::Pdf {
starting_index,
ending_index,
} = self.kind
else {
return Err(miette::miette!("This is not a pdf presentation"));
};
let background = Background::try_from(self.path.clone()).into_diagnostic()?;
debug!(?background);
let document = Document::open(background.path.to_str().unwrap_or_default())
.into_diagnostic()?;
debug!(?document);
let pages = document.pages().into_diagnostic()?;
debug!(?pages);
let pages: Vec<Handle> = pages
.enumerate()
.filter_map(|(index, page)| {
let index = i32::try_from(index).expect("Shouldn't be that high");
let slide = SlideBuilder::new()
.background(
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("")
.audio("")
.font("")
.font_size(50)
.text_alignment(TextAlignment::MiddleCenter)
.video_loop(false)
.video_start_time(0.0)
.video_end_time(0.0)
.build()?;
if index < starting_index || index > ending_index {
return None;
}
let page = page.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let Ok(pixmap) = page
.to_pixmap(&matrix, &colorspace, true, true)
.into_diagnostic()
else {
error!("Can't turn this page into pixmap");
return None;
};
debug!(?pixmap);
Some(Handle::from_rgba(
pixmap.width(),
pixmap.height(),
pixmap.samples().to_vec(),
))
})
.collect();
let mut slides: Vec<Slide> = vec![];
for (index, page) in pages.into_iter().enumerate() {
let slide = SlideBuilder::new()
.background(Background::try_from(self.path.clone()).into_diagnostic()?)
.text("")
.audio("")
.font("")
.font_size(50)
.text_alignment(TextAlignment::MiddleCenter)
.video_loop(false)
.video_start_time(0.0)
.video_end_time(0.0)
.pdf_index(u32::try_from(index).expect("Shouldn't get that high"))
.pdf_page(page)
.build()?;
slides.push(slide);
}
debug!(?slides);
Ok(slides)
Ok(vec![slide])
}
fn box_clone(&self) -> Box<dyn ServiceTrait> {
@ -257,16 +141,14 @@ impl ServiceTrait for Presentation {
}
impl Presentation {
#[must_use]
pub fn new() -> Self {
Self {
title: String::new(),
title: "".to_string(),
..Default::default()
}
}
#[must_use]
pub const fn get_kind(&self) -> &PresKind {
pub fn get_kind(&self) -> &PresKind {
&self.kind
}
}
@ -283,252 +165,81 @@ impl FromRow<'_, SqliteRow> for Presentation {
kind: if row.try_get(3)? {
PresKind::Html
} else {
PresKind::Pdf {
starting_index: row.try_get(4)?,
ending_index: row.try_get(5)?,
}
PresKind::Pdf
},
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 from presentations"#
)
.fetch_all(&*db)
.fetch_all(db)
.await;
match result {
Ok(v) => {
for presentation in v {
for presentation in v.into_iter() {
let _ = self.add_item(Presentation {
id: presentation.id,
title: presentation.title,
path: presentation.path.clone().into(),
path: presentation.path.into(),
kind: if presentation.html {
PresKind::Html
} else if let (Some(starting_index), Some(ending_index)) =
(presentation.starting_index, presentation.ending_index)
{
PresKind::Pdf {
starting_index: i32::try_from(starting_index)
.expect("Shouldn't get that high"),
ending_index: i32::try_from(ending_index)
.expect("Shouldn't get that high"),
}
} else {
let path = PathBuf::from(presentation.path);
Document::open(path.to_str().unwrap_or_default()).map_or(
PresKind::Generic,
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|count| {
let ending_index = count - 1;
PresKind::Pdf {
starting_index: 0,
ending_index,
}
},
)
},
)
PresKind::Pdf
},
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>,
id: i32,
) -> Result<Vec<Presentation>> {
query!("DELETE FROM presentations WHERE id = $1", id)
.execute(&*db)
.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)
}
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 update_presentation_in_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)
.map(|s| s.to_string())
.unwrap_or_default();
let html = presentation.kind == PresKind::Html;
let (starting_index, ending_index) = if let PresKind::Pdf {
starting_index: s_index,
ending_index: e_index,
} = presentation.get_kind()
{
(*s_index, *e_index)
} else {
(0, 0)
};
debug!(starting_index, ending_index);
query!(
r#"UPDATE presentations SET title = $2, file_path = $3, html = $4, starting_index = $5, ending_index = $6 WHERE id = $1"#,
r#"UPDATE presentations SET title = $2, file_path = $3, html = $4 WHERE id = $1"#,
presentation.id,
presentation.title,
path,
html,
starting_index,
ending_index
html
)
.execute(&*db)
.await.into_diagnostic()?;
.execute(&mut db.detach())
.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)
Ok(())
}
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()
}
@ -539,27 +250,19 @@ mod test {
fn test_presentation() -> Presentation {
Presentation {
id: 4,
title: "mzt52.pdf".into(),
path: PathBuf::from("/home/chris/docs/mzt52.pdf"),
kind: PresKind::Pdf {
starting_index: 0,
ending_index: 67,
},
created_at: Local::now(),
accessed_at: Local::now(),
id: 54,
title: "20240327T133649--12-isaiah-and-jesus__lesson_project_tfc".into(),
path: PathBuf::from(
"file:///home/chris/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html",
),
kind: PresKind::Html,
}
}
#[test]
pub fn test_pres() {
let pres = Presentation::new();
assert_eq!(pres.get_kind(), &PresKind::Generic);
}
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
assert_eq!(pres.get_kind(), &PresKind::Pdf)
}
#[tokio::test]
@ -567,15 +270,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 = crate::core::model::get_db().await;
presentation_model.load_from_db(&mut db).await;
if let Some(presentation) =
presentation_model.find(|p| p.id == 54)
{
let test_presentation = test_presentation();
assert_eq!(&test_presentation, presentation);
} else {
panic!();
assert!(false);
}
}
}

View file

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

View file

@ -1,62 +0,0 @@
// 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 serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use crate::core::model::Sort;
pub const SETTINGS_VERSION: u64 = 1;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum AppTheme {
Dark,
Light,
System,
}
impl AppTheme {
pub fn theme(&self) -> theme::Theme {
match self {
Self::Dark => theme::Theme::dark(),
Self::Light => theme::Theme::light(),
Self::System => theme::system_preference(),
}
}
}
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct Settings {
pub app_theme: AppTheme,
pub obs_url: Option<url::Url>,
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 {
fn default() -> Self {
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,
)]
pub struct PersistentState {
pub recent_files: VecDeque<PathBuf>,
}

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

@ -1,55 +1,29 @@
#![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 iced::dialog::ashpd::url::Url;
use crisp::types::{Keyword, Symbol, Value};
use iced_video_player::Video;
use image::EncodableLayout;
use miette::{Result, miette};
use miette::{miette, Result};
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 crate::ui::text_svg::{self, TextSvg};
use super::songs::Song;
#[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,
stroke: Option<Stroke>,
shadow: Option<Shadow>,
text_alignment: TextAlignment,
text_color: Option<Color>,
audio: Option<PathBuf>,
video_loop: bool,
video_start_time: f32,
video_end_time: f32,
pdf_index: u32,
pub text_svg: Option<TextSvg>,
#[serde(skip)]
pdf_page: Option<Handle>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackgroundKind {
#[default]
Image,
Video,
Pdf,
Html,
}
#[derive(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,23 +53,23 @@ 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> {
Self::new(
fn try_from(
value: &Background,
) -> std::result::Result<Self, Self::Error> {
Video::new(
&url::Url::from_file_path(value.path.clone())
.map_err(|()| ParseError::BackgroundNotVideo)?,
.map_err(|_| ParseError::BackgroundNotVideo)?,
)
.map_err(|_| ParseError::BackgroundNotVideo)
}
@ -104,24 +78,21 @@ 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> {
Video::new(
&url::Url::from_file_path(value.path)
.map_err(|_| ParseError::BackgroundNotVideo)?,
)
.map_err(|_| ParseError::BackgroundNotVideo)
}
}
impl TryFrom<String> for Background {
type Error = ParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
Background::try_from(value.as_str())
}
}
@ -129,14 +100,14 @@ 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().unwrap().to_string();
let path = path.trim_start_matches("file://");
let home = dirs::home_dir()
.expect("We should have a home directory")
.unwrap()
.to_str()
.expect("Gah")
.unwrap()
.to_string();
let path = path.replace('~', &home);
let path = path.replace("~", &home);
PathBuf::from(path)
} else {
path
@ -150,35 +121,21 @@ impl TryFrom<PathBuf> for Background {
.to_str()
.unwrap_or_default();
match extension {
"jpeg" | "jpg" | "png" | "webp" => Ok(Self {
path: value.clone(),
kind: BackgroundKind::Image,
image_handle: Some(value.into()),
image_allocation: None,
}),
"jpeg" | "jpg" | "png" | "webp" | "html" => {
Ok(Self {
path: value,
kind: BackgroundKind::Image,
})
}
"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),
}
}
Err(_e) => {
// error!("Couldn't canonicalize: {e} {:?}", path);
Err(e) => {
error!("Couldn't canonicalize: {e} {:?}", path);
Err(ParseError::CannotCanonicalize)
}
}
@ -189,19 +146,17 @@ impl TryFrom<&str> for Background {
type Error = ParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let value = value.trim_start_matches("file://");
if value.starts_with('~') {
dirs::home_dir().map_or_else(
|| Self::try_from(PathBuf::from(value)),
|home| {
home.to_str().map_or_else(
|| Self::try_from(PathBuf::from(value)),
|home| {
let value = value.replace('~', home);
Self::try_from(PathBuf::from(value))
},
)
},
)
if value.starts_with("~") {
if let Some(home) = dirs::home_dir() {
if let Some(home) = home.to_str() {
let value = value.replace("~", home);
Self::try_from(PathBuf::from(value))
} else {
Self::try_from(PathBuf::from(value))
}
} else {
Self::try_from(PathBuf::from(value))
}
} else if value.starts_with("./") {
Err(ParseError::CannotCanonicalize)
} else {
@ -228,151 +183,117 @@ 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}")
}
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum BackgroundKind {
#[default]
Image,
Video,
}
impl From<String> for BackgroundKind {
fn from(value: String) -> Self {
if value == "image" {
Self::Image
BackgroundKind::Image
} else {
Self::Video
BackgroundKind::Video
}
}
}
#[derive(
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Slide {
id: i32,
background: Background,
text: String,
font: String,
font_size: i32,
text_alignment: TextAlignment,
audio: Option<PathBuf>,
video_loop: bool,
video_start_time: f32,
video_end_time: f32,
#[serde(skip)]
pub text_svg: TextSvg,
}
impl From<&Slide> for Value {
fn from(_value: &Slide) -> Self {
fn from(value: &Slide) -> Self {
Self::List(vec![Self::Symbol(Symbol("slide".into()))])
}
}
impl Slide {
#[must_use]
pub fn set_text(mut self, text: impl AsRef<str>) -> Self {
self.text = text.as_ref().into();
self
}
#[must_use]
pub fn with_text_svg(mut self, text_svg: TextSvg) -> Self {
self.text_svg = Some(text_svg);
self
}
#[must_use]
pub fn set_font(mut self, font: impl AsRef<str>) -> Self {
self.font = Some(font.as_ref().into());
self.font = font.as_ref().into();
self
}
#[must_use]
pub const fn set_font_size(mut self, font_size: i32) -> Self {
pub fn set_font_size(mut self, font_size: i32) -> Self {
self.font_size = font_size;
self
}
#[must_use]
pub fn set_audio(mut self, audio: Option<PathBuf>) -> Self {
self.audio = audio;
self
}
#[must_use]
pub const fn set_pdf_index(mut self, pdf_index: u32) -> Self {
self.pdf_index = pdf_index;
self
}
#[must_use]
pub const fn set_stroke(mut self, stroke: Stroke) -> Self {
self.stroke = Some(stroke);
self
}
#[must_use]
pub const fn set_shadow(mut self, shadow: Shadow) -> Self {
self.shadow = Some(shadow);
self
}
#[must_use]
pub const fn set_text_color(mut self, color: Color) -> Self {
self.text_color = Some(color);
self
}
#[must_use]
pub const fn background(&self) -> &Background {
pub fn background(&self) -> &Background {
&self.background
}
#[must_use]
pub fn text(&self) -> String {
self.text.clone()
}
#[must_use]
pub const fn text_alignment(&self) -> TextAlignment {
self.text_alignment
}
#[must_use]
pub const fn font_size(&self) -> i32 {
pub fn font_size(&self) -> i32 {
self.font_size
}
#[must_use]
pub fn font(&self) -> Option<Font> {
pub fn font(&self) -> String {
self.font.clone()
}
#[must_use]
pub const fn video_loop(&self) -> bool {
pub fn video_loop(&self) -> bool {
self.video_loop
}
#[must_use]
pub fn audio(&self) -> Option<PathBuf> {
self.audio.clone()
}
#[must_use]
pub fn pdf_page(&self) -> Option<Handle> {
self.pdf_page.clone()
}
#[must_use]
pub fn text_color(&self) -> Option<Color> {
self.text_color.clone()
}
#[must_use]
pub fn stroke(&self) -> Option<Stroke> {
self.stroke.clone()
}
#[must_use]
pub fn shadow(&self) -> Option<Shadow> {
self.shadow.clone()
}
#[must_use]
pub const fn pdf_index(&self) -> u32 {
self.pdf_index
}
pub fn song_slides(song: &Song) -> Result<Vec<Self>> {
let lyrics = song.get_lyrics()?;
let slides: Vec<Self> = lyrics
let slides: Vec<Slide> = lyrics
.iter()
.map(|l| {
let song = song.clone();
@ -380,7 +301,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)
@ -394,10 +317,14 @@ impl Slide {
Ok(slides)
}
pub(crate) const fn set_index(&mut self, index: i32) {
pub(crate) fn set_index(&mut self, index: i32) {
self.id = index;
}
pub(crate) fn text_to_image(&self) {
todo!()
}
// pub fn slides_from_item(item: &ServiceItem) -> Result<Vec<Self>> {
// todo!()
// }
@ -413,21 +340,20 @@ impl From<&Value> for Slide {
fn from(value: &Value) -> Self {
match value {
Value::List(list) => lisp_to_slide(list),
_ => Self::default(),
_ => Slide::default(),
}
}
}
#[allow(clippy::option_if_let_else)]
fn lisp_to_slide(lisp: &[Value]) -> Slide {
fn lisp_to_slide(lisp: &Vec<Value>) -> Slide {
const DEFAULT_BACKGROUND_LOCATION: usize = 1;
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
@ -437,11 +363,12 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
slide = slide.background(lisp_to_background(background));
} else {
slide = slide.background(Background::default());
}
};
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,
});
@ -482,15 +409,17 @@ fn lisp_to_slide(lisp: &[Value]) -> Slide {
}
}
#[allow(clippy::option_if_let_else)]
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
@ -512,15 +441,13 @@ fn lisp_to_text(lisp: &Value) -> impl Into<String> {
// Need to return a Result here so that we can propogate
// errors and then handle them appropriately
#[allow(clippy::option_if_let_else)]
pub fn lisp_to_background(lisp: &Value) -> Background {
match lisp {
Value::List(list) => {
let _kind = list[0].clone();
if let Some(source) = list
.iter()
.position(|v| v == &Value::Keyword(Keyword::from("source")))
{
let kind = list[0].clone();
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 +465,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 +475,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,23 +493,19 @@ 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>,
font: Option<Font>,
font: Option<String>,
font_size: Option<i32>,
audio: Option<PathBuf>,
stroke: Option<Stroke>,
shadow: Option<Shadow>,
text_color: Option<Color>,
text_alignment: Option<TextAlignment>,
video_loop: Option<bool>,
video_start_time: Option<f32>,
video_end_time: Option<f32>,
pdf_index: Option<u32>,
#[serde(skip)]
pdf_page: Option<Handle>,
#[serde(skip)]
text_svg: Option<TextSvg>,
}
@ -597,7 +524,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,17 +537,12 @@ impl SlideBuilder {
self
}
pub(crate) fn text_color(mut self, text_color: impl Into<Color>) -> Self {
let _ = self.text_color.insert(text_color.into());
self
}
pub(crate) fn audio(mut self, audio: impl Into<PathBuf>) -> Self {
let _ = self.audio.insert(audio.into());
self
}
pub(crate) fn font(mut self, font: impl Into<Font>) -> Self {
pub(crate) fn font(mut self, font: impl Into<String>) -> Self {
let _ = self.font.insert(font.into());
self
}
@ -627,22 +552,10 @@ impl SlideBuilder {
self
}
pub(crate) fn color(mut self, color: impl Into<Color>) -> Self {
let _ = self.text_color.insert(color.into());
self
}
pub(crate) fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
let _ = self.stroke.insert(stroke.into());
self
}
pub(crate) fn shadow(mut self, shadow: impl Into<Shadow>) -> Self {
let _ = self.shadow.insert(shadow.into());
self
}
pub(crate) fn text_alignment(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,31 +565,30 @@ 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
}
pub(crate) fn pdf_page(mut self, pdf_page: Handle) -> Self {
let _ = self.pdf_page.insert(pdf_page);
self
}
pub(crate) fn pdf_index(mut self, pdf_index: impl Into<u32>) -> Self {
let _ = self.pdf_index.insert(pdf_index.into());
self
}
pub(crate) fn build(self) -> Result<Slide> {
let Some(background) = self.background else {
return Err(miette!("No background"));
@ -684,6 +596,9 @@ impl SlideBuilder {
let Some(text) = self.text else {
return Err(miette!("No text"));
};
let Some(font) = self.font else {
return Err(miette!("No font"));
};
let Some(font_size) = self.font_size else {
return Err(miette!("No font_size"));
};
@ -699,24 +614,60 @@ impl SlideBuilder {
let Some(video_end_time) = self.video_end_time else {
return Err(miette!("No video_end_time"));
};
Ok(Slide {
background,
text,
font: self.font,
font_size,
text_alignment,
audio: self.audio,
stroke: self.stroke,
shadow: self.shadow,
text_color: self.text_color,
video_loop,
video_start_time,
video_end_time,
text_svg: self.text_svg,
pdf_index: self.pdf_index.unwrap_or_default(),
pdf_page: self.pdf_page,
if let Some(text_svg) = self.text_svg {
Ok(Slide {
background,
text,
font,
font_size,
text_alignment,
audio: self.audio,
video_loop,
video_start_time,
video_end_time,
text_svg,
..Default::default()
})
} else {
let text_svg = TextSvg::new(text.clone())
.alignment(text_alignment)
.fill("#fff")
.shadow(text_svg::shadow(2, 2, 5, "#000000"))
.stroke(text_svg::stroke(3, "#000"))
.font(
text_svg::Font::from(font.clone())
.size(font_size.try_into().unwrap()),
)
.build();
Ok(Slide {
background,
text,
font,
font_size,
text_alignment,
audio: self.audio,
video_loop,
video_start_time,
video_end_time,
text_svg,
..Default::default()
})
}
}
}
#[derive(Debug, Clone, Default)]
struct Image {
pub source: String,
pub fit: String,
pub children: Vec<String>,
}
impl Image {
fn new() -> Self {
Self {
..Default::default()
})
}
}
}
@ -730,28 +681,56 @@ mod test {
fn test_slide() -> Slide {
Slide {
text: "This is frodo".to_string(),
background: Background::try_from("~/pics/frodo.jpg").expect(""),
font: Some("Quicksand".to_string().into()),
font_size: 140,
background: Background::try_from("~/pics/frodo.jpg")
.unwrap(),
font: "Quicksand".to_string(),
font_size: 70,
..Default::default()
}
}
fn test_second_slide() -> Slide {
Slide {
text: String::new(),
background: Background::try_from("~/vids/test/camprules2024.mp4").expect(""),
font: Some("Quicksand".to_string().into()),
text: "".to_string(),
background: Background::try_from(
"~/vids/test/camprules2024.mp4",
)
.unwrap(),
font: "Quicksand".to_string(),
..Default::default()
}
}
#[test]
fn test_lisp_serialize() {
let lisp =
read_to_string("./test_presentation.lisp").expect("oops");
let lisp_value = crisp::reader::read(&lisp);
match lisp_value {
Value::List(value) => {
let slide = Slide::from(value[0].clone());
let test_slide = test_slide();
assert_eq!(slide, test_slide);
let second_slide = Slide::from(value[1].clone());
let second_test_slide = test_second_slide();
assert_eq!(second_slide, second_test_slide)
}
_ => panic!("this should be a lisp"),
}
}
#[test]
fn test_ron_deserialize() {
let slide =
read_to_string("./test_presentation.ron").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)
}
}
}
}

View file

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

View file

@ -1,578 +0,0 @@
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,
)]
pub struct OnlineSong {
pub lyrics: String,
pub title: String,
pub author: String,
pub provider: Provider,
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()?;
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, head_value);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.into_diagnostic()?;
let response = client
.get(format!("https://api.genius.com/search?q={query}"))
.send()
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let json: Value = serde_json::from_str(&response).into_diagnostic()?;
let hits = json
.get("response")
.expect("respose")
.get("hits")
.expect("hits")
.as_array()
.expect("array");
let songs: Vec<Option<OnlineSong>> =
cosmic::iced::futures::future::join_all(hits.iter().map(|hit| async {
let result = hit.get("result").expect("result");
let title = result
.get("title")
.expect("title")
.as_str()
.expect("title")
.to_string();
let title = title.replace("\u{a0}", " ");
let author = result
.get("artist_names")
.expect("artists")
.as_str()
.expect("artists")
.to_string();
let link = result
.get("url")
.expect("url")
.as_str()
.expect("url")
.to_string();
let song = OnlineSong {
lyrics: String::new(),
title,
author,
provider: Provider::Genius { parsable: false },
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())
}
pub async fn get_genius_lyrics(mut song: OnlineSong) -> Result<OnlineSong> {
let html = reqwest::get(&song.link)
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(lyrics_root_selector) =
scraper::Selector::parse(r#"div[data-lyrics-container="true"]"#)
else {
return Err(miette!("error in finding lyrics_root"));
};
let lyrics = document
.select(&lyrics_root_selector)
.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| {
// 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>>()
})
.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).1.to_string(),
);
song.provider = Provider::Genius {
parsable: lyrics.contains('['),
};
song.lyrics = lyrics;
Ok(song)
}
pub async fn search_lyrics_com_links(
query: impl AsRef<str> + std::fmt::Display,
) -> Result<Vec<String>> {
let html = reqwest::get(format!("http://www.lyrics.com/lyrics/{query}"))
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(best_matches_selector) = scraper::Selector::parse(".best-matches") else {
return Err(miette!("error in finding matches"));
};
let Ok(lyric_selector) = scraper::Selector::parse("a") else {
return Err(miette!("error in finding a links"));
};
Ok(document
.select(&best_matches_selector)
.flat_map(|best_section| best_section.select(&lyric_selector))
.map(|a| a.value().attr("href").unwrap_or("").trim().to_string())
.filter(|a| a.contains("/lyric/"))
.dedup()
.map(|link| {
link.strip_prefix("/lyric/")
.unwrap_or_else(|| &link)
.to_string()
})
.collect())
}
// leaving this lint unfixed because I don't know if we will need this
// id value or not in the future and I'd like to keep the code understanding
// of what this variable might be.
#[allow(clippy::no_effect_underscore_binding)]
pub async fn lyrics_com_link_to_song(
links: Vec<impl AsRef<str> + std::fmt::Display>,
) -> Result<Vec<OnlineSong>> {
let mut songs = vec![];
for link in links {
let parts = link
.as_ref()
.split('/')
.map(std::string::ToString::to_string)
.collect::<Vec<String>>();
let link = format!("https://www.lyrics.com/lyric/{link}");
let _id = &parts[0];
let author = &parts[1].replace('+', " ");
let title = &parts[2].replace('+', " ");
let html = reqwest::get(&link)
.await
.into_diagnostic()?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html);
let Ok(lyric_selector) = scraper::Selector::parse(".lyric-body") else {
return Err(miette!("error in finding lyric-body",));
};
let lyrics = document
.select(&lyric_selector)
.map(|a| a.text().collect::<String>())
.dedup()
.next();
if let Some(lyrics) = lyrics {
let song = OnlineSong {
lyrics,
title: title.clone(),
author: author.clone(),
provider: Provider::LyricsCom,
link,
};
songs.push(song);
}
}
Ok(songs)
}
#[cfg(test)]
mod test {
use crate::core::songs::Song;
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn genius() -> Result<(), String> {
let song = OnlineSong {
lyrics: String::new(),
title: "Death Was Arrested".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(),
};
let hits = search_genius(
"Death was arrested".to_string(),
env!("GENIUS_TOKEN").to_string(),
)
.await
.map_err(|e| e.to_string())?;
assert!(
hits[0].title == song.title,
"There was no song that matched on Genius"
);
let titles: Vec<String> = hits.iter().map(|song| song.title.clone()).collect();
dbg!(titles);
for hit in hits {
let new_song = get_genius_lyrics(hit).await.map_err(|e| e.to_string())?;
dbg!(&new_song);
dbg!(&new_song.provider);
if new_song.lyrics.starts_with("[Verse 1]") {
assert!(new_song.lyrics.contains("[Verse 2]"));
if !new_song.lyrics.contains("[Chorus]") {
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('['))
);
}
}
Ok(())
}
#[tokio::test]
async fn search_to_song() -> Result<(), String> {
let song = OnlineSong {
lyrics: "Alone in my sorrow and dead in my sin\nLost without hope with no place to begin\nYour love Made a way to let mercy come in\nWhen death was arrested and my life began\n\nAsh was redeemed only beauty remains\nMy orphan heart was given a name\nMy mourning grew quiet my feet rose to dance\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nReleased from my chains I'm a prisoner no more\nMy shame was a ransom He faithfully bore\nHe cancelled my debt and He called me His friend\nWhen death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOur savior displayed on a criminal's cross\nDarkness rejoiced as though heaven had lost\nBut then Jesus arose with our freedom in hand\nThat's when death was arrested and my life began\n\nOh, Your grace so free\nWashes over me\nYou have made me new\nNow life begins with You\nIt's your endless love\nPouring down on us\nYou have made us new\nNow life begins with You\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nOh, we're free, free\nForever we're free\nCome join the song\nOf all the redeemed\nYes, we're free free\nForever amen\nWhen death was arrested and my life began\n\nWhen death was arrested and my life began\nWhen death was arrested and my life began".to_string(),
title: "Death Was Arrested".to_string(),
author: "North Point InsideOut".to_string(),
provider: Provider::LyricsCom,
link: "https://www.lyrics.com/lyric/35090938/North+Point+InsideOut/Death+Was+Arrested".to_string(),
};
let links = search_lyrics_com_links("Death was arrested")
.await
.map_err(|e| format!("{e}"))?;
let songs = lyrics_com_link_to_song(links)
.await
.map_err(|e| format!("{e}"))?;
if let Some(first) = songs
.iter()
.find_or_first(|song| song.author == "North Point InsideOut")
{
assert_eq!(&song, first);
// online_song_to_song(song)?;
}
Ok(())
}
#[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:?}",));
}
} else {
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);
}
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,19 +1,22 @@
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>> {
if screenshot.exists() {
debug!("Screenshot already exists");
} else {
pub fn bg_from_video(
video: &Path,
screenshot: &Path,
) -> Result<(), Box<dyn Error>> {
if !screenshot.exists() {
let output_duration = Command::new("ffprobe")
.args(["-i", &video.to_string_lossy()])
.output()?;
io::stderr().write_all(&output_duration.stderr)?;
.output()
.expect("failed to execute ffprobe");
io::stderr().write_all(&output_duration.stderr).unwrap();
let mut at_second = 5;
let mut log = str::from_utf8(&output_duration.stderr)
.expect("Using non UTF-8 characters")
@ -23,9 +26,9 @@ pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Erro
let mut duration = log.split_off(duration_index + 10);
duration.truncate(11);
// debug!("rust-duration-is: {duration}");
let mut hours = String::new();
let mut minutes = String::new();
let mut seconds = String::new();
let mut hours = String::from("");
let mut minutes = String::from("");
let mut seconds = String::from("");
for (i, c) in duration.chars().enumerate() {
if i <= 1 {
hours.push(c);
@ -36,8 +39,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;
@ -58,6 +63,8 @@ pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Erro
.expect("failed to execute ffmpeg");
// io::stdout().write_all(&output.stdout).unwrap();
// io::stderr().write_all(&output.stderr).unwrap();
} else {
debug!("Screenshot already exists");
}
Ok(())
}
@ -65,15 +72,15 @@ 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::data_local_dir().unwrap();
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().unwrap());
screenshot.set_extension("png");
screenshot
}
@ -84,13 +91,33 @@ mod test {
#[test]
fn test_bg_video_creation() {
let video = Path::new("./res/bigbuckbunny.mp4");
let video = Path::new("/home/chris/vids/moms-funeral.mp4");
let screenshot = bg_path_from_video(video);
match bg_from_video(video, &screenshot) {
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_eq!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/moms-funeral.png");
// let runtime = tokio::runtime::Runtime::new().unwrap();
let result = bg_from_video(video, &screenshot);
// let result = runtime.block_on(future);
match result {
Ok(_o) => assert!(screenshot.exists()),
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
),
}
}
#[test]
fn test_bg_not_same() {
let video = Path::new(
"/home/chris/vids/All WebDev Sucks and you know it.webm",
);
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_ne!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/All WebDev Sucks and you know it.webm");
}
}

View file

@ -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 std::path::{Path, PathBuf};
use std::sync::Arc;
use sqlx::{
pool::PoolConnection, query, query_as, Sqlite, SqliteConnection,
SqlitePool,
};
use std::path::PathBuf;
use tracing::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,38 +27,14 @@ 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 {
fn from(_value: &Video) -> Self {
fn from(value: &Video) -> Self {
Self::List(vec![Self::Symbol(Symbol("video".into()))])
}
}
impl From<PathBuf> for Video {
fn from(value: PathBuf) -> Self {
let title: String = value.file_name().map_or_else(
|| "Video".into(),
|filename| filename.to_str().unwrap_or("Video").into(),
);
Self {
title,
path: value,
..Default::default()
}
}
}
impl From<&Path> for Video {
fn from(value: &Path) -> Self {
Self::from(value.to_owned())
}
}
impl Content for Video {
fn title(&self) -> String {
self.title.clone()
@ -76,10 +54,10 @@ impl Content for Video {
fn subtext(&self) -> String {
if self.path.exists() {
self.path.file_name().map_or_else(
|| "Missing video".into(),
|f| f.to_string_lossy().to_string(),
)
self.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or("Missing video".into())
} else {
"Missing video".into()
}
@ -92,48 +70,64 @@ impl From<Value> for Video {
}
}
#[allow(clippy::cast_precision_loss)]
impl From<&Value> for Video {
fn from(value: &Value) -> Self {
match value {
Value::List(list) => {
let path = list
.iter()
.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)))
});
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)))
} 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()
});
let start_time = list
.iter()
.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)
});
let start_time = if let Some(start_pos) =
list.iter().position(|v| {
v == &Value::Keyword(Keyword::from(
"start-time",
))
}) {
let pos = start_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32)
} else {
None
};
let end_time = list
.iter()
.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)
});
let end_time = if let Some(end_pos) =
list.iter().position(|v| {
v == &Value::Keyword(Keyword::from(
"end-time",
))
}) {
let pos = end_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32)
} else {
None
};
let looping = list
.iter()
.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")
});
let looping = if let Some(loop_pos) =
list.iter().position(|v| {
v == &Value::Keyword(Keyword::from("loop"))
}) {
let pos = loop_pos + 1;
list.get(pos)
.map(|l| String::from(l) == *"true")
.unwrap_or_default()
} else {
false
};
Self {
title: title.unwrap_or_default(),
@ -160,7 +154,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,147 +177,41 @@ 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 {
for video in v.into_iter() {
let _ = self.add_item(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>,
id: i32,
) -> Result<Vec<Video>> {
query!("DELETE FROM videos WHERE id = $1", id)
.execute(&*db)
.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)
}
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 update_video_in_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)
.map(|s| s.to_string())
.unwrap_or_default();
query!(
r#"UPDATE videos SET title = $2, file_path = $3, start_time = $4, end_time = $5, loop = $6 WHERE id = $1"#,
video.id,
@ -330,25 +221,18 @@ pub async fn update_video(
video.end_time,
video.looping,
)
.execute(&*db)
.await.into_diagnostic()?;
.execute(&mut db.detach())
.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)
Ok(())
}
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)]
@ -359,9 +243,7 @@ mod test {
fn test_video(title: String) -> Video {
Video {
title,
path: PathBuf::from(
"/home/chris/nc/tfc/Documents/lessons/videos/christ-nutshell.mp4",
),
path: PathBuf::from("~/vids/camprules2024.mp4"),
..Default::default()
}
}
@ -371,15 +253,17 @@ 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;
if let Some(video) = video_model.find(|v| v.id == 2) {
let test_video = test_video("christ-our-hope.mp4".into());
let mut db = crate::core::model::get_db().await;
video_model.load_from_db(&mut db).await;
if let Some(video) = video_model.find(|v| v.id == 73) {
let test_video = test_video(
"Getting started with Tokio. The ultimate starter guide to writing async Rust."
.into(),
);
assert_eq!(test_video.title, video.title);
} else {
panic!();
assert!(false);
}
}
@ -389,23 +273,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
),
}
}
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
}

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
})
}

View file

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

2941
src/main.rs Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

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

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

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

@ -1,17 +1,12 @@
use crate::core::model::LibraryKind;
// pub mod double_ended_slider;
pub mod image_editor;
pub mod double_ended_slider;
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_editor;
pub mod video;
pub mod widgets;
pub enum EditorMode {

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,354 +0,0 @@
use cosmic::iced::Size;
use cosmic::iced_core::widget::tree;
use cosmic::{
Element,
iced::core::{
self, Clipboard, Shell, layout, renderer, widget::Tree,
},
iced::{
Event, Length, Point, Rectangle, Vector,
clipboard::dnd::{DndEvent, SourceEvent},
event, mouse,
},
widget::Widget,
};
use tracing::debug;
use crate::core::service_items::ServiceItem;
#[must_use]
pub const fn service<Message: Clone + 'static>(
service: &Vec<ServiceItem>,
) -> Service<'_, Message> {
Service::new(service)
}
pub struct Service<'a, Message> {
items: &'a Vec<ServiceItem>,
on_start: Option<Message>,
on_cancelled: Option<Message>,
on_finish: Option<Message>,
drag_threshold: f32,
width: Length,
height: Length,
}
impl<'a, Message: Clone + 'static> Service<'a, Message> {
#[must_use]
pub const fn new(service_items: &'a Vec<ServiceItem>) -> Self {
Self {
items: service_items,
drag_threshold: 8.0,
on_start: None,
on_cancelled: None,
on_finish: None,
width: Length::Fill,
height: Length::Fill,
}
}
#[must_use]
pub const fn drag_threshold(mut self, threshold: f32) -> Self {
self.drag_threshold = threshold;
self
}
// pub fn start_dnd(
// &self,
// clipboard: &mut dyn Clipboard,
// bounds: Rectangle,
// offset: Vector,
// ) {
// let Some(content) = self.drag_content.as_ref().map(|f| f())
// else {
// return;
// };
// iced_core::clipboard::start_dnd(
// clipboard,
// false,
// if let Some(window) = self.window.as_ref() {
// Some(iced_core::clipboard::DndSource::Surface(
// *window,
// ))
// } else {
// Some(iced_core::clipboard::DndSource::Widget(
// self.id.clone(),
// ))
// },
// self.drag_icon.as_ref().map(|f| {
// let (icon, state, offset) = f(offset);
// iced_core::clipboard::IconSurface::new(
// container(icon)
// .width(Length::Fixed(bounds.width))
// .height(Length::Fixed(bounds.height))
// .into(),
// state,
// offset,
// )
// }),
// Box::new(content),
// DndAction::Move,
// );
// }
#[must_use]
pub fn on_start(mut self, on_start: Option<Message>) -> Self {
self.on_start = on_start;
self
}
#[must_use]
pub fn on_cancel(
mut self,
on_cancelled: Option<Message>,
) -> Self {
self.on_cancelled = on_cancelled;
self
}
#[must_use]
pub fn on_finish(mut self, on_finish: Option<Message>) -> Self {
self.on_finish = on_finish;
self
}
}
impl<Message: Clone + 'static>
Widget<Message, cosmic::Theme, cosmic::Renderer>
for Service<'_, Message>
{
fn size(&self) -> iced_core::Size<Length> {
Size {
width: Length::Fill,
height: Length::Fill,
}
}
fn layout(
&self,
_tree: &mut Tree,
_renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(limits, self.width, self.height)
}
fn state(&self) -> iced_core::widget::tree::State {
tree::State::new(State::new())
}
// fn operate(
// &self,
// tree: &mut Tree,
// layout: layout::Layout<'_>,
// renderer: &cosmic::Renderer,
// operation: &mut dyn iced_core::widget::Operation<()>,
// ) {
// operation.custom(
// (&mut tree.state) as &mut dyn Any,
// Some(&self.id),
// );
// operation.container(
// Some(&self.id),
// layout.bounds(),
// &mut |operation| {
// self.container.as_widget().operate(
// &mut tree.children[0],
// layout,
// renderer,
// operation,
// )
// },
// );
// }
fn on_event(
&mut self,
tree: &mut Tree,
event: Event,
layout: layout::Layout<'_>,
cursor: mouse::Cursor,
_renderer: &cosmic::Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) -> event::Status {
let state = tree.state.downcast_mut::<State>();
match event {
Event::Mouse(mouse_event) => match mouse_event {
mouse::Event::ButtonPressed(mouse::Button::Left) => {
if let Some(position) = cursor.position() {
if !state.hovered {
return event::Status::Ignored;
}
state.left_pressed_position = Some(position);
return event::Status::Captured;
}
}
mouse::Event::ButtonReleased(mouse::Button::Left)
if state.left_pressed_position.is_some() =>
{
state.left_pressed_position = None;
return event::Status::Captured;
}
mouse::Event::CursorMoved { .. } => {
if let Some(position) = cursor.position() {
if state.hovered {
// We ignore motion if we do not possess drag content by now.
if let Some(left_pressed_position) =
state.left_pressed_position
&& position
.distance(left_pressed_position)
> self.drag_threshold
{
if let Some(on_start) =
self.on_start.as_ref()
{
shell.publish(on_start.clone());
}
let _offset = Vector::new(
left_pressed_position.x
- layout.bounds().x,
left_pressed_position.y
- layout.bounds().y,
);
state.is_dragging = true;
state.left_pressed_position = None;
}
if !cursor.is_over(layout.bounds()) {
state.hovered = false;
return event::Status::Ignored;
}
} else if cursor.is_over(layout.bounds()) {
state.hovered = true;
}
return event::Status::Captured;
}
}
_ => return event::Status::Ignored,
},
Event::Dnd(DndEvent::Source(SourceEvent::Cancelled)) => {
debug!("canceled");
if state.is_dragging {
if let Some(m) = self.on_cancelled.as_ref() {
shell.publish(m.clone());
}
state.is_dragging = false;
return event::Status::Captured;
}
return event::Status::Ignored;
}
Event::Dnd(DndEvent::Source(SourceEvent::Finished)) => {
debug!("dropped");
if state.is_dragging {
if let Some(m) = self.on_finish.as_ref() {
shell.publish(m.clone());
}
state.is_dragging = false;
return event::Status::Captured;
}
return event::Status::Ignored;
}
Event::Dnd(event) => debug!(?event),
_ => return event::Status::Ignored,
}
event::Status::Ignored
}
// fn mouse_interaction(
// &self,
// tree: &Tree,
// layout: layout::Layout<'_>,
// cursor_position: mouse::Cursor,
// viewport: &Rectangle,
// renderer: &cosmic::Renderer,
// ) -> mouse::Interaction {
// let state = tree.state.downcast_ref::<State>();
// if state.is_dragging {
// return mouse::Interaction::Grabbing;
// }
// self.container.as_widget().mouse_interaction(
// &tree.children[0],
// layout,
// cursor_position,
// viewport,
// renderer,
// )
// }
fn draw(
&self,
_tree: &Tree,
_renderer: &mut cosmic::Renderer,
_theme: &cosmic::Theme,
_renderer_style: &renderer::Style,
_layout: layout::Layout<'_>,
_cursor_position: mouse::Cursor,
_viewport: &Rectangle,
) {
// let state = tree.state.downcast_mut::<State>();
for _item in self.items {}
}
// fn overlay<'b>(
// &'b mut self,
// tree: &'b mut Tree,
// layout: layout::Layout<'_>,
// renderer: &cosmic::Renderer,
// translation: Vector,
// ) -> Option<
// overlay::Element<
// 'b,
// Message,
// cosmic::Theme,
// cosmic::Renderer,
// >,
// > {
// self.container.as_widget_mut().overlay(
// &mut tree.children[0],
// layout,
// renderer,
// translation,
// )
// }
// #[cfg(feature = "a11y")]
// /// get the a11y nodes for the widget
// fn a11y_nodes(
// &self,
// layout: iced_core::Layout<'_>,
// state: &Tree,
// p: mouse::Cursor,
// ) -> iced_accessibility::A11yTree {
// let c_state = &state.children[0];
// self.container.as_widget().a11y_nodes(layout, c_state, p)
// }
}
impl<'a, Message: Clone + 'static> From<Service<'a, Message>>
for Element<'a, Message>
{
fn from(e: Service<'a, Message>) -> Self {
Element::new(e)
}
}
/// Local state of the [`MouseListener`].
#[derive(Debug, Default)]
struct State {
hovered: bool,
left_pressed_position: Option<Point>,
is_dragging: bool,
_cached_bounds: Rectangle,
}
impl State {
fn new() -> Self {
Self::default()
}
}

View file

@ -1,21 +1,24 @@
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 iced::{
widget::{
self,
canvas::{self, Program, Stroke},
container, Canvas,
},
Color, Font, Length, Renderer, Size,
};
use tracing::debug;
#[derive(Debug, Default)]
struct State {
_cache: canvas::Cache,
cache: canvas::Cache,
}
#[derive(Debug, Default)]
pub struct SlideEditor {
_state: State,
_font: Font,
state: State,
font: Font,
program: EditorProgram,
}
@ -31,16 +34,11 @@ pub enum Message {
}
pub struct Text {
_text: String,
}
pub struct Image {
_source: PathBuf,
text: String,
}
pub enum SlideWidget {
Text(Text),
Image(Image),
}
#[derive(Debug, Clone)]
@ -51,11 +49,14 @@ pub enum SlideError {
#[derive(Debug, Default)]
struct EditorProgram {
_mouse_button_pressed: Option<cosmic::iced::mouse::Button>,
mouse_button_pressed: Option<iced::mouse::Button>,
}
impl SlideEditor {
pub fn view(&self, _font: Font) -> cosmic::Element<'_, SlideWidget> {
pub fn view<'a>(
&'a self,
font: Font,
) -> iced::Element<'a, SlideWidget> {
container(
widget::canvas(&self.program)
.height(Length::Fill)
@ -65,19 +66,20 @@ impl SlideEditor {
}
}
/// Ensure to use the `cosmic::Theme and cosmic::Renderer` here
/// Ensure to use the `iced::Theme and iced::Renderer` here
/// or else it will not compile
#[allow(clippy::extra_unused_lifetimes)]
impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram {
impl<'a> Program<SlideWidget, iced::Theme, iced::Renderer>
for EditorProgram
{
type State = ();
fn draw(
&self,
_state: &Self::State,
state: &Self::State,
renderer: &Renderer,
_theme: &cosmic::Theme,
bounds: cosmic::iced::Rectangle,
_cursor: cosmic::iced::core::mouse::Cursor,
theme: &iced::Theme,
bounds: iced::Rectangle,
cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry<Renderer>> {
// We prepare a new `Frame`
let mut frame = canvas::Frame::new(renderer, bounds.size());
@ -86,7 +88,7 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
// We create a `Path` representing a simple circle
let circle = canvas::Path::circle(frame.center(), 50.0);
let border = canvas::Path::rectangle(
cosmic::iced::Point { x: 10.0, y: 10.0 },
iced::Point { x: 10.0, y: 10.0 },
Size::new(frame_rect.width, frame_rect.height),
);
@ -94,11 +96,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
@ -108,19 +114,19 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
fn update(
&self,
_state: &mut Self::State,
event: &canvas::Event,
bounds: cosmic::iced::Rectangle,
_cursor: cosmic::iced::core::mouse::Cursor,
) -> Option<cosmic::iced::widget::Action<SlideWidget>> {
event: &iced::Event,
bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> std::option::Option<iced::widget::Action<SlideWidget>> {
match event {
canvas::Event::Mouse(event) => match event {
cosmic::iced::mouse::Event::CursorEntered => {
debug!("cursor entered");
iced::Event::Mouse(event) => match event {
iced::mouse::Event::CursorEntered => {
debug!("cursor entered")
}
cosmic::iced::mouse::Event::CursorLeft => {
debug!("cursor left");
iced::mouse::Event::CursorLeft => {
debug!("cursor left")
}
cosmic::iced::mouse::Event::CursorMoved { position } => {
iced::mouse::Event::CursorMoved { position } => {
if bounds.x < position.x
&& bounds.y < position.y
&& (bounds.width + bounds.x) > position.x
@ -129,26 +135,24 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
debug!(?position, "cursor moved");
}
}
cosmic::iced::mouse::Event::ButtonPressed(button) => {
iced::mouse::Event::ButtonPressed(button) => {
// self.mouse_button_pressed = Some(button);
debug!(?button, "mouse button pressed");
debug!(?button, "mouse button pressed")
}
cosmic::iced::mouse::Event::ButtonReleased(button) => {
debug!(?button, "mouse button released");
iced::mouse::Event::ButtonReleased(button) => {
debug!(?button, "mouse button released")
}
cosmic::iced::mouse::Event::WheelScrolled { delta } => {
debug!(?delta, "scroll wheel");
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::Dnd(_dnd_event) => todo!(),
canvas::Event::PlatformSpecific(_platform_specific) => {
todo!()
}
iced::Event::Touch(event) => debug!("test"),
iced::Event::Keyboard(event) => debug!("test"),
iced::Event::Keyboard(event) => todo!(),
iced::Event::Mouse(event) => todo!(),
iced::Event::Window(event) => todo!(),
iced::Event::Touch(event) => todo!(),
iced::Event::InputMethod(event) => todo!(),
}
None
}
@ -156,9 +160,9 @@ impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer> for EditorProgram
fn mouse_interaction(
&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()
_bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> iced::mouse::Interaction {
iced::mouse::Interaction::default()
}
}

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

File diff suppressed because it is too large Load diff

View file

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

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,272 +0,0 @@
use std::io;
use std::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 tracing::{debug, error, warn};
use url::Url;
use crate::core::videos;
use crate::ui::gst_video;
#[derive(Debug)]
pub struct VideoEditor {
pub video: Option<Video>,
core_video: Option<videos::Video>,
title: String,
editing: bool,
position: f64,
}
pub enum Action {
Task(Task<Message>),
UpdateVideo(videos::Video),
None,
}
#[derive(Debug, Clone)]
pub enum Message {
ChangeVideo(videos::Video),
Update(videos::Video),
ChangeTitle(String),
PickVideo,
Edit(bool),
None,
PauseVideo,
UpdateVideoFile(videos::Video),
VideoPos(f64),
NewFrame,
}
impl VideoEditor {
#[must_use]
pub fn new() -> Self {
Self {
video: None,
core_video: None,
title: "Death was Arrested".to_string(),
editing: false,
position: 0.0,
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangeVideo(video) => {
self.update_entire_video(&video);
}
Message::ChangeTitle(title) => {
self.title.clone_from(&title);
if let Some(video) = &self.core_video {
let mut video = video.clone();
video.title = title;
return self.update(Message::Update(video));
}
}
Message::Edit(edit) => {
debug!(edit);
self.editing = edit;
}
Message::PauseVideo => {
if let Some(video) = &mut self.video {
let paused = video.paused();
video.set_paused(!paused);
}
}
Message::Update(video) => {
warn!(?video);
return Action::UpdateVideo(video);
}
Message::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)
})
});
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
}
pub fn view(&self) -> Element<Message> {
let video_elements = self.video.as_ref().map_or_else(
|| container(horizontal()),
|video| {
let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start-symbolic")
} else {
icon::from_name("media-playback-pause-symbolic")
})
.on_press(Message::PauseVideo);
let video_track = slider(
0.0..=video.duration().as_secs_f64(),
video.position().as_secs_f64(),
Message::VideoPos,
)
.step(0.1)
.width(Length::Fill)
.height(cosmic::theme::spacing().space_s);
container(
row![play_button, video_track]
.align_y(Vertical::Center)
.spacing(cosmic::theme::spacing().space_m),
)
.padding(cosmic::theme::spacing().space_s)
.center_x(Length::FillPortion(2))
},
);
let video_player = self
.video
.as_ref()
.map_or_else(
|| Space::new().apply(container),
|video| {
VideoPlayer::new(video)
.on_new_frame(Message::NewFrame)
.apply(container)
},
)
.center(Length::Fill);
let video_section = column![video_player, video_elements]
.spacing(cosmic::theme::spacing().space_s);
let column = column![
self.toolbar(),
container(video_section).center_x(Length::FillPortion(2))
]
.spacing(theme::active().cosmic().space_l());
column.into()
}
fn toolbar(&self) -> Element<Message> {
let title_box =
text_input("Title...", &self.title).on_input(Message::ChangeTitle);
let video_selector =
button::icon(icon::from_name("folder-videos-symbolic").scale(2))
.label("Video")
.tooltip("Select a video")
.on_press(Message::PickVideo)
.padding(10);
row![
text::body("Title:"),
title_box,
space::horizontal(),
video_selector
]
.align_y(Vertical::Center)
.spacing(10)
.into()
}
pub const fn editing(&self) -> bool {
self.editing
}
fn update_entire_video(&mut self, video: &videos::Video) {
debug!(?video);
let Ok(url) = Url::from_file_path(video.path.clone()) 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);
self.core_video = Some(video.clone());
}
}
impl Default for VideoEditor {
fn default() -> Self {
Self::new()
}
}
async fn pick_video() -> Result<PathBuf, VideoError> {
let dialog = Dialog::new().title("Choose a video...");
let bg_filter = FileFilter::new("Videos")
.extension("mp4")
.extension("webm")
.extension("mkv");
dialog
.filter(bg_filter)
.directory(dirs::home_dir().expect("oops"))
.open_file()
.await
.map_err(|e| {
error!(?e);
VideoError::DialogClosed
})
.map(|file| file.url().to_file_path().expect("Should be a file here"))
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(
// "Images and Videos",
// &["png", "jpeg", "mp4", "webm", "mkv", "jpg", "mpeg"],
// )
// .set_directory(dirs::home_dir().unwrap())
// .pick_file()
// .await
// .ok_or(VideoError::BackgroundDialogClosed)
// .map(|file| file.path().to_owned())
}
#[derive(Debug, Clone)]
pub enum VideoError {
DialogClosed,
IOError(io::ErrorKind),
}

View file

@ -1,797 +0,0 @@
//! Distribute draggable content vertically.
// This widget is a modification of the original `Column` widget from [`iced`]
//
// [`iced`]: https://github.com/iced-rs/iced
//
// Copyright 2019 Héctor Ramón, Iced contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
use cosmic::Theme;
use cosmic::iced::advanced::layout::{self, Layout};
use cosmic::iced::advanced::widget::{Operation, Tree, Widget, tree};
use cosmic::iced::advanced::{Clipboard, Shell, overlay, renderer};
use cosmic::iced::alignment::{self, Alignment};
use cosmic::iced::event::Event;
use cosmic::iced::{
self, Background, Border, Color, Element, Length, Padding, Pixels, Point, Rectangle,
Size, Transformation, Vector, mouse,
};
use super::{Action, DragEvent, DropPosition};
pub fn column<'a, Message, Theme, Renderer>(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
Column::with_children(children)
}
const DRAG_DEADBAND_DISTANCE: f32 = 5.0;
/// A container that distributes its contents vertically.
///
/// # Example
/// ```no_run
/// # mod iced { pub mod widget { pub use iced_widget::*; } }
/// # pub type State = ();
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
/// use iced::widget::{button, column};
///
/// #[derive(Debug, Clone)]
/// enum Message {
/// // ...
/// }
///
/// fn view(state: &State) -> Element<'_, Message> {
/// column![
/// "I am on top!",
/// button("I am in the center!"),
/// "I am below.",
/// ].into()
/// }
/// ```
#[allow(missing_debug_implementations)]
pub struct Column<'a, Message, Theme = cosmic::Theme, Renderer = iced::Renderer>
where
Theme: Catalog,
{
spacing: f32,
padding: Padding,
width: Length,
height: Length,
max_width: f32,
align: Alignment,
clip: bool,
deadband_zone: f32,
children: Vec<Element<'a, Message, Theme, Renderer>>,
on_drag: Option<Box<dyn Fn(DragEvent) -> Message + 'a>>,
class: Theme::Class<'a>,
}
impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
/// Creates an empty [`Column`].
#[must_use]
pub fn new() -> Self {
Self::from_vec(Vec::new())
}
/// Creates a [`Column`] with the given capacity.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self::from_vec(Vec::with_capacity(capacity))
}
/// Creates a [`Column`] with the given elements.
pub fn with_children(
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
let iterator = children.into_iter();
Self::with_capacity(iterator.size_hint().0).extend(iterator)
}
/// Creates a [`Column`] from an already allocated [`Vec`].
///
/// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means
/// it won't automatically adapt to the sizing strategy of its contents.
///
/// If any of the children have a [`Length::Fill`] strategy, you will need to
/// call [`Column::width`] or [`Column::height`] accordingly.
#[must_use]
pub fn from_vec(children: Vec<Element<'a, Message, Theme, Renderer>>) -> Self {
Self {
spacing: 0.0,
padding: Padding::ZERO,
width: Length::Shrink,
height: Length::Shrink,
max_width: f32::INFINITY,
align: Alignment::Start,
clip: false,
deadband_zone: DRAG_DEADBAND_DISTANCE,
children,
class: Theme::default(),
on_drag: None,
}
}
/// Sets the vertical spacing _between_ elements.
///
/// Custom margins per element do not exist in iced. You should use this
/// method instead! While less flexible, it helps you keep spacing between
/// elements consistent.
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
self.spacing = amount.into().0;
self
}
/// Sets the [`Padding`] of the [`Column`].
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.padding = padding.into();
self
}
/// Sets the width of the [`Column`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Column`].
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
/// Sets the maximum width of the [`Column`].
pub fn max_width(mut self, max_width: impl Into<Pixels>) -> Self {
self.max_width = max_width.into().0;
self
}
/// Sets the horizontal alignment of the contents of the [`Column`] .
pub fn align_x(mut self, align: impl Into<alignment::Horizontal>) -> Self {
self.align = Alignment::from(align.into());
self
}
/// Sets whether the contents of the [`Column`] should be clipped on
/// overflow.
pub const fn clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
/// Sets the drag deadband zone of the [`Column`].
pub const fn deadband_zone(mut self, deadband_zone: f32) -> Self {
self.deadband_zone = deadband_zone;
self
}
/// Adds an element to the [`Column`].
pub fn push(
mut self,
child: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let child = child.into();
let child_size = child.as_widget().size_hint();
self.width = self.width.enclose(child_size.width);
self.height = self.height.enclose(child_size.height);
self.children.push(child);
self
}
/// Adds an element to the [`Column`], if `Some`.
pub fn push_maybe(
self,
child: Option<impl Into<Element<'a, Message, Theme, Renderer>>>,
) -> Self {
if let Some(child) = child {
self.push(child)
} else {
self
}
}
/// Sets the style of the [`Column`].
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
/// Sets the style class of the [`Column`].
#[must_use]
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
/// Extends the [`Column`] with the given children.
pub fn extend(
self,
children: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
) -> Self {
children.into_iter().fold(self, Self::push)
}
/// The message produced by the [`Column`] when a child is dragged.
pub fn on_drag(mut self, on_reorder: impl Fn(DragEvent) -> Message + 'a) -> Self {
self.on_drag = Some(Box::new(on_reorder));
self
}
// Computes the index and position where a dragged item should be dropped.
fn compute_target_index(
&self,
cursor_position: Point,
layout: Layout<'_>,
dragged_index: usize,
) -> (usize, DropPosition) {
let cursor_y = cursor_position.y;
for (i, child_layout) in layout.children().enumerate() {
let bounds = child_layout.bounds();
let y = bounds.y;
let height = bounds.height;
if cursor_y >= y && cursor_y <= y + height {
if i == dragged_index {
// Cursor is over the dragged item itself
return (i, DropPosition::Swap);
}
let thickness = height / 4.0;
let top_threshold = y + thickness;
let bottom_threshold = y + height - thickness;
if cursor_y < top_threshold {
// Near the top edge - insert above
return (i, DropPosition::Before);
} else if cursor_y > bottom_threshold {
// Near the bottom edge - insert below
return (i + 1, DropPosition::After);
}
// Middle area - swap
return (i, DropPosition::Swap);
} else if cursor_y < y {
// Cursor is above this child
return (i, DropPosition::Before);
}
}
// Cursor is below all children
(self.children.len(), DropPosition::After)
}
}
impl<Message, Renderer> Default for Column<'_, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
fn default() -> Self {
Self::new()
}
}
impl<'a, Message, Theme, Renderer: renderer::Renderer>
FromIterator<Element<'a, Message, Theme, Renderer>>
for Column<'a, Message, Theme, Renderer>
where
Theme: Catalog,
{
fn from_iter<T: IntoIterator<Item = Element<'a, Message, Theme, Renderer>>>(
iter: T,
) -> Self {
Self::with_children(iter)
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Column<'_, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Theme: Catalog,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Action>()
}
fn state(&self) -> tree::State {
tree::State::new(Action::Idle)
}
fn children(&self) -> Vec<Tree> {
self.children.iter().map(Tree::new).collect()
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(self.children.as_mut_slice());
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let limits = limits.max_width(self.max_width);
layout::flex::resolve(
layout::flex::Axis::Vertical,
renderer,
&limits,
self.width,
self.height,
self.padding,
self.spacing,
self.align,
self.children.as_mut(),
&mut tree.children,
)
}
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.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
.for_each(|((child, state), c_layout)| {
child.as_widget_mut().operate(
state,
c_layout.with_virtual_offset(layout.virtual_offset()),
renderer,
operation,
);
});
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
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) {
*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(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let action = tree.state.downcast_ref::<Action>();
if let Action::Dragging { .. } = *action {
return mouse::Interaction::Grabbing;
}
self.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.map(|((child, state), c_layout)| {
child.as_widget().mouse_interaction(
state,
c_layout.with_virtual_offset(layout.virtual_offset()),
cursor,
viewport,
renderer,
)
})
.max()
.unwrap_or_default()
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
default_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let action = tree.state.downcast_ref::<Action>();
let style = theme.style(&self.class);
match action {
Action::Dragging {
index,
last_cursor,
origin,
..
} => {
let child_count = self.children.len();
// Determine the target index based on cursor position
let target_index = if cursor.position().is_some() {
let (target_index, _) =
self.compute_target_index(*last_cursor, layout, *index);
target_index.min(child_count - 1)
} else {
*index
};
// Store the width of the dragged item
let drag_bounds = layout.children().nth(*index).unwrap().bounds();
let drag_height = drag_bounds.height + self.spacing;
// Draw all children except the one being dragged
let mut translations = 0.0;
for i in 0..child_count {
let child = &self.children[i];
let state = &tree.children[i];
let child_layout = layout.children().nth(i).unwrap();
// Draw the dragged item separately
// TODO: Draw a shadow below the picked item to enhance the
// floating effect
if i == *index {
let scaling = Transformation::scale(style.scale);
let translation = *last_cursor - *origin * scaling;
renderer.with_translation(translation, |renderer| {
renderer.with_transformation(scaling, |renderer| {
renderer.with_layer(child_layout.bounds(), |renderer| {
child.as_widget().draw(
state,
renderer,
theme,
default_style,
child_layout,
cursor,
viewport,
);
});
});
});
} else {
let offset: i32 = match target_index.cmp(index) {
std::cmp::Ordering::Less
if i >= target_index && i < *index =>
{
1
}
std::cmp::Ordering::Greater
if i > *index && i <= target_index =>
{
-1
}
_ => 0,
};
let translation = Vector::new(0.0, offset as f32 * drag_height);
renderer.with_translation(translation, |renderer| {
child.as_widget().draw(
state,
renderer,
theme,
default_style,
child_layout,
cursor,
viewport,
);
// Draw an overlay if this item is being moved
// TODO: instead of drawing an overlay, it would be nicer to
// draw the item with a reduced opacity, but that's not possible today
if offset != 0 {
renderer.fill_quad(
renderer::Quad {
bounds: child_layout.bounds(),
..renderer::Quad::default()
},
style.moved_item_overlay,
);
// Keep track of the total translation so we can
// draw the "ghost" of the dragged item later
translations -= (child_layout.bounds().height
+ self.spacing)
* offset.signum() as f32;
}
});
}
}
// Draw a ghost of the dragged item in its would-be position
let ghost_translation = Vector::new(0.0, translations);
renderer.with_translation(ghost_translation, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: drag_bounds,
border: style.ghost_border,
..renderer::Quad::default()
},
style.ghost_background,
);
});
}
_ => {
// Draw all children normally when not dragging
if let Some(clipped_viewport) = layout.bounds().intersection(viewport) {
let viewport = if self.clip {
&clipped_viewport
} else {
viewport
};
for ((child, state), c_layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.filter(|(_, layout)| layout.bounds().intersects(viewport))
{
child.as_widget().draw(
state,
renderer,
theme,
default_style,
c_layout.with_virtual_offset(layout.virtual_offset()),
cursor,
viewport,
);
}
}
}
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
overlay::from_children(
&mut self.children,
tree,
layout,
renderer,
viewport,
translation,
)
}
fn drag_destinations(
&self,
state: &Tree,
layout: Layout<'_>,
renderer: &Renderer,
dnd_rectangles: &mut cosmic::iced::core::clipboard::DndDestinationRectangles,
) {
for ((e, c_layout), state) in self
.children
.iter()
.zip(layout.children())
.zip(state.children.iter())
{
e.as_widget().drag_destinations(
state,
c_layout.with_virtual_offset(layout.virtual_offset()),
renderer,
dnd_rectangles,
);
}
}
}
impl<'a, Message, Theme, Renderer> From<Column<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: Catalog + 'a,
Renderer: renderer::Renderer + 'a,
{
fn from(column: Column<'a, Message, Theme, Renderer>) -> Self {
Self::new(column)
}
}
/// The theme catalog of a [`Column`].
pub trait Catalog {
/// The item class of the [`Catalog`].
type Class<'a>;
/// The default class produced by the [`Catalog`].
fn default<'a>() -> Self::Class<'a>;
/// The [`Style`] of a class with the given status.
fn style(&self, class: &Self::Class<'_>) -> Style;
}
/// The appearance of a [`Column`].
#[derive(Debug, Clone, Copy)]
pub struct Style {
/// The scaling to apply to a picked element while it's being dragged.
pub scale: f32,
/// The color of the overlay on items that are moved around
pub moved_item_overlay: Color,
/// The outline border of the dragged item's ghost
pub ghost_border: Border,
/// The background of the dragged item's ghost
pub ghost_background: Background,
}
/// A styling function for a [`Column`].
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
impl Catalog for cosmic::Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(default)
}
fn style(&self, class: &Self::Class<'_>) -> Style {
class(self)
}
}
#[must_use]
pub fn default(theme: &Theme) -> Style {
Style {
scale: 1.05,
moved_item_overlay: Color::from(theme.cosmic().primary.base).scale_alpha(0.2),
ghost_border: Border {
width: 1.0,
color: theme.cosmic().secondary.base.into(),
radius: 0.0.into(),
},
ghost_background: Color::from(theme.cosmic().secondary.base)
.scale_alpha(0.2)
.into(),
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,44 +0,0 @@
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)]
pub enum Action {
Idle,
Picking {
index: usize,
origin: Point,
},
Dragging {
index: usize,
origin: Point,
last_cursor: Point,
},
}
#[derive(Debug, Clone, Copy)]
pub enum DropPosition {
Before,
Swap,
After,
}
#[derive(Debug, Clone)]
pub enum DragEvent {
Picked {
index: usize,
},
Dropped {
index: usize,
target_index: usize,
drop_position: DropPosition,
},
Canceled {
index: usize,
},
}

Some files were not shown because too many files have changed in this diff Show more