Compare commits

...
Sign in to create a new pull request.

124 commits

Author SHA1 Message Date
a14e0ff13f fixing some presentation/library bugs
Some checks failed
/ clippy (push) Failing after 5m10s
/ test (push) Failing after 5m39s
2026-03-06 16:52:04 -06:00
3ac38e4769 some ui tweaks 2026-03-06 12:05:24 -06:00
11fab45a30 fix splitting pdfs so they update in db 2026-03-06 11:55:33 -06:00
19fc673761 fixing eliding text panicing when using text that isn't UTF-8 2026-03-06 11:35:02 -06:00
508d9a2d88 update todo 2026-03-06 11:34:58 -06:00
5c8b3f344f update todo
Some checks failed
/ clippy (push) Failing after 4m34s
/ test (push) Failing after 5m20s
2026-03-06 07:30:19 -06:00
dc2ddec965 update todo
Some checks failed
/ clippy (push) Failing after 4m59s
/ test (push) Failing after 5m20s
2026-03-06 07:03:53 -06:00
eeb969722e update todo
Some checks failed
/ clippy (push) Failing after 4m48s
/ test (push) Failing after 5m9s
2026-03-05 10:34:15 -06:00
acfbda6463 update todo
Some checks failed
/ clippy (push) Failing after 4m51s
/ test (push) Failing after 5m22s
2026-02-24 15:21:54 -06:00
67b2bf2478 adding genius lyrics searcher
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-02-24 15:18:51 -06:00
cf691a2de2 split this into its own function
Some checks failed
/ clippy (push) Successful in 6m25s
/ test (push) Failing after 12m51s
2026-02-23 15:18:38 -06:00
57d0380ca3 converting OnlineSong to Song testing 2026-02-23 15:16:46 -06:00
c482436d05 cleanup: some refactoring in the file module 2026-02-23 15:16:25 -06:00
e0d2944437 update todo 2026-02-23 15:16:18 -06:00
c30f077246 bugfix: fixing the loading not creating the handle to the TextSvg
Some checks failed
/ clippy (push) Failing after 4m49s
/ test (push) Failing after 5m21s
2026-02-23 12:12:45 -06:00
1b3e67ada1 testing: some other test data fixes
Some checks failed
/ clippy (push) Successful in 4m44s
/ test (push) Failing after 5m29s
2026-02-23 12:01:39 -06:00
24b444567a testing: fix the testing data to allow the fontdb to choose a font
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-02-23 11:59:58 -06:00
df1e247576 testing: loading test is more robust around size
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-02-23 11:57:46 -06:00
0710eb9609 bugfix: adding TextSvg to the saving and loading functions
Some checks failed
/ clippy (push) Failing after 4m59s
/ test (push) Failing after 5m29s
2026-02-23 10:01:34 -06:00
605cf4c271 update todo 2026-02-20 23:02:18 -06:00
d7061508f5 getting closer to some tests that may help with proper loading 2026-02-20 22:55:44 -06:00
9026e0505e update todo
Some checks failed
/ clippy (push) Successful in 5m21s
/ test (push) Failing after 11m55s
2026-02-18 13:44:33 -06:00
985710b7c5 bugs: loading is nearly working...
There is a bug with the slides not containing the text portions of the
songs. There is also perhaps a bug in the logic to move to the next
and previous slides
2026-02-18 13:35:46 -06:00
3685670ff7 bugfix: fixing scrolling issues with the verse editors
Some checks failed
/ clippy (push) Successful in 4m43s
/ test (push) Failing after 5m26s
2026-02-18 11:54:07 -06:00
3e9882ba75 adding a semi working load function with some testing 2026-02-18 11:45:49 -06:00
56d5e684b9 building a loading function
Some checks failed
/ clippy (push) Failing after 5m14s
/ test (push) Failing after 10m48s
2026-02-18 10:07:03 -06:00
bc0464ef03 trying to fix the scrolling in the verse editors
Some checks failed
/ clippy (push) Failing after 4m59s
/ test (push) Failing after 14m57s
2026-02-17 15:23:11 -06:00
a36af6bc3b fixing unused import 2026-02-17 15:07:55 -06:00
57e5177f8b fixing inconsistencies in the ui 2026-02-17 15:04:02 -06:00
12847da660 update todo 2026-02-17 14:25:30 -06:00
c9819472c7 unnecessary jobs 2026-02-17 14:23:39 -06:00
c6ae2a0fbd updating db to allow for changing the style and weight of the font
Also, fixed a bug in showing the stroke and shadow size if set to 0
2026-02-17 14:22:34 -06:00
d77205cdec add a linting check to forgejo actions
All checks were successful
/ clippy (push) Successful in 4m53s
/ test (push) Successful in 5m25s
2026-02-17 11:20:52 -06:00
02773f068f lints: Fixed the rest of the lints and some bugs along the way 2026-02-17 11:18:21 -06:00
452f1fc0c7 fix imports format 2026-02-17 09:47:00 -06:00
2112eb1d19 more linties
All checks were successful
/ test (push) Successful in 5m50s
2026-02-17 09:46:20 -06:00
f8a72b1359 fix some more lints
All checks were successful
/ test (push) Successful in 5m27s
2026-02-16 15:19:12 -06:00
01bffe2014 allowing option_if_let_else and fixing a lot of them
All checks were successful
/ test (push) Successful in 5m58s
I was misunderstanding how to handle the references in these closure
contexts again. But now some of that code is a bit more idiomatic/readable
2026-02-16 15:07:20 -06:00
40ae229391 fix more broken tests
All checks were successful
/ test (push) Successful in 5m32s
2026-02-16 10:40:56 -06:00
475896f754 CI tests should skip some that are reliant on filesystem of user
Some checks failed
/ test (push) Failing after 6m2s
2026-02-16 10:25:03 -06:00
6aa6f94e05 fix failing test in CI
Some checks failed
/ test (push) Failing after 5m29s
2026-02-16 10:09:07 -06:00
d9de88bc99 bugfix: search input not updating to typing
Some checks failed
/ test (push) Failing after 5m35s
2026-02-16 06:34:55 -06:00
e792be5e4c mooooooor lints 2026-02-16 06:34:42 -06:00
1c8a0bf450 mor lint fixes
Some checks failed
/ test (push) Failing after 5m41s
2026-02-15 22:06:55 -06:00
2e8829027a clippy warnings down to 250 when using pedantic, nursery, and perf
Some checks failed
/ test (push) Failing after 12m50s
2026-02-15 14:44:25 -06:00
66cea56767 fixing song_editor not using real dropdown values and some lints
Some checks failed
/ test (push) Has been cancelled
2026-02-15 14:40:28 -06:00
8803976d2d removing these 2026-02-15 12:57:41 -06:00
81e2734e8a a lot more linting fixes 2026-02-15 12:57:26 -06:00
0d7e5b9285 fixing a lot of lints
Some checks failed
/ test (push) Failing after 5m47s
2026-02-15 11:17:30 -06:00
3139bd3488 sort the fonts 2026-02-13 17:27:29 -06:00
560e922fed unsure on name of extension
Some checks failed
/ test (push) Failing after 5m39s
2026-02-13 16:59:07 -06:00
0c36e180a5 update gitignore so test files aren't in there
Some checks failed
/ test (push) Failing after 5m21s
2026-02-13 15:25:36 -06:00
66d286816b fonts now show up as more options but...
Some checks failed
/ test (push) Has been cancelled
They still don't always pick the right one to render the text
2026-02-13 15:22:41 -06:00
bf2fa842cd feature: font style and weight is configurable but not persistant
Some checks failed
/ test (push) Failing after 5m27s
2026-02-13 14:44:28 -06:00
6327ed51b4 update todo 2026-02-13 14:44:21 -06:00
4ae0fea807 add bigbuckbunny as a test video for creating thumbnails
Some checks failed
/ test (push) Failing after 5m20s
2026-02-13 11:55:54 -06:00
7aeb15f2ff try this
Some checks failed
/ test (push) Failing after 5m27s
2026-02-13 11:37:00 -06:00
a71f276367 moving the just command
Some checks failed
/ test (push) Failing after 1m26s
2026-02-13 11:24:32 -06:00
0b6a1989a3 welll
Some checks failed
/ test (push) Failing after 1m27s
2026-02-13 11:20:00 -06:00
eb578db1b6 grrrr
Some checks failed
/ test (push) Failing after 29s
2026-02-13 11:17:28 -06:00
f8a9d6a92e switch to nix-env so node is installed
Some checks failed
/ test (push) Failing after 26s
2026-02-13 11:16:04 -06:00
620ce25a27 ensure node is on the image
Some checks failed
/ test (push) Failing after 7s
2026-02-13 11:14:30 -06:00
893e118836 add back checkout
Some checks failed
/ test (push) Failing after 2s
2026-02-13 11:13:09 -06:00
cc82f4f36a try adding the nix-command feature
Some checks failed
/ test (push) Failing after 1s
2026-02-13 11:12:02 -06:00
57db89b9cf try remove the checkout?
Some checks failed
/ test (push) Failing after 1s
2026-02-13 11:11:04 -06:00
efe4a264be try the nixos-latest image
Some checks failed
/ test (push) Failing after 24s
2026-02-13 11:09:54 -06:00
6a281ac4f6 update
Some checks failed
/ test (push) Failing after 12m52s
2026-02-13 10:56:35 -06:00
54650ba011 try this
Some checks failed
/ test (push) Failing after 2s
2026-02-13 10:52:30 -06:00
e4baa81434 adding a better workflow for basic testing
Some checks are pending
/ test (push) Waiting to run
2026-02-13 10:50:06 -06:00
595769ddd9 remove lots of old dead code in file saving system
Some checks failed
/ test (push) Failing after 28s
2026-02-13 10:33:50 -06:00
e44fb3a3e1 fixtest: test_song_from_db
Some checks are pending
/ test (push) Waiting to run
2026-02-13 10:28:15 -06:00
959cec1351 update test.db
Some checks are pending
/ test (push) Waiting to run
2026-02-13 10:24:25 -06:00
8c2861fa30 update todo 2026-02-13 10:24:25 -06:00
c4c4cd7cc3 fixing more file saving
Some checks are pending
/ test (push) Waiting to run
2026-02-13 10:23:49 -06:00
0d799f7ee3 some work on the saving system
Some checks are pending
/ test (push) Waiting to run
2026-02-12 15:48:58 -06:00
2ca91dbc44 some clippy stuff
Some checks are pending
/ test (push) Waiting to run
2026-02-12 15:48:43 -06:00
28d09120e0 clippy fix
Some checks are pending
/ test (push) Waiting to run
2026-02-12 13:52:52 -06:00
01ad8bcc83 fix some unused imports 2026-02-12 13:47:50 -06:00
9f4586b275 setup for import view dialog
Some checks are pending
/ test (push) Waiting to run
2026-02-12 11:50:49 -06:00
0b71cab26e remove some unwraps
Some checks are pending
/ test (push) Waiting to run
2026-02-12 11:44:04 -06:00
4fd73eec0b bugfix: storing of song details was not working in some edge cases
Some checks are pending
/ test (push) Waiting to run
This fix is using the names of columns in sqlite to make sure we are
only grabbing the right values
2026-02-12 11:28:06 -06:00
2d3ba561c0 bugfix: verse change to blank now deletes the lyrics
Some checks are pending
/ test (push) Waiting to run
2026-02-11 16:11:23 -06:00
6173e09422 ensure updating and deleting verses are previewed right
Some checks are pending
/ test (push) Waiting to run
2026-02-11 15:21:46 -06:00
2dbbeb96cf use rust-overlay instead of fenix
Some checks are pending
/ test (push) Waiting to run
2026-02-11 14:43:19 -06:00
c2fa5bf6dc updated for tests
Some checks are pending
/ test (push) Waiting to run
2026-02-11 14:17:17 -06:00
21c49fcd41 fix some issues with using the wrong db in tests
Some checks are pending
/ test (push) Waiting to run
2026-02-11 14:15:29 -06:00
2d68fd6477 migrate db to add stroke and shadow to songs 2026-02-11 14:15:17 -06:00
0d18724859 update stroke and shadow and fixing tests for it 2026-02-11 14:14:55 -06:00
52cda40da6 add shadow icons
Some checks are pending
/ test (push) Waiting to run
2026-02-11 11:14:43 -06:00
3eb5479240 setting of stroke and shadow works
Some checks are pending
/ test (push) Waiting to run
2026-02-11 11:14:10 -06:00
eb5fbd5a48 more stroke sizes 2026-02-11 09:50:27 -06:00
4bd8cf04d4 update slides and songs to generate strokes and shadows in text_svg
Some checks are pending
/ test (push) Waiting to run
2026-02-11 06:25:55 -06:00
3b960c5a17 idk
Some checks are pending
/ test (push) Waiting to run
2026-02-10 15:24:46 -06:00
4278b64322 forgot that fontdb needs loaded
Some checks are pending
/ test (push) Waiting to run
2026-02-10 14:27:48 -06:00
b2dd665e63 more testing framework
Some checks are pending
/ test (push) Waiting to run
2026-02-10 14:24:46 -06:00
203bfad894 setup a noncached svg generator and test its speed
Some checks are pending
/ test (push) Waiting to run
2026-02-10 13:46:07 -06:00
ddd1a42309 update todo 2026-02-10 13:13:42 -06:00
473f4aaa34 back to ole faithful
Some checks are pending
/ test (push) Waiting to run
2026-02-10 13:11:25 -06:00
a2c137b256 try again again
Some checks are pending
/ test (push) Waiting to run
2026-02-10 12:53:55 -06:00
8b78357cde try this again
Some checks are pending
/ test (push) Waiting to run
2026-02-10 12:27:26 -06:00
aefac265f3 try this instead
Some checks are pending
/ test (push) Waiting to run
2026-02-10 12:19:29 -06:00
324a8df24e update gitignore
Some checks are pending
/ test (push) Waiting to run
2026-02-10 11:50:58 -06:00
eeb89f8971 setting up a possible stream option 2026-02-10 11:50:10 -06:00
9d3fd7f8de some possible benching later 2026-02-10 11:49:57 -06:00
a8c869dc14 remove benches
Some checks are pending
/ test (push) Waiting to run
2026-02-10 10:17:20 -06:00
77af991b2b make test_song public 2026-02-10 09:54:54 -06:00
ede8716096 add bench structure 2026-02-10 09:54:42 -06:00
b8bb67cfc9 add benches 2026-02-10 09:54:34 -06:00
b3c7eb747d update gitignore
Some checks are pending
/ test (push) Waiting to run
2026-02-10 09:41:46 -06:00
f1f3bb2261 add benching to nextest 2026-02-10 09:41:09 -06:00
50b26d59d9 tests are passing
Some checks are pending
/ test (push) Waiting to run
2026-02-10 09:37:50 -06:00
0165945d91 add nerdfont.ttf
Some checks are pending
/ test (push) Waiting to run
2026-02-10 09:29:05 -06:00
3fdd378a39 add the test db
Some checks are pending
/ test (push) Waiting to run
2026-02-10 09:28:47 -06:00
1acec2c950 some work on tests
Some checks are pending
/ test (push) Waiting to run
2026-02-10 06:40:50 -06:00
86bec476b8 remove dbg! calls after tests have passed
Some checks are pending
/ test (push) Waiting to run
2026-02-09 19:16:41 -06:00
eb0dbf7235 remove lisp test 2026-02-09 19:16:33 -06:00
87639e9ad3 remove unnecessary cargo changes 2026-02-09 19:16:21 -06:00
64c512f0ca update todo 2026-02-09 19:16:13 -06:00
5e46dbae17 a better way to profile
Some checks are pending
/ test (push) Waiting to run
2026-02-09 19:11:55 -06:00
e006068863 make TextSvg and Slide better designed 2026-02-09 15:04:07 -06:00
87655baa76 tweaks to profile 2026-02-09 15:03:55 -06:00
1118c0bcb2 reduce resolution for generated svg for space savings 2026-02-09 13:54:40 -06:00
d902a47aba meh 2026-02-09 13:54:32 -06:00
a22220c4df more composable nix
Some checks are pending
/ test (push) Waiting to run
2026-02-09 11:53:23 -06:00
46 changed files with 3649 additions and 2276 deletions

1
.config/nextest.toml Normal file
View file

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

2
.envrc
View file

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

View file

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

View file

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

View file

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

6
.gitignore vendored
View file

@ -8,3 +8,9 @@ data.db
/perf.data
/perf.data.old
.aider*
test.db-shm
test.db-wal
test.lum
test.pres
profile.json.gz

13
Cargo.lock generated
View file

@ -4488,6 +4488,7 @@ dependencies = [
"ron 0.8.1",
"scraper",
"serde",
"serde_json",
"sqlx",
"strum",
"strum_macros",
@ -6988,16 +6989,16 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap 2.12.1",
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@ -10204,6 +10205,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zstd"
version = "0.13.3"

View file

@ -47,6 +47,7 @@ derive_more = { version = "2.1.1", features = ["debug"] }
reqwest = "0.13.1"
scraper = "0.25.0"
itertools = "0.14.0"
serde_json = "1.0.149"
# rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] }

View file

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

67
flake.lock generated
View file

@ -6,11 +6,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1765435813,
"narHash": "sha256-C6tT7K1Lx6VsYw1BY5S3OavtapUvEnDQtmQB5DSgbCc=",
"lastModified": 1770794449,
"narHash": "sha256-1nFkhcZx9+Sdw5OXwJqp5TxvGncqRqLeK781v0XV3WI=",
"owner": "nix-community",
"repo": "fenix",
"rev": "6399553b7a300c77e7f07342904eb696a5b6bf9d",
"rev": "b19d93fdf9761e6101f8cb5765d638bacebd9a1b",
"type": "github"
},
"original": {
@ -65,11 +65,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1763384566,
"narHash": "sha256-r+wgI+WvNaSdxQmqaM58lVNvJYJ16zoq+tKN20cLst4=",
"lastModified": 1769799857,
"narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=",
"owner": "nix-community",
"repo": "naersk",
"rev": "d4155d6ebb70fbe2314959842f744aa7cabbbf6a",
"rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339",
"type": "github"
},
"original": {
@ -80,11 +80,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"lastModified": 1770562336,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github"
},
"original": {
@ -112,11 +112,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1765472234,
"narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=",
"lastModified": 1770562336,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github"
},
"original": {
@ -126,22 +126,39 @@
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1765400135,
"narHash": "sha256-D3+4hfNwUhG0fdCpDhOASLwEQ1jKuHi4mV72up4kLQM=",
"lastModified": 1770702974,
"narHash": "sha256-CbvWu72rpGHK5QynoXwuOnVzxX7njF2LYgk8wRSiAQ0=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "fface27171988b3d605ef45cf986c25533116f7e",
"rev": "07a594815f7c1d6e7e39f21ddeeedb75b21795f4",
"type": "github"
},
"original": {
@ -168,6 +185,24 @@
"type": "github"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1770779462,
"narHash": "sha256-ykcXTKtV+dOaKlOidAj6dpewBHjni9/oy/6VKcqfzfY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "8a53b3ade61914cdb10387db991b90a3a6f3c441",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,

View file

@ -6,6 +6,7 @@
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";
};
outputs =
@ -14,24 +15,34 @@
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system;
overlays = [ fenix.overlays.default ];
inherit system overlays;
# overlays = [ rust-overlay.overlays.default ];
# overlays = [cargo2nix.overlays.default];
};
naersk' = pkgs.callPackage naersk { };
nbi = with pkgs; [
# toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]);
nativeBuildInputs = with pkgs; [
# Rust tools
alejandra
(pkgs.fenix.stable.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
# toolchain
# (pkgs.fenix.default.withComponents [
# "cargo"
# "clippy"
# "rust-std"
# # "rust-src"
# "rustc"
# "rustfmt"
# ])
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" ];
}))
cargo-nextest
rust-analyzer
cargo-criterion
# rust-analyzer-nightly
vulkan-loader
wayland
wayland-protocols
@ -40,7 +51,7 @@
sccache
];
bi = with pkgs; [
buildInputs = with pkgs; [
gcc
stdenv
gnumake
@ -75,7 +86,31 @@
just
sqlx-cli
cargo-watch
samply
];
LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
with pkgs;
pkgs.lib.makeLibraryPath [
pkgs.alsa-lib
pkgs.gst_all_1.gst-libav
pkgs.gst_all_1.gstreamer
pkgs.gst_all_1.gst-plugins-bad
pkgs.gst_all_1.gst-plugins-good
pkgs.gst_all_1.gst-plugins-ugly
pkgs.gst_all_1.gst-plugins-base
pkgs.gst_all_1.gst-plugins-rs
pkgs.gst_all_1.gst-vaapi
pkgs.glib
pkgs.fontconfig
pkgs.vulkan-loader
pkgs.wayland
pkgs.wayland-protocols
pkgs.libxkbcommon
pkgs.mupdf
pkgs.libclang
]
}";
in
rec {
devShell =
@ -84,38 +119,18 @@
# stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv;
}
{
nativeBuildInputs = nbi;
buildInputs = bi;
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
]
}";
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
# LIBCLANG_PATH = "${pkgs.clang}";
DATABASE_URL = "sqlite:///home/chris/.local/share/lumina/library-db.sqlite3";
DATABASE_URL = "sqlite://./test.db";
# RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library";
};
defaultPackage = naersk'.buildPackage {
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.;
};
packages = {
default = naersk'.buildPackage {
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.;
};
};

View file

@ -20,9 +20,14 @@ run-file:
clean:
cargo clean
test:
cargo test --benches --tests --all-features -- --nocapture
cargo nextest run
ci-test:
cargo nextest run -- --skip test_db_and_model --skip test_update --skip test_song_slide_speed --skip test_song_to_slide --skip test_song_from_db
bench:
export NEXTEST_EXPERIMENTAL_BENCHMARKS=1
cargo nextest bench
profile:
cargo flamegraph -- {{verbose}} {{ui}}
samply record cargo run --release -- {{verbose}} {{ui}}
alias b := build
alias r := run

View file

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

View file

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

BIN
res/bigbuckbunny.mp4 Normal file

Binary file not shown.

BIN
res/nerdfont.ttf Normal file

Binary file not shown.

79
res/shadow.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 3.2 KiB

12
res/text-shadow.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 976 B

View file

@ -2,28 +2,40 @@ use crate::core::{
kinds::ServiceItemKind, service_items::ServiceItem,
slide::Background,
};
use miette::{IntoDiagnostic, Result};
use cosmic::widget::image::Handle;
use miette::{IntoDiagnostic, Result, miette};
use std::{
fs::{self, File},
io::Write,
iter,
path::{Path, PathBuf},
};
use tar::Builder;
use tracing::error;
use zstd::Encoder;
use tar::{Archive, Builder};
use tracing::{debug, error};
use zstd::{Decoder, Encoder};
pub async fn save(
#[allow(clippy::too_many_lines)]
pub fn save(
list: Vec<ServiceItem>,
path: impl AsRef<Path>,
overwrite: bool,
) -> Result<()> {
let path = path.as_ref();
if overwrite && path.exists() {
fs::remove_file(path).into_diagnostic()?;
}
let save_file = File::create(path).into_diagnostic()?;
let ron = process_service_items(&list)?;
let ron_pretty = ron::ser::PrettyConfig::default();
let ron = ron::ser::to_string_pretty(&list, ron_pretty)
.into_diagnostic()?;
let encoder = Encoder::new(save_file, 3).unwrap();
let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail")
.auto_finish();
let mut tar = Builder::new(encoder);
let mut temp_dir = dirs::data_dir().unwrap();
let mut temp_dir = dirs::data_dir().expect(
"there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
);
temp_dir.push("lumina");
let mut s: String =
iter::repeat_with(fastrand::alphanumeric).take(5).collect();
@ -31,6 +43,7 @@ pub async fn save(
temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?;
let service_file = temp_dir.join("serviceitems.ron");
debug!(?service_file);
fs::File::create(&service_file).into_diagnostic()?;
match fs::File::options()
.read(true)
@ -38,19 +51,40 @@ pub async fn save(
.open(service_file)
{
Ok(mut f) => {
f.write(ron.as_bytes()).into_diagnostic()?;
match f.write(ron.as_bytes()) {
Ok(size) => {
debug!(size);
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => {
debug!(
"should have added serviceitems.ron to the file"
);
}
Err(e) => {
error!(?e);
return Err(miette!("PROBS: {e}"));
}
}
}
Err(e) => {
error!("There were problems making a file i guess: {e}");
return Err(miette!("There was a problem: {e}"));
}
}
// let list list.iter_mut().map(|item| {
// match item.kind {
// ServiceItemKind::Song(mut song) => {
// song.background
// }
// }
// }).collect();
let mut append_file = |path: PathBuf| -> Result<()> {
let file_name = path.file_name().unwrap_or_default();
let mut file = fs::File::open(&path).into_diagnostic()?;
tar.append_file(file_name, &mut file).into_diagnostic()?;
Ok(())
};
for item in list {
let background;
let audio: Option<PathBuf>;
@ -84,323 +118,417 @@ pub async fn save(
todo!()
}
}
if let Some(file) = audio {
let audio_file =
temp_dir.join(file.file_name().expect(
"Audio file couldn't be added to temp_dir",
));
if let Ok(file) = file.strip_prefix("file://") {
fs::File::create(&audio_file).into_diagnostic()?;
fs::copy(file, audio_file).into_diagnostic()?;
} else {
fs::File::create(&audio_file).into_diagnostic()?;
fs::copy(file, audio_file).into_diagnostic()?;
}
if let Some(path) = audio
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
if let Some(file) = background {
let background_file =
temp_dir.join(file.path.file_name().expect(
"Background file couldn't be added to temp_dir",
));
if let Ok(file) = file.path.strip_prefix("file://") {
fs::File::create(&background_file)
.into_diagnostic()?;
fs::copy(file, background_file).into_diagnostic()?;
} else {
fs::File::create(&background_file)
.into_diagnostic()?;
fs::copy(file.path, background_file)
.into_diagnostic()?;
if let Some(background) = background
&& let path = background.path
&& path.exists()
{
debug!(?path);
append_file(path)?;
}
for slide in item.slides {
if let Some(svg) = slide.text_svg
&& let Some(path) = svg.path
{
append_file(path)?;
}
}
}
tar.append_dir_all(path, temp_dir).into_diagnostic()?;
tar.finish().into_diagnostic()
match tar.finish() {
Ok(()) => (),
Err(e) => {
error!(?e);
return Err(miette!("tar error: {e}"));
}
}
fs::remove_dir_all(temp_dir).into_diagnostic()
}
async fn clear_temp_dir(_temp_dir: &Path) -> Result<()> {
todo!()
}
#[allow(clippy::too_many_lines)]
pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let decoder =
Decoder::new(fs::File::open(&path).into_diagnostic()?)
.into_diagnostic()?;
let mut tar = Archive::new(decoder);
fn process_service_items(items: &Vec<ServiceItem>) -> Result<String> {
Ok(items
.iter()
.filter_map(|item| {
let ron = ron::ser::to_string(item);
ron.ok()
let mut cache_dir =
dirs::cache_dir().expect("Should be a cache dir");
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
let save_name_ext = path
.as_ref()
.extension()
.expect("Should have extension")
.to_str()
.expect("Should be fine");
let save_name_string = path
.as_ref()
.file_name()
.expect("Should be a name")
.to_os_string()
.into_string()
.expect("Should be fine");
let save_name = save_name_string
.trim_end_matches(&format!(".{save_name_ext}"));
cache_dir.push(save_name);
if let Err(e) = fs::remove_dir_all(&cache_dir) {
debug!("There is no dir here: {e}");
}
fs::create_dir_all(&cache_dir).into_diagnostic()?;
for entry in tar.entries().into_diagnostic()? {
let mut entry = entry.into_diagnostic()?;
entry.unpack_in(&cache_dir).into_diagnostic()?;
}
let mut dir = fs::read_dir(&cache_dir).into_diagnostic()?;
let ron_file = dir
.find_map(|file| {
if file.as_ref().ok()?.path().extension()?.to_str()?
== "ron"
{
Some(file.ok()?.path())
} else {
None
}
})
.collect())
.expect("Should have a ron file");
let ron_string =
fs::read_to_string(ron_file).into_diagnostic()?;
let mut items =
ron::de::from_str::<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!(),
}
}
}
Ok(items)
}
// async fn process_song(
// database_id: i32,
// db: &mut SqliteConnection,
// ) -> Result<Value> {
// let song = get_song_from_db(database_id, db).await?;
// let song_ron = ron::to_value(&song)?;
// let kind_ron = ron::to_value(ServiceItemKind::Song)?;
// let json =
// serde_json::json!({"item": song_json, "kind": kind_json});
// Ok(json)
// }
#[cfg(test)]
mod test {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use resvg::usvg::fontdb;
// async fn process_image(
// database_id: i32,
// db: &mut SqliteConnection,
// ) -> Result<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)
// }
use super::*;
use crate::{
core::{
service_items::ServiceTrait,
slide::{Slide, TextAlignment},
songs::{Song, VerseName},
},
ui::text_svg::text_svg_generator,
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
// async fn process_video(
// database_id: i32,
// db: &mut SqliteConnection,
// ) -> Result<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)
// }
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).unwrap();
Song {
id: 7,
title: "Death Was Arrested".to_string(),
lyrics: Some(lyrics),
author: Some(
"North Point Worship".to_string(),
),
ccli: None,
audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()),
verse_order: Some(vec!["Some([Chorus(number:1),Intro(number:1),Other(number:99),Bridge(number:1),Verse(number:4),Verse(number:2),Verse(number:3),Verse(number:1)])".to_string()]),
background: Some(Background::try_from("/home/chris/nc/tfc/openlp/Flood/motions/Ocean_Floor_HD.mp4").unwrap()),
text_alignment: Some(TextAlignment::MiddleCenter),
font: None,
font_size: Some(120),
font_style: None,
font_weight: None,
text_color: None,
stroke_size: None,
verses: Some(vec![VerseName::Chorus { number: 1 }, VerseName::Intro { number: 1 }, VerseName::Other { number: 99 }, VerseName::Bridge { number: 1 }, VerseName::Verse { number: 4 }, VerseName::Verse { number: 2 }, VerseName::Verse { number: 3 }, VerseName::Verse { number: 1 }
]),
verse_map,
..Default::default()
}
}
// async fn process_presentation(
// database_id: i32,
// db: &mut SqliteConnection,
// ) -> Result<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)
// }
fn get_items() -> Vec<ServiceItem> {
let song = test_song();
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let fontdb = Arc::new(fontdb);
let slides = song
.to_slides()
.unwrap()
.into_par_iter()
.map(|slide| {
text_svg_generator(
slide.clone(),
&Arc::clone(&fontdb),
)
.map_or_else(
|e| {
assert!(false, "Couldn't create svg: {e}");
slide
},
|slide| slide,
)
})
.collect::<Vec<Slide>>();
let items = vec![
ServiceItem {
database_id: 7,
kind: ServiceItemKind::Song(song.clone()),
id: 0,
title: "Death was Arrested".into(),
slides: slides.clone(),
},
ServiceItem {
database_id: 7,
kind: ServiceItemKind::Song(song),
id: 1,
title: "Death was Arrested".into(),
slides: slides,
},
];
items
}
// #[cfg(test)]
// mod test {
// use std::path::PathBuf;
#[test]
fn test_load() -> Result<(), String> {
test_save();
let path = PathBuf::from("./test.pres");
let result = load(&path);
match result {
Ok(items) => {
assert!(items.len() > 0);
// assert_eq!(items, get_items());
let cache_dir = cache_dir();
assert!(fs::read_dir(&cache_dir).is_ok());
assert!(
find_paths(&items),
"Some paths must not have the cache_dir in it's path"
);
find_svgs(&items)?;
Ok(())
}
Err(e) => Err(e.to_string()),
}
}
// use super::*;
// use fs::canonicalize;
// use pretty_assertions::assert_eq;
// use sqlx::Connection;
// use tracing::debug;
fn find_svgs(items: &Vec<ServiceItem>) -> Result<(), String> {
let cache_dir = cache_dir();
items.iter().try_for_each(|item| {
if let ServiceItemKind::Song(..) = item.kind {
item.slides.iter().try_for_each(|slide| {
slide.text_svg.as_ref().map_or(Err(String::from("There is no TextSvg for this song")), |text_svg| {
// async fn get_db() -> SqliteConnection {
// let mut data = dirs::data_local_dir().unwrap();
// data.push("lumina");
// data.push("library-db.sqlite3");
// let mut db_url = String::from("sqlite://");
// db_url.push_str(data.to_str().unwrap());
// SqliteConnection::connect(&db_url).await.expect("problems")
// }
if text_svg.handle.is_none() {
return Err(String::from("There is no handle in this song's TextSvg"));
};
// #[tokio::test(flavor = "current_thread")]
// async fn test_process_song() {
// let mut db = get_db().await;
// let result = process_song(7, &mut db).await;
// let json_song_file = PathBuf::from("./test/test_song.json");
// if let Ok(path) = canonicalize(json_song_file) {
// debug!(file = ?&path);
// if let Ok(s) = fs::read_to_string(path) {
// debug!(s);
// match result {
// Ok(json) => assert_eq!(json.to_string(), s),
// Err(e) => panic!(
// "There was an error in processing the song: {e}"
// ),
// }
// } else {
// panic!("String wasn't read from file");
// }
// } else {
// panic!("Cannot find absolute path to test_song.json");
// }
// }
text_svg.path.as_ref().map_or(Err(String::from("There is no path in this song's TextSvg")), |path| {
if path.exists() {
let mut path = path.clone();
if path.metadata().unwrap().len() < 20000 {
return Err(String::from("SVG text is too small, maybe the svg didn't generate properly"))
}
if path.pop() && path == cache_dir {
Ok(())
} else {
Err(String::from("The path of the TextSvg isn't in the load directory"))
}
} else {
Err(String::from("The path in this TextSvg doesn't exist"))
}
})
})
})
} else {
Ok(())
}
})
}
// #[tokio::test(flavor = "current_thread")]
// async fn test_process_image() {
// let mut db = get_db().await;
// let result = process_image(3, &mut db).await;
// let json_image_file = PathBuf::from("./test/test_image.json");
// if let Ok(path) = canonicalize(json_image_file) {
// debug!(file = ?&path);
// if let Ok(s) = fs::read_to_string(path) {
// debug!(s);
// match result {
// Ok(json) => assert_eq!(json.to_string(), s),
// Err(e) => panic!(
// "There was an error in processing the image: {e}"
// ),
// }
// } else {
// panic!("String wasn't read from file");
// }
// } else {
// panic!("Cannot find absolute path to test_image.json");
// }
// }
// checks to make sure all paths in slides and items point to cache_dir
fn find_paths(items: &Vec<ServiceItem>) -> bool {
let cache_dir = cache_dir();
items.iter().all(|item| {
match &item.kind {
ServiceItemKind::Song(song) => {
if let Some(bg) = &song.background {
if !bg.path.starts_with(&cache_dir) {
return false;
}
}
if let Some(audio) = &song.audio {
if !audio.starts_with(&cache_dir) {
return false;
}
}
}
ServiceItemKind::Video(video) => {
if !video.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Image(image) => {
if !image.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Presentation(presentation) => {
if !presentation.path.starts_with(&cache_dir) {
return false;
}
}
ServiceItemKind::Content(_slide) => todo!(),
}
for slide in &item.slides {
if !slide.background().path.starts_with(&cache_dir) {
return false;
}
if !slide.audio().map_or(true, |audio| {
audio.starts_with(&cache_dir)
}) {
return false;
}
}
true
})
}
// #[tokio::test(flavor = "current_thread")]
// async fn test_process_video() {
// let mut db = get_db().await;
// let result = process_video(73, &mut db).await;
// let json_video_file = PathBuf::from("./test/test_video.json");
// if let Ok(path) = canonicalize(json_video_file) {
// debug!(file = ?&path);
// if let Ok(s) = fs::read_to_string(path) {
// debug!(s);
// match result {
// Ok(json) => assert_eq!(json.to_string(), s),
// Err(e) => panic!(
// "There was an error in processing the video: {e}"
// ),
// }
// } else {
// panic!("String wasn't read from file");
// }
// } else {
// panic!("Cannot find absolute path to test_video.json");
// }
// }
fn cache_dir() -> PathBuf {
let mut cache_dir = dirs::cache_dir().unwrap();
cache_dir.push("lumina");
cache_dir.push("cached_save_files");
cache_dir.push("test");
cache_dir
}
// #[tokio::test(flavor = "current_thread")]
// async fn test_process_presentation() {
// let mut db = get_db().await;
// let result = process_presentation(54, &mut db).await;
// let json_presentation_file =
// PathBuf::from("./test/test_presentation.json");
// if let Ok(path) = canonicalize(json_presentation_file) {
// debug!(file = ?&path);
// if let Ok(s) = fs::read_to_string(path) {
// debug!(s);
// match result {
// Ok(json) => assert_eq!(json.to_string(), s),
// Err(e) => panic!(
// "There was an error in processing the presentation: {e}"
// ),
// }
// } else {
// panic!("String wasn't read from file");
// }
// } else {
// panic!(
// "Cannot find absolute path to test_presentation.json"
// );
// }
// }
// fn get_items() -> Vec<ServiceItem> {
// let items = vec![
// ServiceItem {
// database_id: 7,
// kind: ServiceItemKind::Song,
// id: 0,
// },
// ServiceItem {
// database_id: 54,
// kind: ServiceItemKind::Presentation(PresKind::Html),
// id: 0,
// },
// ServiceItem {
// database_id: 73,
// kind: ServiceItemKind::Video,
// id: 0,
// },
// ];
// items
// }
// #[tokio::test]
// async fn test_service_items() {
// let mut db = get_db().await;
// let items = get_items();
// let json_item_file =
// PathBuf::from("./test/test_service_items.json");
// let result = process_service_items(&items, &mut db).await;
// if let Ok(path) = canonicalize(json_item_file) {
// if let Ok(s) = fs::read_to_string(path) {
// match result {
// Ok(strings) => assert_eq!(strings.to_string(), s),
// Err(e) => panic!("There was an error: {e}"),
// }
// }
// }
// }
// // #[tokio::test]
// // async fn test_save() {
// // let path = PathBuf::from("~/dev/lumina/src/rust/core/test.pres");
// // let list = get_items();
// // match save(list, path).await {
// // Ok(_) => assert!(true),
// // Err(e) => panic!("There was an error: {e}"),
// // }
// // }
// #[tokio::test]
// async fn test_store() {
// let path = PathBuf::from(
// "/home/chris/dev/lumina/src/rust/core/test.pres",
// );
// let save_file = match File::create(path) {
// Ok(f) => f,
// Err(e) => panic!("Couldn't create save_file: {e}"),
// };
// let mut db = get_db().await;
// let list = get_items();
// if let Ok(json) = process_service_items(&list, &mut db).await
// {
// println!("{:?}", json);
// match store_service_items(
// &list, &mut db, &save_file, &json,
// )
// .await
// {
// Ok(_) => assert!(true),
// Err(e) => panic!("There was an error: {e}"),
// }
// } else {
// panic!("There was an error getting the json value");
// }
// }
// // #[tokio::test]
// // async fn test_things() {
// // let mut temp_dir = dirs::data_dir().unwrap();
// // temp_dir.push("lumina");
// // let mut s: String =
// // iter::repeat_with(fastrand::alphanumeric)
// // .take(5)
// // .collect();
// // s.insert_str(0, "temp_");
// // temp_dir.push(s);
// // let _ = fs::create_dir_all(&temp_dir);
// // let mut db = get_db().await;
// // let service_file = temp_dir.join("serviceitems.json");
// // let list = get_items();
// // if let Ok(json) = process_service_items(&list, &mut db).await {
// // let _ = fs::File::create(&service_file);
// // match fs::write(service_file, json.to_string()) {
// // Ok(_) => assert!(true),
// // Err(e) => panic!("There was an error: {e}"),
// // }
// // } else {
// // panic!("There was an error getting the json value");
// // }
// // }
// }
#[test]
fn test_save() {
let path = PathBuf::from("./test.pres");
let list = get_items();
match save(list, &path, true) {
Ok(_) => {
assert!(path.is_file());
let Ok(file) = fs::File::open(path) else {
return assert!(false, "couldn't open file");
};
let Ok(size) = file.metadata().map(|data| data.len())
else {
return assert!(
false,
"couldn't get file metadata"
);
};
assert!(size > 0);
}
Err(e) => assert!(false, "{e}"),
}
}
}

View file

@ -72,11 +72,10 @@ impl Content for Image {
fn subtext(&self) -> String {
if self.path.exists() {
self.path
.file_name()
.map_or("Missing image".into(), |f| {
f.to_string_lossy().to_string()
})
self.path.file_name().map_or_else(
|| "Missing image".into(),
|f| f.to_string_lossy().to_string(),
)
} else {
"Missing image".into()
}
@ -89,6 +88,7 @@ impl From<Value> for Image {
}
}
#[allow(clippy::option_if_let_else)]
impl From<&Value> for Image {
fn from(value: &Value) -> Self {
match value {
@ -269,7 +269,9 @@ mod test {
fn test_image(title: String) -> Image {
Image {
title,
path: PathBuf::from("~/pics/camprules2024.mp4"),
path: PathBuf::from(
"/home/chris/pics/memes/no-i-dont-think.gif",
),
..Default::default()
}
}
@ -280,10 +282,10 @@ mod test {
items: vec![],
kind: LibraryKind::Image,
};
let mut db = crate::core::model::get_db().await;
let mut db = add_db().await.unwrap().acquire().await.unwrap();
image_model.load_from_db(&mut db).await;
if let Some(image) = image_model.find(|i| i.id == 3) {
let test_image = test_image("nccq5".into());
if let Some(image) = image_model.find(|i| i.id == 23) {
let test_image = test_image("no-i-dont-think.gif".into());
assert_eq!(test_image.title, image.title);
} else {
assert!(false);
@ -317,4 +319,9 @@ mod test {
),
}
}
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
}

View file

@ -28,9 +28,11 @@ impl TryFrom<PathBuf> for ServiceItemKind {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or(miette::miette!(
"There isn't an extension on this file"
))?;
.ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext {
"png" | "jpg" | "jpeg" => {
Ok(Self::Image(Image::from(path)))
@ -38,6 +40,7 @@ impl TryFrom<PathBuf> for ServiceItemKind {
"mp4" | "mkv" | "webm" => {
Ok(Self::Video(Video::from(path)))
}
"pdf" => Ok(Self::Presentation(Presentation::from(path))),
_ => Err(miette::miette!("Unknown item")),
}
}

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, mem::replace, path::PathBuf};
use std::{borrow::Cow, fs, mem::replace, path::PathBuf};
use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use miette::{IntoDiagnostic, Result, miette};
@ -84,26 +84,36 @@ impl<T> Model<T> {
}
pub fn update_item(&mut self, item: T, index: i32) -> Result<()> {
if let Some(current_item) = self.items.get_mut(index as usize)
{
let _old_item = replace(current_item, item);
Ok(())
} else {
Err(miette!(
"Item doesn't exist in model. Id was {}",
index
))
}
self.items
.get_mut(
usize::try_from(index)
.expect("Shouldn't be negative"),
)
.map_or_else(
|| {
Err(miette!(
"Item doesn't exist in model. Id was {index}"
))
},
|current_item| {
let _old_item = replace(current_item, item);
Ok(())
},
)
}
pub fn remove_item(&mut self, index: i32) -> Result<()> {
self.items.remove(index as usize);
self.items.remove(
usize::try_from(index).expect("Shouldn't be negative"),
);
Ok(())
}
#[must_use]
pub fn get_item(&self, index: i32) -> Option<&T> {
self.items.get(index as usize)
self.items.get(
usize::try_from(index).expect("shouldn't be negative"),
)
}
pub fn find<P>(&self, f: P) -> Option<&T>
@ -114,7 +124,10 @@ impl<T> Model<T> {
}
pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> {
self.items.insert(index as usize, item);
self.items.insert(
usize::try_from(index).expect("Shouldn't be negative"),
item,
);
Ok(())
}
}
@ -131,11 +144,13 @@ impl<T> Model<T> {
// }
pub async fn get_db() -> SqliteConnection {
let mut data = dirs::data_local_dir().unwrap();
let mut data = dirs::data_local_dir()
.expect("Should be able to find a data dir");
data.push("lumina");
let _ = fs::create_dir_all(&data);
data.push("library-db.sqlite3");
let mut db_url = String::from("sqlite://");
db_url.push_str(data.to_str().unwrap());
db_url.push_str(data.to_str().expect("Should be there"));
SqliteConnection::connect(&db_url).await.expect("problems")
}

View file

@ -65,27 +65,24 @@ impl From<PathBuf> for Presentation {
.to_str()
.unwrap_or_default()
{
"pdf" => {
if let Ok(document) = Document::open(&value.as_path())
{
if let Ok(count) = document.page_count() {
PresKind::Pdf {
starting_index: 0,
ending_index: count - 1,
}
} else {
"pdf" => Document::open(&value.as_path()).map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
}
}
} else {
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
}
}
}
},
|count| PresKind::Pdf {
starting_index: 0,
ending_index: count - 1,
},
)
},
),
"html" => PresKind::Html,
_ => PresKind::Generic,
};
@ -129,11 +126,10 @@ impl Content for Presentation {
fn subtext(&self) -> String {
if self.path.exists() {
self.path
.file_name()
.map_or("Missing presentation".into(), |f| {
f.to_string_lossy().to_string()
})
self.path.file_name().map_or_else(
|| "Missing presentation".into(),
|f| f.to_string_lossy().to_string(),
)
} else {
"Missing presentation".into()
}
@ -146,6 +142,7 @@ impl From<Value> for Presentation {
}
}
#[allow(clippy::option_if_let_else)]
impl From<&Value> for Presentation {
fn from(value: &Value) -> Self {
match value {
@ -206,15 +203,14 @@ impl ServiceTrait for Presentation {
let pages: Vec<Handle> = pages
.enumerate()
.filter_map(|(index, page)| {
if (index as i32) < starting_index {
return None;
} else if (index as i32) > ending_index {
let index = i32::try_from(index)
.expect("Shouldn't be that high");
if index < starting_index || index > ending_index {
return None;
}
let Some(page) = page.ok() else {
return None;
};
let page = page.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let Ok(pixmap) = page
@ -248,7 +244,10 @@ impl ServiceTrait for Presentation {
.video_loop(false)
.video_start_time(0.0)
.video_end_time(0.0)
.pdf_index(index as u32)
.pdf_index(
u32::try_from(index)
.expect("Shouldn't get that high"),
)
.pdf_page(page)
.build()?;
slides.push(slide);
@ -334,32 +333,38 @@ impl Model<Presentation> {
presentation.ending_index,
) {
PresKind::Pdf {
starting_index: starting_index as i32,
ending_index: ending_index as i32,
starting_index: i32::try_from(
starting_index,
)
.expect("Shouldn't get that high"),
ending_index: i32::try_from(
ending_index,
)
.expect("Shouldn't get that high"),
}
} else {
let path =
PathBuf::from(presentation.path);
if let Ok(document) =
Document::open(path.as_path())
{
if let Ok(count) =
document.page_count()
{
let ending_index = count - 1;
PresKind::Pdf {
starting_index: 0,
ending_index,
}
} else {
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
}
}
} else {
PresKind::Generic
}
Document::open(path.as_path()).map_or(
PresKind::Generic,
|document| {
document.page_count().map_or(
PresKind::Pdf {
starting_index: 0,
ending_index: 0,
},
|count| {
let ending_index =
count - 1;
PresKind::Pdf {
starting_index: 0,
ending_index,
}
},
)
},
)
},
});
}
@ -393,11 +398,22 @@ pub async fn add_presentation_to_db(
.unwrap_or_default();
let html = presentation.kind == PresKind::Html;
let mut db = db.detach();
let (starting_index, ending_index) = if let PresKind::Pdf {
starting_index,
ending_index,
} = presentation.kind
{
(starting_index, ending_index)
} else {
(0, 0)
};
query!(
r#"INSERT INTO presentations (title, file_path, html) VALUES ($1, $2, $3)"#,
r#"INSERT INTO presentations (title, file_path, html, starting_index, ending_index) VALUES ($1, $2, $3, $4, $5)"#,
presentation.title,
path,
html,
starting_index,
ending_index
)
.execute(&mut db)
.await
@ -416,16 +432,17 @@ pub async fn update_presentation_in_db(
.unwrap_or_default();
let html = presentation.kind == PresKind::Html;
let mut db = db.detach();
let mut starting_index = 0;
let mut ending_index = 0;
if let PresKind::Pdf {
let (starting_index, ending_index) = if let PresKind::Pdf {
starting_index: s_index,
ending_index: e_index,
} = presentation.get_kind()
} =
presentation.get_kind()
{
starting_index = *s_index;
ending_index = *e_index;
}
(*s_index, *e_index)
} else {
(0, 0)
};
debug!(starting_index, ending_index);
let id = presentation.id;
if let Err(e) =
query!("SELECT id FROM presentations where id = $1", id)
@ -439,7 +456,10 @@ pub async fn update_presentation_in_db(
let Some(mut max) = ids.iter().map(|r| r.id).max() else {
return Err(miette::miette!("cannot find max id"));
};
debug!(?e, "Presentation not found");
debug!(
?e,
"Presentation not found adding a new presentation"
);
max += 1;
let result = query!(
r#"INSERT into presentations VALUES($1, $2, $3, $4, $5, $6)"#,
@ -456,7 +476,7 @@ pub async fn update_presentation_in_db(
return match result {
Ok(_) => {
debug!("should have been updated");
debug!("presentation should have been added");
Ok(())
}
Err(e) => {
@ -470,11 +490,13 @@ pub async fn update_presentation_in_db(
debug!(?presentation, "should be been updated");
let result = query!(
r#"UPDATE presentations SET title = $2, file_path = $3, html = $4 WHERE id = $1"#,
r#"UPDATE presentations SET title = $2, file_path = $3, html = $4, starting_index = $5, ending_index = $6 WHERE id = $1"#,
presentation.id,
presentation.title,
path,
html
html,
starting_index,
ending_index
)
.execute(&mut db)
.await.into_diagnostic();
@ -506,12 +528,13 @@ mod test {
fn test_presentation() -> Presentation {
Presentation {
id: 54,
title: "20240327T133649--12-isaiah-and-jesus__lesson_project_tfc".into(),
path: PathBuf::from(
"file:///home/chris/docs/notes/lessons/20240327T133649--12-isaiah-and-jesus__lesson_project_tfc.html",
),
kind: PresKind::Html,
id: 4,
title: "mzt52.pdf".into(),
path: PathBuf::from("/home/chris/docs/mzt52.pdf"),
kind: PresKind::Pdf {
starting_index: 0,
ending_index: 67,
},
}
}
@ -521,16 +544,21 @@ mod test {
assert_eq!(pres.get_kind(), &PresKind::Generic)
}
async fn add_db() -> Result<SqlitePool> {
let mut db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
#[tokio::test]
async fn test_db_and_model() {
let mut presentation_model: Model<Presentation> = Model {
items: vec![],
kind: LibraryKind::Presentation,
};
let mut db = crate::core::model::get_db().await;
let mut db = add_db().await.unwrap().acquire().await.unwrap();
presentation_model.load_from_db(&mut db).await;
if let Some(presentation) =
presentation_model.find(|p| p.id == 54)
presentation_model.find(|p| p.id == 4)
{
let test_presentation = test_presentation();
assert_eq!(&test_presentation, presentation);

View file

@ -32,7 +32,7 @@ impl Eq for ServiceItem {}
impl PartialOrd for ServiceItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.id.partial_cmp(&other.id)
Some(self.cmp(other))
}
}
@ -89,9 +89,11 @@ impl TryFrom<PathBuf> for ServiceItem {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or(miette::miette!(
"There isn't an extension on this file"
))?;
.ok_or_else(|| {
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext {
"png" | "jpg" | "jpeg" => {
Ok(Self::from(&Image::from(path)))
@ -157,6 +159,8 @@ impl From<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 {
@ -280,64 +284,61 @@ impl From<Vec<ServiceItem>> for Service {
impl From<&Song> for ServiceItem {
fn from(song: &Song) -> Self {
if let Ok(slides) = song.to_slides() {
Self {
song.to_slides().map_or_else(
|_| Self {
kind: ServiceItemKind::Song(song.clone()),
database_id: song.id,
title: song.title.clone(),
..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Song(song.clone()),
database_id: song.id,
title: song.title.clone(),
slides,
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Song(song.clone()),
database_id: song.id,
title: song.title.clone(),
..Default::default()
}
}
},
)
}
}
impl From<&Video> for ServiceItem {
fn from(video: &Video) -> Self {
if let Ok(slides) = video.to_slides() {
Self {
video.to_slides().map_or_else(
|_| Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
slides,
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Video(video.clone()),
database_id: video.id,
title: video.title.clone(),
..Default::default()
}
}
},
)
}
}
impl From<&Image> for ServiceItem {
fn from(image: &Image) -> Self {
if let Ok(slides) = image.to_slides() {
Self {
image.to_slides().map_or_else(
|_| Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
..Default::default()
},
|slides| Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
slides,
..Default::default()
}
} else {
Self {
kind: ServiceItemKind::Image(image.clone()),
database_id: image.id,
title: image.title.clone(),
..Default::default()
}
}
},
)
}
}
@ -368,14 +369,11 @@ impl From<&Presentation> for ServiceItem {
}
}
#[allow(unused)]
impl Service {
fn add_item(
&mut self,
item: impl Into<ServiceItem>,
) -> Result<()> {
fn add_item(&mut self, item: impl Into<ServiceItem>) {
let service_item: ServiceItem = item.into();
self.items.push(service_item);
Ok(())
}
pub fn to_slides(&self) -> Result<Vec<Slide>> {
@ -391,7 +389,7 @@ impl Service {
.collect::<Vec<Slide>>();
let mut final_slides = vec![];
for (index, mut slide) in slides.into_iter().enumerate() {
slide.set_index(index as i32);
slide.set_index(i32::try_from(index).into_diagnostic()?);
final_slides.push(slide);
}
Ok(final_slides)
@ -454,19 +452,15 @@ mod test {
let pres = test_presentation();
let pres_item = ServiceItem::from(&pres);
let mut service_model = Service::default();
match service_model.add_item(&song) {
Ok(_) => {
assert_eq!(
ServiceItemKind::Song(song),
service_model.items[0].kind
);
assert_eq!(
ServiceItemKind::Presentation(pres),
pres_item.kind
);
assert_eq!(service_item, service_model.items[0]);
}
Err(e) => panic!("Problem adding item: {:?}", e),
}
service_model.add_item(&song);
assert_eq!(
ServiceItemKind::Song(song),
service_model.items[0].kind
);
assert_eq!(
ServiceItemKind::Presentation(pres),
pres_item.kind
);
assert_eq!(service_item, service_model.items[0]);
}
}

View file

@ -1,6 +1,5 @@
use cosmic::{
cosmic_theme::palette::rgb::Rgba, widget::image::Handle,
};
#![allow(clippy::similar_names, unused)]
use cosmic::widget::image::Handle;
// use cosmic::dialog::ashpd::url::Url;
use crisp::types::{Keyword, Symbol, Value};
use iced_video_player::Video;
@ -12,7 +11,7 @@ use std::{
};
use tracing::error;
use crate::ui::text_svg::TextSvg;
use crate::ui::text_svg::{Color, Font, Shadow, Stroke, TextSvg};
use super::songs::Song;
@ -23,20 +22,20 @@ pub struct Slide {
id: i32,
pub(crate) background: Background,
text: String,
font: String,
font: Option<Font>,
font_size: i32,
stroke_size: i32,
stroke_color: Option<Rgba>,
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>,
#[serde(skip)]
pub text_svg: Option<TextSvg>,
}
#[derive(
@ -50,13 +49,6 @@ pub enum BackgroundKind {
Html,
}
#[derive(Debug, Clone, Default)]
struct Image {
pub source: String,
pub fit: String,
pub children: Vec<String>,
}
#[derive(
Clone,
Copy,
@ -144,12 +136,15 @@ 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().unwrap().to_string();
let path = path
.to_str()
.expect("Should have a string")
.to_string();
let path = path.trim_start_matches("file://");
let home = dirs::home_dir()
.unwrap()
.expect("We should have a home directory")
.to_str()
.unwrap()
.expect("Gah")
.to_string();
let path = path.replace('~', &home);
PathBuf::from(path)
@ -197,16 +192,18 @@ impl TryFrom<&str> for Background {
fn try_from(value: &str) -> Result<Self, Self::Error> {
let value = value.trim_start_matches("file://");
if value.starts_with('~') {
if let Some(home) = dirs::home_dir() {
if let Some(home) = home.to_str() {
let value = value.replace('~', home);
Self::try_from(PathBuf::from(value))
} else {
Self::try_from(PathBuf::from(value))
}
} else {
Self::try_from(PathBuf::from(value))
}
dirs::home_dir().map_or_else(
|| Self::try_from(PathBuf::from(value)),
|home| {
home.to_str().map_or_else(
|| Self::try_from(PathBuf::from(value)),
|home| {
let value = value.replace('~', home);
Self::try_from(PathBuf::from(value))
},
)
},
)
} else if value.starts_with("./") {
Err(ParseError::CannotCanonicalize)
} else {
@ -270,68 +267,116 @@ impl From<&Slide> for Value {
}
impl Slide {
#[must_use]
pub fn set_text(mut self, text: impl AsRef<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 = font.as_ref().into();
self.font = Some(font.as_ref().into());
self
}
#[must_use]
pub const fn set_font_size(mut self, font_size: i32) -> Self {
self.font_size = font_size;
self
}
#[must_use]
pub fn set_audio(mut self, audio: Option<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 {
&self.background
}
#[must_use]
pub fn text(&self) -> String {
self.text.clone()
}
#[must_use]
pub const fn text_alignment(&self) -> TextAlignment {
self.text_alignment
}
#[must_use]
pub const fn font_size(&self) -> i32 {
self.font_size
}
pub fn font(&self) -> String {
#[must_use]
pub fn font(&self) -> Option<Font> {
self.font.clone()
}
#[must_use]
pub const 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
}
@ -366,10 +411,6 @@ impl Slide {
self.id = index;
}
pub(crate) fn text_to_image(&self) {
todo!()
}
// pub fn slides_from_item(item: &ServiceItem) -> Result<Vec<Self>> {
// todo!()
// }
@ -390,7 +431,8 @@ impl From<&Value> for Slide {
}
}
fn lisp_to_slide(lisp: &Vec<Value>) -> Slide {
#[allow(clippy::option_if_let_else)]
fn lisp_to_slide(lisp: &[Value]) -> Slide {
const DEFAULT_BACKGROUND_LOCATION: usize = 1;
const DEFAULT_TEXT_LOCATION: usize = 0;
@ -454,6 +496,7 @@ fn lisp_to_slide(lisp: &Vec<Value>) -> Slide {
}
}
#[allow(clippy::option_if_let_else)]
fn lisp_to_font_size(lisp: &Value) -> i32 {
match lisp {
Value::List(list) => {
@ -486,6 +529,7 @@ fn lisp_to_text(lisp: &Value) -> impl Into<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) => {
@ -544,9 +588,12 @@ pub fn lisp_to_background(lisp: &Value) -> Background {
pub struct SlideBuilder {
background: Option<Background>,
text: Option<String>,
font: Option<String>,
font: Option<Font>,
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>,
@ -585,12 +632,20 @@ 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<String>) -> Self {
pub(crate) fn font(mut self, font: impl Into<Font>) -> Self {
let _ = self.font.insert(font.into());
self
}
@ -600,6 +655,27 @@ 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,
@ -657,9 +733,6 @@ impl SlideBuilder {
let Some(text) = self.text else {
return Err(miette!("No text"));
};
let Some(font) = self.font else {
return Err(miette!("No font"));
};
let Some(font_size) = self.font_size else {
return Err(miette!("No font_size"));
};
@ -678,10 +751,13 @@ impl SlideBuilder {
Ok(Slide {
background,
text,
font,
font: self.font,
font_size,
text_alignment,
audio: self.audio,
stroke: self.stroke,
shadow: self.shadow,
text_color: self.text_color,
video_loop,
video_start_time,
video_end_time,
@ -693,12 +769,6 @@ impl SlideBuilder {
}
}
impl Image {
fn new() -> Self {
Default::default()
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
@ -711,7 +781,7 @@ mod test {
text: "This is frodo".to_string(),
background: Background::try_from("~/pics/frodo.jpg")
.unwrap(),
font: "Quicksand".to_string(),
font: Some("Quicksand".to_string().into()),
font_size: 140,
..Default::default()
}
@ -724,30 +794,11 @@ mod test {
"~/vids/test/camprules2024.mp4",
)
.unwrap(),
font: "Quicksand".to_string(),
font: Some("Quicksand".to_string().into()),
..Default::default()
}
}
#[test]
fn test_lisp_serialize() {
let lisp =
read_to_string("./test_presentation.lisp").expect("oops");
let lisp_value = crisp::reader::read(&lisp);
match lisp_value {
Value::List(value) => {
let slide = Slide::from(value[0].clone());
let test_slide = test_slide();
assert_eq!(slide, test_slide);
let second_slide = Slide::from(value[1].clone());
let second_test_slide = test_second_slide();
assert_eq!(second_slide, second_test_slide)
}
_ => panic!("this should be a lisp"),
}
}
#[test]
fn test_ron_deserialize() {
let slide = read_to_string("./test_presentation.ron")

View file

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

View file

@ -3,8 +3,11 @@ use std::{
};
use cosmic::{
cosmic_theme::palette::rgb::Rgba,
iced::clipboard::mime::AsMimeTypes,
cosmic_theme::palette::Srgb,
iced::{
clipboard::mime::AsMimeTypes,
font::{Style, Weight},
},
};
use crisp::types::{Keyword, Symbol, Value};
use itertools::Itertools;
@ -16,14 +19,17 @@ use sqlx::{
};
use tracing::{debug, error};
use crate::{Slide, SlideBuilder, core::slide};
use super::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
service_items::ServiceTrait,
slide::{Background, TextAlignment},
use crate::{
Slide, SlideBuilder,
core::{
content::Content,
kinds::ServiceItemKind,
model::{LibraryKind, Model},
service_items::ServiceTrait,
slide::{self, Background, TextAlignment},
song_search::OnlineSong,
},
ui::text_svg::{Color, Font, Stroke, shadow, stroke},
};
#[derive(
@ -41,11 +47,14 @@ pub struct Song {
pub text_alignment: Option<TextAlignment>,
pub font: Option<String>,
pub font_size: Option<i32>,
pub stroke_size: Option<i32>,
pub stroke_color: Option<Rgba>,
pub shadow_size: Option<i32>,
pub shadow_offset: Option<(i32, i32)>,
pub shadow_color: Option<Rgba>,
pub font_weight: Option<Weight>,
pub font_style: Option<Style>,
pub text_color: Option<Srgb>,
pub stroke_size: Option<u16>,
pub stroke_color: Option<Srgb>,
pub shadow_size: Option<u16>,
pub shadow_offset: Option<(i16, i16)>,
pub shadow_color: Option<Srgb>,
pub verses: Option<Vec<VerseName>>,
pub verse_map: Option<HashMap<VerseName, String>>,
}
@ -76,8 +85,9 @@ pub enum VerseName {
}
impl VerseName {
pub fn from_string(name: String) -> Self {
match name.as_str() {
#[must_use]
pub fn from_string(name: &str) -> Self {
match name {
"Verse" => Self::Verse { number: 1 },
"Pre-Chorus" => Self::PreChorus { number: 1 },
"Chorus" => Self::Chorus { number: 1 },
@ -87,11 +97,12 @@ impl VerseName {
"Outro" => Self::Outro { number: 1 },
"Instrumental" => Self::Instrumental { number: 1 },
"Other" => Self::Other { number: 1 },
"Blank" => Self::Blank,
// Blank is included in wildcard
_ => Self::Blank,
}
}
#[must_use]
pub fn all_names() -> Vec<String> {
vec![
"Verse".into(),
@ -107,6 +118,7 @@ impl VerseName {
]
}
#[must_use]
pub fn next(&self) -> Self {
match self {
Self::Verse { number } => {
@ -249,7 +261,9 @@ impl Content for Song {
}
fn subtext(&self) -> String {
self.author.clone().unwrap_or("Author missing".into())
self.author
.clone()
.unwrap_or_else(|| "Author missing".into())
}
}
@ -267,31 +281,78 @@ impl ServiceTrait for Song {
let lyrics: Vec<String> = self
.verses
.as_ref()
.ok_or(miette!("There are no verses assigned yet."))?
.ok_or_else(|| {
miette!("There are no verses assigned yet.")
})?
.iter()
.filter_map(|verse| self.get_lyric(verse))
.map(|lyric| {
.flat_map(|lyric| {
lyric
.split("\n\n")
.map(|s| s.to_string())
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
})
.flatten()
.collect();
debug!(?lyrics);
let slides: Vec<Slide> = lyrics
.iter()
.filter_map(|l| {
SlideBuilder::new()
let font = Font::default()
.name(
self.font
.clone()
.unwrap_or_else(|| "Calibri".into()),
)
.style(self.font_style.unwrap_or_default())
.weight(self.font_weight.unwrap_or_default())
.size(
u8::try_from(self.font_size.unwrap_or(100))
.unwrap_or(100),
);
let stroke_size =
self.stroke_size.unwrap_or_default();
let stroke: Stroke = stroke(
stroke_size,
self.stroke_color
.map(Color::from)
.unwrap_or_default(),
);
let shadow_size =
self.shadow_size.unwrap_or_default();
let shadow = shadow(
self.shadow_offset.unwrap_or_default().0,
self.shadow_offset.unwrap_or_default().1,
shadow_size,
self.shadow_color
.map(Color::from)
.unwrap_or_default(),
);
let builder = SlideBuilder::new();
let builder = if shadow_size > 0 {
builder.shadow(shadow)
} else {
builder
};
let builder = if stroke_size > 0 {
builder.stroke(stroke)
} else {
builder
};
builder
.background(
self.background.clone().unwrap_or_default(),
)
.font(self.font.clone().unwrap_or_default())
.font(font)
.font_size(self.font_size.unwrap_or_default())
.text_alignment(
self.text_alignment.unwrap_or_default(),
)
.text_color(
self.text_color.unwrap_or_else(|| {
Srgb::new(1.0, 1.0, 1.0)
}),
)
.audio(self.audio.clone().unwrap_or_default())
.video_loop(true)
.video_start_time(0.0)
@ -310,28 +371,10 @@ impl ServiceTrait for Song {
}
}
const VERSE_KEYWORDS: [&str; 24] = [
"Verse 1", "Verse 2", "Verse 3", "Verse 4", "Verse 5", "Verse 6",
"Verse 7", "Verse 8", "Chorus 1", "Chorus 2", "Chorus 3",
"Chorus 4", "Bridge 1", "Bridge 2", "Bridge 3", "Bridge 4",
"Intro 1", "Intro 2", "Ending 1", "Ending 2", "Other 1",
"Other 2", "Other 3", "Other 4",
];
#[allow(clippy::too_many_lines)]
impl FromRow<'_, SqliteRow> for Song {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let lyrics: &str = row.try_get(8)?;
// let Some((mut verses, mut verse_map)) =
// lyrics_to_verse(lyrics.clone()).ok()
// else {
// return Err(sqlx::Error::ColumnDecode {
// index: "8".into(),
// source: miette!(
// "Couldn't decode the song into verses"
// )
// .into(),
// });
// };
let lyrics: &str = row.try_get("lyrics")?;
let Ok(verse_map) = ron::de::from_str::<
Option<HashMap<VerseName, String>>,
@ -344,7 +387,7 @@ impl FromRow<'_, SqliteRow> for Song {
.into(),
});
};
let verse_order: &str = row.try_get(0)?;
let verse_order: &str = row.try_get("verse_order")?;
let Ok(verses) =
ron::de::from_str::<Option<Vec<VerseName>>>(verse_order)
else {
@ -364,25 +407,69 @@ impl FromRow<'_, SqliteRow> for Song {
.collect()
};
let stroke_size = match row.try_get("stroke_size") {
Ok(size) => Some(size),
Err(e) => {
error!(?e);
None
}
};
let stroke_color = row
.try_get("stroke_color")
.ok()
.and_then(|color: String| {
ron::de::from_str::<Option<Srgb>>(&color).ok()
})
.flatten();
let shadow_size = row.try_get("shadow_size").ok();
let shadow_color = row
.try_get("shadow_color")
.ok()
.and_then(|color: String| {
ron::de::from_str::<Option<Srgb>>(&color).ok()
})
.flatten();
let shadow_offset = match (
row.try_get("shadow_offset_x").ok(),
row.try_get("shadow_offset_y").ok(),
) {
(Some(x), Some(y)) => Some((x, y)),
_ => None,
};
let style_string: String = row.try_get("style")?;
let font_style =
ron::de::from_str::<Option<Style>>(&style_string)
.ok()
.flatten();
let weight_string: String = row.try_get("weight")?;
let font_weight =
ron::de::from_str::<Option<Weight>>(&weight_string)
.ok()
.flatten();
Ok(Self {
id: row.try_get(12)?,
title: row.try_get(5)?,
id: row.try_get("id")?,
title: row.try_get("title")?,
lyrics: Some(lyrics.to_string()),
author: row.try_get(10)?,
ccli: row.try_get(9)?,
author: row.try_get("author")?,
ccli: row.try_get("ccli")?,
audio: Some(PathBuf::from({
let string: String = row.try_get(11)?;
let string: String = row.try_get("audio")?;
string
})),
verse_order: Some(verse_order),
background: {
let string: String = row.try_get(7)?;
let string: String = row.try_get("background")?;
Background::try_from(string).ok()
},
text_alignment: Some({
let horizontal_alignment: String = row.try_get(4)?;
let vertical_alignment: String = row.try_get(3)?;
debug!(horizontal_alignment, vertical_alignment);
let horizontal_alignment: String =
row.try_get("horizontal_text_alignment")?;
let vertical_alignment: String =
row.try_get("vertical_text_alignment")?;
// debug!(horizontal_alignment, vertical_alignment);
match (
horizontal_alignment.to_lowercase().as_str(),
vertical_alignment.to_lowercase().as_str(),
@ -403,9 +490,15 @@ impl FromRow<'_, SqliteRow> for Song {
_ => TextAlignment::MiddleCenter,
}
}),
font: row.try_get(6)?,
font_size: row.try_get(1)?,
stroke_size: None,
font: row.try_get("font")?,
font_size: row.try_get("font_size")?,
font_style,
font_weight,
stroke_size,
stroke_color,
shadow_size,
shadow_color,
shadow_offset,
verses,
verse_map,
..Default::default()
@ -413,6 +506,27 @@ impl FromRow<'_, SqliteRow> for Song {
}
}
impl From<OnlineSong> for Song {
fn from(value: OnlineSong) -> Self {
let mut song = Self::default();
song.verse_map = Some(HashMap::new());
for line in value.lyrics.lines() {
let next_verse = song.get_next_verse_name();
if let Some(verse_map) = song.verse_map.as_mut() {
verse_map
.entry(next_verse)
.or_insert_with(|| line.to_string());
}
if let Some(verses) = song.verses.as_mut() {
verses.push(next_verse);
} else {
song.verses = Some(vec![next_verse]);
}
}
song
}
}
impl From<Value> for Song {
fn from(value: Value) -> Self {
match value {
@ -422,72 +536,9 @@ impl From<Value> for Song {
}
}
fn lyrics_to_verse(
lyrics: String,
) -> Result<(Vec<VerseName>, HashMap<VerseName, String>)> {
let mut verse_list = Vec::new();
if lyrics.is_empty() {
return Err(miette!("There is no lyrics here"));
}
let raw_lyrics = lyrics.as_str();
let mut lyric_map = HashMap::new();
let mut verse_title = String::new();
let mut lyric = String::new();
for (i, line) in raw_lyrics.split('\n').enumerate() {
if VERSE_KEYWORDS.contains(&line) {
if i != 0 {
lyric_map.insert(verse_title, lyric);
lyric = String::new();
verse_title = line.to_string();
} else {
verse_title = line.to_string();
}
} else {
lyric.push_str(line);
lyric.push('\n');
}
}
lyric_map.insert(verse_title, lyric);
let mut verse_map = HashMap::new();
for (verse_name, lyric) in lyric_map {
let mut verse_elements = verse_name.split_whitespace();
let verse_keyword = verse_elements.next();
let Some(keyword) = verse_keyword else {
return Err(miette!(
"Can't parse a proper verse keyword from lyrics"
));
};
let verse_index = verse_elements.next();
let Some(index) = verse_index else {
return Err(miette!(
"Can't parse a proper verse index from lyrics"
));
};
let index = index.parse::<usize>().into_diagnostic()?;
let verse = match keyword {
"Verse" => VerseName::Verse { number: index },
"Pre-Chorus" => VerseName::PreChorus { number: index },
"Chorus" => VerseName::Chorus { number: index },
"Post-Chorus" => VerseName::PostChorus { number: index },
"Bridge" => VerseName::Bridge { number: index },
"Intro" => VerseName::Intro { number: index },
"Outro" => VerseName::Outro { number: index },
"Instrumental" => {
VerseName::Instrumental { number: index }
}
"Other" => VerseName::Other { number: index },
_ => VerseName::Other { number: 99 },
};
verse_list.push(verse);
let lyric = lyric.trim().to_string();
verse_map.insert(verse, lyric);
}
Ok((verse_list, verse_map))
}
#[allow(clippy::option_if_let_else)]
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_lines)]
pub fn lisp_to_song(list: Vec<Value>) -> Song {
const DEFAULT_SONG_ID: i32 = 0;
// const DEFAULT_SONG_LOCATION: usize = 0;
@ -566,7 +617,8 @@ pub fn lisp_to_song(list: Vec<Value>) -> Song {
.position(|v| v == &Value::Keyword(Keyword::from("title")))
{
let pos = key_pos + 1;
list.get(pos).map_or(String::from("song"), String::from)
list.get(pos)
.map_or_else(|| String::from("song"), String::from)
} else {
String::from("song")
};
@ -609,10 +661,7 @@ pub fn lisp_to_song(list: Vec<Value>) -> Song {
|| text.contains("i1")
}
_ => false,
} && match &inner[1] {
Value::String(_) => true,
_ => false,
})
} && matches!(&inner[1], Value::String(_)))
}
_ => false,
})
@ -634,7 +683,7 @@ pub fn lisp_to_song(list: Vec<Value>) -> Song {
let verse_title = match lyric_verse.as_str() {
"i1" => r"\n\nIntro 1\n",
"i2" => r"\n\nIntro 1\n",
"i2" => r"\n\nIntro 2\n",
"v1" => r"\n\nVerse 1\n",
"v2" => r"\n\nVerse 2\n",
"v3" => r"\n\nVerse 3\n",
@ -685,7 +734,7 @@ pub async fn get_song_from_db(
index: i32,
db: &mut SqliteConnection,
) -> Result<Song> {
let row = query(r#"SELECT verse_order as "verse_order!", font_size as "font_size!: i32", background_type as "background_type!", horizontal_text_alignment as "horizontal_text_alignment!", vertical_text_alignment as "vertical_text_alignment!", title as "title!", font as "font!", background as "background!", lyrics as "lyrics!", ccli as "ccli!", author as "author!", audio as "audio!", id as "id: i32" from songs where id = $1"#).bind(index).fetch_one(db).await.into_diagnostic()?;
let row = query("SELECT verse_order, font_size, background_type, horizontal_text_alignment, vertical_text_alignment, title, font, background, lyrics, ccli, author, audio, stroke_size, stroke_color, shadow_color, shadow_size, shadow_offset_x, shadow_offset_y, style, weight, id from songs where id = $1").bind(index).fetch_one(db).await.into_diagnostic()?;
Song::from_row(&row).into_diagnostic()
}
@ -702,22 +751,15 @@ impl Model<Song> {
pub async fn load_from_db(&mut self, db: &mut SqlitePool) {
// static DATABASE_URL: &str = "sqlite:///home/chris/.local/share/lumina/library-db.sqlite3";
let db1 = db.acquire().await.unwrap();
let result = query(r#"SELECT verse_order as "verse_order!", font_size as "font_size!: i32", background_type as "background_type!", horizontal_text_alignment as "horizontal_text_alignment!", vertical_text_alignment as "vertical_text_alignment!", title as "title!", font as "font!", background as "background!", lyrics as "lyrics!", ccli as "ccli!", author as "author!", audio as "audio!", id as "id: i32" from songs"#).fetch_all(&mut db1.detach()).await;
let db1 = db.acquire().await.expect("Database not found");
let result = query("SELECT verse_order, font_size, background_type, horizontal_text_alignment, vertical_text_alignment, title, font, background, lyrics, ccli, author, audio, stroke_size, shadow_size, stroke_color, shadow_color, shadow_offset_x, shadow_offset_y, style, weight, id from songs").fetch_all(&mut db1.detach()).await;
match result {
Ok(s) => {
for song in s {
let db2 = db.acquire().await.unwrap();
// let db2 = db.acquire().await.unwrap();
match Song::from_row(&song) {
Ok(song) => {
match update_song_in_db(song.clone(), db2)
.await
{
Ok(_) => {
let _ = self.add_item(song);
}
Err(e) => error!(?e),
}
let _ = self.add_item(song);
}
Err(e) => {
error!(
@ -753,16 +795,14 @@ pub async fn add_song_to_db(
let mut song = Song::default();
let verse_order = {
if let Some(vo) = song.verse_order.clone() {
song.verse_order.clone().map_or_else(String::new, |vo| {
vo.into_iter()
.map(|mut s| {
s.push(' ');
s
})
.collect::<String>()
} else {
String::new()
}
})
};
let audio = song
@ -790,7 +830,9 @@ pub async fn add_song_to_db(
.execute(&mut db)
.await
.into_diagnostic()?;
song.id = res.last_insert_rowid() as i32;
song.id = i32::try_from(res.last_insert_rowid()).expect(
"Fairly confident that this number won't get that high",
);
Ok(song)
}
@ -815,30 +857,55 @@ pub async fn update_song_in_db(
let lyrics = item.verse_map.map(|map| {
map.iter()
.map(|(name, lyric)| {
let lyric = lyric.trim_end_matches("\n").to_string();
let lyric = lyric.trim_end_matches('\n').to_string();
(name.to_owned(), lyric)
})
.collect::<HashMap<VerseName, String>>()
});
let lyrics = ron::ser::to_string(&lyrics).into_diagnostic()?;
let (vertical_alignment, horizontal_alignment) = item
.text_alignment
.map(|ta| match ta {
TextAlignment::TopLeft => ("top", "left"),
TextAlignment::TopCenter => ("top", "center"),
TextAlignment::TopRight => ("top", "right"),
TextAlignment::MiddleLeft => ("center", "left"),
TextAlignment::MiddleCenter => ("center", "center"),
TextAlignment::MiddleRight => ("center", "right"),
TextAlignment::BottomLeft => ("bottom", "left"),
TextAlignment::BottomCenter => ("bottom", "center"),
TextAlignment::BottomRight => ("bottom", "right"),
})
.unwrap_or_else(|| ("center", "center"));
let (vertical_alignment, horizontal_alignment) =
item.text_alignment.map_or_else(
|| ("center", "center"),
|ta| match ta {
TextAlignment::TopLeft => ("top", "left"),
TextAlignment::TopCenter => ("top", "center"),
TextAlignment::TopRight => ("top", "right"),
TextAlignment::MiddleLeft => ("center", "left"),
TextAlignment::MiddleCenter => ("center", "center"),
TextAlignment::MiddleRight => ("center", "right"),
TextAlignment::BottomLeft => ("bottom", "left"),
TextAlignment::BottomCenter => ("bottom", "center"),
TextAlignment::BottomRight => ("bottom", "right"),
},
);
query!(
r#"UPDATE songs SET title = $2, lyrics = $3, author = $4, ccli = $5, verse_order = $6, audio = $7, font = $8, font_size = $9, background = $10, horizontal_text_alignment = $11, vertical_text_alignment = $12 WHERE id = $1"#,
let stroke_size = item.stroke_size.unwrap_or_default();
let shadow_size = item.shadow_size.unwrap_or_default();
let (shadow_offset_x, shadow_offset_y) =
item.shadow_offset.unwrap_or_default();
let stroke_color =
ron::ser::to_string(&item.stroke_color).into_diagnostic()?;
let shadow_color =
ron::ser::to_string(&item.shadow_color).into_diagnostic()?;
let style =
ron::ser::to_string(&item.font_style).into_diagnostic()?;
let weight =
ron::ser::to_string(&item.font_weight).into_diagnostic()?;
// debug!(
// ?stroke_size,
// ?stroke_color,
// ?shadow_size,
// ?shadow_color,
// ?shadow_offset_x,
// ?shadow_offset_y
// );
let result = query!(
r#"UPDATE songs SET title = $2, lyrics = $3, author = $4, ccli = $5, verse_order = $6, audio = $7, font = $8, font_size = $9, background = $10, horizontal_text_alignment = $11, vertical_text_alignment = $12, stroke_color = $13, shadow_color = $14, stroke_size = $15, shadow_size = $16, shadow_offset_x = $17, shadow_offset_y = $18, style = $19, weight = $20 WHERE id = $1"#,
item.id,
item.title,
lyrics,
@ -849,25 +916,34 @@ pub async fn update_song_in_db(
item.font,
item.font_size,
background,
horizontal_alignment,
vertical_alignment,
horizontal_alignment
stroke_color,
shadow_color,
stroke_size,
shadow_size,
shadow_offset_x,
shadow_offset_y,
style,
weight
)
.execute(&mut db.detach())
.await
.into_diagnostic()?;
debug!(rows_affected = ?result.rows_affected());
Ok(())
}
impl Song {
#[must_use]
pub fn get_lyric(&self, verse: &VerseName) -> Option<String> {
let lyric = self.verse_map.as_ref().and_then(|verse_map| {
self.verse_map.as_ref().and_then(|verse_map| {
verse_map.get(verse).cloned().map(|lyric| {
lyric.trim().trim_end_matches("\n").to_string()
lyric.trim().trim_end_matches('\n').to_string()
})
});
lyric
})
}
pub fn set_lyrics<T: Into<String>>(
@ -881,7 +957,7 @@ impl Song {
verse_map
.entry(*verse)
.and_modify(|old_lyrics| {
*old_lyrics = lyric_copy.clone()
old_lyrics.clone_from(&lyric_copy);
})
.or_insert(lyric_copy);
// debug!(?verse_map, "should be updated");
@ -895,91 +971,20 @@ impl Song {
}
pub fn get_lyrics(&self) -> Result<Vec<String>> {
// ---------------------------------
// new implementation
// ---------------------------------
if let Some(verses) = self.verses.as_ref() {
let mut lyrics = vec![];
for verse in verses {
if verse == &VerseName::Blank {
lyrics.push("".into());
lyrics.push(String::new());
continue;
}
if let Some(lyric) = self.get_lyric(verse) {
lyrics.push(lyric)
lyrics.push(lyric);
}
}
return Ok(lyrics);
} else {
return Err(miette!("No verses in this song yet"));
}
// ---------------------------------
// old implementation
// ---------------------------------
let mut lyric_list = Vec::new();
if self.lyrics.is_none() {
return Err(miette!("There is no lyrics here"));
} else if self.verse_order.is_none() {
return Err(miette!("There is no verse_order here"));
} else if self
.verse_order
.clone()
.is_some_and(|v| v.is_empty())
{
return Err(miette!("There is no verse_order here"));
}
if let Some(raw_lyrics) = self.lyrics.clone() {
let raw_lyrics = raw_lyrics.as_str();
let verse_order = self.verses.clone();
let mut lyric_map = HashMap::new();
let mut verse_title = String::new();
let mut lyric = String::new();
for (i, line) in raw_lyrics.split('\n').enumerate() {
if VERSE_KEYWORDS.contains(&line) {
if i != 0 {
lyric_map.insert(verse_title, lyric);
lyric = String::new();
verse_title = line.to_string();
} else {
verse_title = line.to_string();
}
} else {
lyric.push_str(line);
lyric.push('\n');
}
}
lyric_map.insert(verse_title, lyric);
for verse in verse_order.unwrap_or_default() {
let verse_name = &verse.get_name();
if let Some(lyric) = lyric_map.get(verse_name) {
if lyric.contains("\n\n") {
let split_lyrics: Vec<&str> =
lyric.split("\n\n").collect();
for lyric in split_lyrics {
if lyric.is_empty() {
continue;
}
lyric_list.push(lyric.to_string());
}
continue;
}
lyric_list.push(lyric.clone());
} else {
// error!("NOT WORKING!");
}
}
// for lyric in lyric_list.iter() {
// debug!(lyric = ?lyric)
// }
Ok(lyric_list)
} else {
Err(miette!("There are no lyrics"))
}
Err(miette!("No verses in this song yet"))
}
pub fn update_verse_name(
@ -990,7 +995,11 @@ impl Song {
if let Some(verse_map) = self.verse_map.as_mut()
&& let Some(lyric) = verse_map.remove(old_verse)
{
verse_map.insert(verse, lyric);
if verse == VerseName::Blank {
verse_map.insert(verse, String::new());
} else {
verse_map.insert(verse, lyric);
}
}
let Some(verses) = self.verses.clone() else {
return;
@ -1000,7 +1009,7 @@ impl Song {
.filter(|verse| verse != old_verse)
.collect();
new_verses.push(verse);
self.verses = Some(new_verses)
self.verses = Some(new_verses);
}
// TODO update_verse needs to also change the lyrics for the song such that
@ -1077,41 +1086,31 @@ impl Song {
}
}
#[must_use]
pub fn get_next_verse_name(&self) -> VerseName {
if let Some(verse_names) = &self.verses {
let verses: Vec<&VerseName> = verse_names
let verses = verse_names
.iter()
.filter(|verse| match verse {
VerseName::Verse { .. } => true,
_ => false,
.filter(|verse| {
matches!(verse, VerseName::Verse { .. })
})
.sorted()
.collect();
let choruses: Vec<&VerseName> = verse_names
.iter()
.filter(|verse| match verse {
VerseName::Chorus { .. } => true,
_ => false,
})
.collect();
let bridges: Vec<&VerseName> = verse_names
.iter()
.filter(|verse| match verse {
VerseName::Bridge { .. } => true,
_ => false,
})
.collect();
if verses.is_empty() {
.sorted();
let mut choruses = verse_names.iter().filter(|verse| {
matches!(verse, VerseName::Chorus { .. })
});
let mut bridges = verse_names.iter().filter(|verse| {
matches!(verse, VerseName::Bridge { .. })
});
if verses.len() == 0 {
VerseName::Verse { number: 1 }
} else if choruses.is_empty() {
} else if choruses.next().is_none() {
VerseName::Chorus { number: 1 }
} else if verses.len() == 1 {
let verse_number =
if let Some(last_verse) = verses.iter().last() {
match last_verse {
VerseName::Verse { number } => *number,
_ => 0,
}
if let Some(VerseName::Verse { number }) =
verses.last()
{
*number
} else {
0
};
@ -1119,10 +1118,10 @@ impl Song {
return VerseName::Verse { number: 1 };
}
VerseName::Verse { number: 2 }
} else if bridges.is_empty() {
} else if bridges.next().is_none() {
VerseName::Bridge { number: 1 }
} else {
if let Some(last_verse) = verses.iter().last()
if let Some(last_verse) = verses.last()
&& let VerseName::Verse { number } = last_verse
{
return VerseName::Verse { number: number + 1 };
@ -1145,22 +1144,20 @@ impl Song {
verses.push(verse);
} else {
self.verses = Some(vec![verse]);
};
}
}
pub(crate) fn verse_name_from_str(
&self,
verse_name: String, // chorus 2
verse_name: &str, // chorus 2
old_verse_name: VerseName, // v4
) -> VerseName {
if old_verse_name.get_name() == verse_name {
return old_verse_name;
};
if let Some(verses) =
self.verse_map.clone().map(|verse_map| {
}
self.verse_map.clone().map(|verse_map| {
verse_map.into_keys().collect::<Vec<VerseName>>()
})
{
}).map_or_else(|| VerseName::from_string(verse_name), |verses| {
verses
.into_iter()
.filter(|verse| {
@ -1168,8 +1165,8 @@ impl Song {
.get_name()
.split_whitespace()
.next()
.unwrap()
== &verse_name
.expect("Shouldn't fail, the get_name() fn won't return a string that is blank or all whitespace")
== verse_name
})
.sorted()
.last()
@ -1177,9 +1174,7 @@ impl Song {
|| VerseName::from_string(verse_name),
|verse_name| verse_name.next(),
)
} else {
VerseName::from_string(verse_name)
}
})
}
pub(crate) fn delete_verse(&mut self, verse: VerseName) {
@ -1194,10 +1189,14 @@ impl Song {
#[cfg(test)]
mod test {
use std::fs::read_to_string;
use std::sync::Arc;
use crate::ui::text_svg::text_svg_generator_with_cache;
use super::*;
use pretty_assertions::assert_eq;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use resvg::usvg::fontdb;
#[test]
pub fn test_song_lyrics() {
@ -1337,11 +1336,7 @@ You saved my soul"
}
async fn add_db() -> Result<SqlitePool> {
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());
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
@ -1379,7 +1374,7 @@ You saved my soul"
#[tokio::test]
async fn test_song_from_db() {
let song = test_song();
let mut db = crate::core::model::get_db().await;
let mut db = add_db().await.unwrap().acquire().await.unwrap();
let result = get_song_from_db(7, &mut db).await;
match result {
Ok(db_song) => {
@ -1412,7 +1407,7 @@ You saved my soul"
}
}
fn test_song() -> Song {
pub 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).unwrap();
@ -1438,15 +1433,6 @@ You saved my soul"
}
}
fn test_lisp_song() -> Value {
let lisp = read_to_string("./test_song.lisp").expect("oops");
let lisp_value = crisp::reader::read(&lisp);
match lisp_value {
Value::List(v) => v.first().unwrap().clone(),
_ => Value::Nil,
}
}
#[test]
fn test_verse_names_and_adding() {
let mut song = Song::default();
@ -1497,6 +1483,50 @@ You saved my soul"
assert_eq!(name, VerseName::Verse { number: 5 });
}
#[tokio::test]
async fn test_song_to_slide() {
let song = test_song();
let songs: Vec<Song> = (0..100)
.map(|index| {
let mut song = song.clone();
song.id = index;
song
})
.collect();
let fontdb = Arc::new(fontdb::Database::new());
songs.into_par_iter().for_each(|song| {
let slides = song.to_slides().unwrap();
slides.into_par_iter().for_each(|slide| {
text_svg_generator_with_cache(slide, &fontdb, None)
.map_or_else(
|e| assert!(false, "{e}"),
|slide| {
assert!(slide.text_svg.is_some_and(
|svg| svg.handle.is_some()
))
},
)
});
});
}
// extern crate test;
// use test::{Bencher, black_box};
// #[bench]
// fn bench_pow(b: &mut Bencher) {
// // Optionally include some setup
// let x: f64 = 211.0 * 11.0;
// let y: f64 = 301.0 * 103.0;
// b.iter(|| {
// // Inner closure, the actual test
// for i in 1..100 {
// black_box(x.powf(y).powf(x));
// }
// });
// }
// #[test]
// pub fn test_lisp_conversion() {
// let value = test_lisp_song();

View file

@ -16,9 +16,8 @@ pub fn bg_from_video(
} else {
let output_duration = Command::new("ffprobe")
.args(["-i", &video.to_string_lossy()])
.output()
.expect("failed to execute ffprobe");
io::stderr().write_all(&output_duration.stderr).unwrap();
.output()?;
io::stderr().write_all(&output_duration.stderr)?;
let mut at_second = 5;
let mut log = str::from_utf8(&output_duration.stderr)
.expect("Using non UTF-8 characters")
@ -72,15 +71,18 @@ pub fn bg_from_video(
pub fn bg_path_from_video(video: &Path) -> PathBuf {
let video = PathBuf::from(video);
debug!(?video);
let mut data_dir = dirs::data_local_dir().unwrap();
let mut data_dir =
dirs::cache_dir().expect("Can't find cache dir");
data_dir.push("lumina");
data_dir.push("thumbnails");
let _ = fs::create_dir_all(&data_dir);
if !data_dir.exists() {
fs::create_dir(&data_dir)
.expect("Could not create thumbnails dir");
}
let mut screenshot = data_dir.clone();
screenshot.push(video.file_name().unwrap());
screenshot
.push(video.file_name().expect("Should have file name"));
screenshot.set_extension("png");
screenshot
}
@ -91,19 +93,9 @@ mod test {
#[test]
fn test_bg_video_creation() {
let video = Path::new("/home/chris/vids/moms-funeral.mp4");
let video = Path::new("./res/bigbuckbunny.mp4");
let screenshot = bg_path_from_video(video);
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 {
match bg_from_video(video, &screenshot) {
Ok(_o) => assert!(screenshot.exists()),
Err(e) => debug_assert!(
false,
@ -112,18 +104,4 @@ mod test {
),
}
}
#[test]
fn test_bg_not_same() {
let video = Path::new(
"/home/chris/vids/All WebDev Sucks and you know it.webm",
);
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_ne!(
screenshot_string,
"/home/chris/.local/share/lumina/thumbnails/All WebDev Sucks and you know it.webm"
);
}
}

View file

@ -74,11 +74,10 @@ impl Content for Video {
fn subtext(&self) -> String {
if self.path.exists() {
self.path
.file_name()
.map_or("Missing video".into(), |f| {
f.to_string_lossy().to_string()
})
self.path.file_name().map_or_else(
|| "Missing video".into(),
|f| f.to_string_lossy().to_string(),
)
} else {
"Missing video".into()
}
@ -91,20 +90,21 @@ 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 = if let Some(path_pos) =
list.iter().position(|v| {
let path = 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
};
})
.and_then(|path_pos| {
let pos = path_pos + 1;
list.get(pos)
.map(|p| PathBuf::from(String::from(p)))
});
let title = path.clone().map(|p| {
let path =
@ -114,40 +114,41 @@ impl From<&Value> for Video {
title.to_string()
});
let start_time = if let Some(start_pos) =
list.iter().position(|v| {
let start_time = 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
};
})
.and_then(|start_pos| {
let pos = start_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32)
});
let end_time = if let Some(end_pos) =
list.iter().position(|v| {
let end_time = 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
};
})
.and_then(|end_pos| {
let pos = end_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32)
});
let looping = if let Some(loop_pos) =
list.iter().position(|v| {
let looping = list
.iter()
.position(|v| {
v == &Value::Keyword(Keyword::from("loop"))
}) {
let pos = loop_pos + 1;
list.get(pos)
.is_some_and(|l| String::from(l) == *"true")
} else {
false
};
})
.is_some_and(|loop_pos| {
let pos = loop_pos + 1;
list.get(pos).is_some_and(|l| {
String::from(l) == *"true"
})
});
Self {
title: title.unwrap_or_default(),
@ -310,7 +311,9 @@ mod test {
fn test_video(title: String) -> Video {
Video {
title,
path: PathBuf::from("~/vids/camprules2024.mp4"),
path: PathBuf::from(
"/home/chris/docs/notes/lessons/christ-our-hope.mp4",
),
..Default::default()
}
}
@ -321,13 +324,10 @@ mod test {
items: vec![],
kind: LibraryKind::Video,
};
let mut db = crate::core::model::get_db().await;
let mut db = add_db().await.unwrap().acquire().await.unwrap();
video_model.load_from_db(&mut db).await;
if let Some(video) = video_model.find(|v| v.id == 73) {
let test_video = test_video(
"Getting started with Tokio. The ultimate starter guide to writing async Rust."
.into(),
);
if let Some(video) = video_model.find(|v| v.id == 2) {
let test_video = test_video("christ-our-hope.mp4".into());
assert_eq!(test_video.title, video.title);
} else {
assert!(false);
@ -361,4 +361,9 @@ mod test {
),
}
}
async fn add_db() -> Result<SqlitePool> {
let db_url = String::from("sqlite://./test.db");
SqlitePool::connect(&db_url).await.into_diagnostic()
}
}

View file

@ -35,156 +35,157 @@ 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,
// slide::{Background, TextAlignment},
// songs::Song,
// videos::Video,
// };
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"),
// }
// }
#[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]);
// // 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]);
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()),
// slides: vec![slide],
// ..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 {
// 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_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),
// stroke_size: Some(2),
// verses: None,
// verse_map: None,
// stroke_color: todo!(),
// shadow_size: todo!(),
// shadow_offset: todo!(),
// shadow_color: todo!(),
// }
// }
// }

View file

@ -1,11 +1,13 @@
use clap::{Parser, command};
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
use clap::Parser;
use core::service_items::ServiceItem;
use core::slide::{
Background, BackgroundKind, Slide, SlideBuilder, TextAlignment,
};
use cosmic::app::{Core, Settings, Task};
use cosmic::cosmic_config::{Config, CosmicConfigEntry};
use cosmic::dialog::file_chooser::save;
use cosmic::dialog::file_chooser::{open, save};
use cosmic::iced::alignment::Vertical;
use cosmic::iced::keyboard::{Key, Modifiers};
use cosmic::iced::window::{Mode, Position};
@ -33,13 +35,12 @@ use cosmic::widget::{container, text};
use cosmic::widget::{icon, slider};
use cosmic::{Application, ApplicationExt, Apply, Element, executor};
use cosmic::{cosmic_config, theme};
use crisp::types::Value;
use lisp::parse_lisp;
// use crisp::types::Value;
// use lisp::parse_lisp;
use miette::{IntoDiagnostic, Result, miette};
use rayon::prelude::*;
use resvg::usvg::fontdb;
use std::collections::HashMap;
use std::fs::read_to_string;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, level_filters::LevelFilter};
@ -61,7 +62,7 @@ use crate::ui::video_editor::{self, VideoEditor};
use crate::ui::widgets::draggable;
pub mod core;
pub mod lisp;
// pub mod lisp;
pub mod ui;
#[derive(Debug, Parser)]
@ -123,17 +124,16 @@ fn main() -> Result<()> {
}
};
let settings;
if args.ui {
let settings = if args.ui {
debug!(target: "lumina", "main view");
settings = Settings::default().debug(false).is_daemon(true);
Settings::default().debug(false).is_daemon(true)
} else {
debug!("window view");
settings = Settings::default()
Settings::default()
.debug(false)
.no_main_window(true)
.is_daemon(true);
}
.is_daemon(true)
};
cosmic::app::run::<App>(settings, (args, config_handler, config))
.map_err(|e| miette!("Invalid things... {}", e))
@ -143,6 +143,7 @@ fn main() -> Result<()> {
// Theme::dark()
// }
#[allow(clippy::struct_excessive_bools)]
struct App {
core: Core,
nav_model: nav_bar::Model,
@ -178,6 +179,7 @@ struct App {
obs_connection: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
enum Message {
Present(presenter::Message),
@ -219,6 +221,7 @@ enum Message {
New,
Open,
OpenFile(PathBuf),
OpenLoadItems(Vec<ServiceItem>),
Save,
SaveAsDialog,
SaveAs(PathBuf),
@ -273,6 +276,8 @@ impl cosmic::Application for App {
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
#[allow(clippy::too_many_lines)]
fn init(
core: Core,
input: Self::Flags,
@ -287,60 +292,56 @@ impl cosmic::Application for App {
let mut windows = vec![];
if input.0.ui {
windows.push(core.main_window_id().unwrap());
windows.push(
core.main_window_id()
.expect("should be a window here"),
);
}
let (config_handler, settings) = (input.1, input.2);
let items = if let Some(file) = input.0.file {
match read_to_string(file) {
Ok(lisp) => {
let mut service_items = vec![];
let lisp = crisp::reader::read(&lisp);
match lisp {
Value::List(vec) => {
// let items = vec
// .into_par_iter()
// .map(|value| parse_lisp(value))
// .collect();
// slide_vector.append(items);
for value in vec {
let mut inner_vector =
parse_lisp(value);
service_items
.append(&mut inner_vector);
}
}
_ => todo!(),
}
service_items
}
Err(e) => {
warn!("Missing file or could not read: {e}");
vec![]
}
}
} else {
vec![]
};
// let items = input.0.file.map_or_else(Vec::new, |file| {
// match read_to_string(file) {
// Ok(lisp) => {
// let mut service_items = vec![];
// let lisp = crisp::reader::read(&lisp);
// match lisp {
// Value::List(vec) => {
// for value in vec {
// let mut inner_vector =
// parse_lisp(value);
// service_items
// .append(&mut inner_vector);
// }
// }
// _ => todo!(),
// }
// service_items
// }
// Err(e) => {
// warn!("Missing file or could not read: {e}");
// vec![]
// }
// }
// });
let items: Vec<ServiceItem> = items
.into_par_iter()
.map(|mut item| {
item.slides = item
.slides
.into_par_iter()
.map(|mut slide| {
text_svg::text_svg_generator(
&mut slide,
Arc::clone(&fontdb),
);
slide
})
.collect();
item
})
.collect();
// let items: Vec<ServiceItem> = items
// .into_par_iter()
// .map(|mut item| {
// item.slides = item
// .slides
// .into_par_iter()
// .map(|mut slide| {
// text_svg::text_svg_generator(
// &mut slide, &fontdb,
// );
// slide
// })
// .collect();
// item
// })
// .collect();
let items: Vec<ServiceItem> = vec![];
let presenter = Presenter::with_items(items.clone());
let song_editor = SongEditor::new(Arc::clone(&fontdb));
@ -435,7 +436,7 @@ impl cosmic::Application for App {
batch.push(app.show_window());
}
batch.push(app.add_library());
batch.push(add_library());
// batch.push(app.add_service(items, Arc::clone(&fontdb)));
let batch = Task::batch(batch);
(app, batch)
@ -660,7 +661,7 @@ impl cosmic::Application for App {
iced::keyboard::Event::ModifiersChanged(
modifiers,
) => Some(Message::ModifiersPressed(modifiers)),
_ => None,
iced::keyboard::Event::KeyPressed { .. } => None,
},
iced::Event::Mouse(_event) => None,
iced::Event::Window(window_event) => {
@ -713,6 +714,7 @@ impl cosmic::Application for App {
None
}
#[allow(clippy::too_many_lines)]
fn dialog(&self) -> Option<Element<'_, Self::Message>> {
let cosmic::cosmic_theme::Spacing {
space_xxs,
@ -774,6 +776,7 @@ impl cosmic::Application for App {
.spacing(space_s)
.apply(container)
.padding(space_xl)
.max_width(600)
.style(nav_bar_style);
let modal = mouse_area(modal)
.on_press(Message::None)
@ -857,11 +860,18 @@ impl cosmic::Application for App {
modal
);
Some(mouse_stack.into())
} else if self.song_editor.importing() {
Some(
self.song_editor
.import_view()
.map(Message::SongEditor),
)
} else {
None
}
}
#[allow(clippy::too_many_lines)]
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Key(key, modifiers) => {
@ -951,7 +961,7 @@ impl cosmic::Application for App {
}
presentation_editor::Action::SplitAddPresentation((first, second)) => {
if self.library.is_some() {
let second_task = self.update(Message::Library(library::Message::AddPresentations(Some(vec![second]))));
let second_task = self.update(Message::Library(library::Message::AddPresentationSplit(Some(second))));
self.update(Message::Library(library::Message::UpdatePresentation(first))).chain(second_task)
} else {
@ -1025,7 +1035,6 @@ impl cosmic::Application for App {
}
self.current_item =
(item_index, slide_index);
Task::batch(tasks)
} else {
// debug!("Slides are not longer");
if self
@ -1047,8 +1056,8 @@ impl cosmic::Application for App {
self.current_item =
(item_index + 1, 0);
}
Task::batch(tasks)
}
Task::batch(tasks)
} else {
Task::none()
}
@ -1087,14 +1096,11 @@ impl cosmic::Application for App {
} else {
// debug!("Change slide to previous items slides");
let previous_item_slides_length =
if let Some(item) = self
.service
self.service
.get(item_index - 1)
{
item.slides.len()
} else {
0
};
.map_or(0, |item| {
item.slides.len()
});
self.current_item = (
item_index - 1,
previous_item_slides_length - 1,
@ -1215,11 +1221,7 @@ impl cosmic::Application for App {
})
}
Message::CloseWindow(id) => {
if let Some(id) = id {
window::close(id)
} else {
Task::none()
}
id.map_or_else(Task::none, window::close)
}
Message::WindowOpened(id) => {
debug!(?id, "Window opened");
@ -1377,12 +1379,13 @@ impl cosmic::Application for App {
item.slides = item
.slides
.into_par_iter()
.map(|mut slide| {
.map(|slide| {
let fontdb = Arc::clone(&self.fontdb);
text_svg::text_svg_generator(
&mut slide, fontdb,
);
slide
slide.clone(),
&fontdb,
)
.unwrap_or(slide)
})
.collect();
self.service.insert(index, item);
@ -1417,12 +1420,13 @@ impl cosmic::Application for App {
item.slides = item
.slides
.into_par_iter()
.map(|mut slide| {
.map(|slide| {
let fontdb = Arc::clone(&self.fontdb);
text_svg::text_svg_generator(
&mut slide, fontdb,
);
slide
slide.clone(),
&fontdb,
)
.unwrap_or(slide)
})
.collect();
self.service.push(item);
@ -1440,7 +1444,7 @@ impl cosmic::Application for App {
Task::none()
}
Message::Search(query) => {
self.search_query = query.clone();
self.search_query.clone_from(&query);
self.search(query)
}
Message::UpdateSearchResults(items) => {
@ -1497,22 +1501,50 @@ impl cosmic::Application for App {
}
Message::Open => {
debug!("Open file");
Task::none()
Task::perform(open_dialog(), |res| match res {
Ok(file) => {
cosmic::Action::App(Message::OpenFile(file))
}
Err(e) => {
error!(
?e,
"There was an error during opening"
);
cosmic::Action::None
}
})
}
Message::OpenFile(file) => {
debug!(?file, "opening file");
Task::perform(
async move { file::load(file) },
|res| match res {
Ok(items) => cosmic::Action::App(
Message::OpenLoadItems(items),
),
Err(e) => {
error!(?e);
cosmic::Action::None
}
},
)
}
Message::OpenLoadItems(items) => {
self.service = items.clone();
self.presenter.service = items;
Task::none()
}
Message::Save => {
let service = self.service.clone();
let file = self.file.clone();
let file_name = self.file.file_name().expect("Since we are saving we should have given a name by now").to_owned();
Task::perform(
file::save(service, file.clone()),
async move { file::save(service, file, true) },
move |res| match res {
Ok(()) => {
tracing::info!(
"saving file to: {:?}",
file
file_name
);
cosmic::Action::None
}
@ -1584,6 +1616,7 @@ impl cosmic::Application for App {
}
// Main window view
#[allow(clippy::too_many_lines)]
fn view(&self) -> Element<Message> {
let cosmic::cosmic_theme::Spacing {
space_none,
@ -1600,24 +1633,27 @@ impl cosmic::Application for App {
);
let video_button_icon =
if let Some(video) = &self.presenter.video {
let (icon_name, tooltip) = if video.paused() {
("media-play", "Play")
} else {
("media-pause", "Pause")
};
button::icon(icon::from_name(icon_name))
.tooltip(tooltip)
.on_press(Message::Present(
presenter::Message::StartVideo,
))
} else {
button::icon(icon::from_name("media-play"))
.tooltip("Play")
.on_press(Message::Present(
presenter::Message::StartVideo,
))
};
self.presenter.video.as_ref().map_or_else(
|| {
button::icon(icon::from_name("media-play"))
.tooltip("Play")
.on_press(Message::Present(
presenter::Message::StartVideo,
))
},
|video| {
let (icon_name, tooltip) = if video.paused() {
("media-play", "Play")
} else {
("media-pause", "Pause")
};
button::icon(icon::from_name(icon_name))
.tooltip(tooltip)
.on_press(Message::Present(
presenter::Message::StartVideo,
))
},
);
let slide_preview = column![
Space::with_height(Length::Fill),
@ -1659,13 +1695,10 @@ impl cosmic::Application for App {
let library = if self.library_open {
Container::new(
Container::new(
if let Some(library) = &self.library {
library.view().map(Message::Library)
} else {
Space::new(0, 0).into()
},
)
Container::new(self.library.as_ref().map_or_else(
|| Element::from(Space::new(0, 0)),
|library| library.view().map(Message::Library),
))
.style(nav_bar_style),
)
.padding(space_s)
@ -1806,14 +1839,8 @@ where
.map(|id| cosmic::Action::App(Message::WindowOpened(id)))
}
fn add_library(&self) -> Task<Message> {
Task::perform(async move { Library::new().await }, |x| {
cosmic::Action::App(Message::AddLibrary(x))
})
}
fn search(&self, query: String) -> Task<Message> {
if let Some(library) = self.library.clone() {
self.library.clone().map_or_else(Task::none, |library| {
Task::perform(
async move { library.search_items(query).await },
|items| {
@ -1822,9 +1849,19 @@ where
))
},
)
} else {
Task::none()
}
})
// if let Some(library) = self.library.clone() {
// Task::perform(
// async move { library.search_items(query).await },
// |items| {
// cosmic::Action::App(Message::UpdateSearchResults(
// items,
// ))
// },
// )
// } else {
// Task::none()
// }
}
fn process_key_press(
@ -1898,6 +1935,7 @@ where
}
}
#[allow(clippy::too_many_lines)]
fn service_list(&self) -> Element<Message> {
let list =
self.service.iter().enumerate().map(|(index, item)| {
@ -2170,13 +2208,30 @@ where
}
}
fn add_library() -> Task<Message> {
Task::perform(async move { Library::new().await }, |x| {
cosmic::Action::App(Message::AddLibrary(x))
})
}
async fn save_as_dialog() -> Result<PathBuf> {
let dialog = save::Dialog::new();
save::file(dialog).await.into_diagnostic().map(|response| {
response.url().map_or_else(
|| Err(miette!("Can't convert url of file to a path")),
|url| Ok(url.to_file_path().unwrap()),
|url| {
Ok(url.to_file_path().expect("Should be a file here"))
},
)
})?
}
async fn open_dialog() -> Result<PathBuf> {
let dialog = open::Dialog::new();
open::file(dialog).await.into_diagnostic().map(|response| {
response.url().to_file_path().map_err(|e| {
miette!("Can't convert to file path: {:?}", e)
})
})?
}

View file

@ -2,7 +2,7 @@ use std::{io, path::PathBuf};
use crate::core::images::Image;
use cosmic::{
Element, Task,
Apply, Element, Task,
dialog::file_chooser::{FileFilter, open::Dialog},
iced::{Length, alignment::Vertical},
iced_widget::{column, row},
@ -52,7 +52,7 @@ impl ImageEditor {
self.update_entire_image(&image);
}
Message::ChangeTitle(title) => {
self.title = title.clone();
self.title.clone_from(&title);
if let Some(image) = &self.image {
let mut image = image.clone();
image.title = title;
@ -77,13 +77,11 @@ impl ImageEditor {
let task = Task::perform(
pick_image(),
move |image_result| {
if let Ok(image) = image_result {
image_result.map_or(Message::None, |image| {
let mut image = Image::from(image);
image.id = image_id;
Message::Update(image)
} else {
Message::None
}
})
},
);
return Action::Task(task);
@ -95,12 +93,10 @@ impl ImageEditor {
#[must_use]
pub fn view(&self) -> Element<Message> {
let container = if let Some(pic) = &self.image {
let image = widget::image(pic.path.clone());
container(image)
} else {
container(Space::new(0, 0))
};
let container = self.image.as_ref().map_or_else(
|| Space::new(0, 0).apply(container),
|pic| widget::image(pic.path.clone()).apply(container),
);
let column = column![
self.toolbar(),
container.center_x(Length::FillPortion(2))
@ -139,7 +135,7 @@ impl ImageEditor {
fn update_entire_image(&mut self, image: &Image) {
self.image = Some(image.clone());
self.title = image.title.clone();
self.title.clone_from(&image.title);
}
}
@ -167,7 +163,9 @@ async fn pick_image() -> Result<PathBuf, ImageError> {
error!(?e);
ImageError::DialogClosed
})
.map(|file| file.url().to_file_path().unwrap())
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(

View file

@ -36,6 +36,7 @@ use crate::core::{
videos::{self, Video, add_video_to_db, update_video_in_db},
};
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone)]
pub struct Library {
song_library: Model<Song>,
@ -70,6 +71,7 @@ impl MenuAction for MenuMessage {
}
}
#[allow(clippy::large_enum_variant)]
pub enum Action {
OpenItem(Option<(LibraryKind, i32)>),
DraggedItem(ServiceItem),
@ -104,6 +106,7 @@ pub enum Message {
AddImages(Option<Vec<Image>>),
AddVideos(Option<Vec<Video>>),
AddPresentations(Option<Vec<Presentation>>),
AddPresentationSplit(Option<Presentation>),
}
impl<'a> Library {
@ -137,6 +140,10 @@ impl<'a> Library {
self.song_library.get_item(index)
}
#[allow(clippy::cast_possible_wrap)]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::match_same_arms)]
pub fn update(&'a mut self, message: Message) -> Action {
match message {
Message::None => (),
@ -248,8 +255,38 @@ impl<'a> Library {
Task::batch(tasks).chain(after_task),
);
}
Message::AddPresentationSplit(presentation) => {
debug!(?presentation, "adding to db");
if let Some(presentation) = presentation {
if let Err(e) = self
.presentation_library
.add_item(presentation.clone())
{
error!(?e);
}
return Action::Task(
Task::future(self.db.acquire()).and_then(
move |db| {
Task::perform(
add_presentation_to_db(
presentation.clone(),
db,
),
move |res| {
debug!("added to db");
if let Err(e) = res {
error!(?e);
}
Message::None
},
)
},
),
);
}
}
Message::AddPresentations(presentations) => {
debug!(?presentations);
debug!(?presentations, "adding to db");
let mut index = self.presentation_library.items.len();
// Check if empty
let mut tasks = Vec::new();
@ -591,7 +628,7 @@ impl<'a> Library {
}
Message::VideoChanged => debug!("vid shoulda changed"),
Message::UpdatePresentation(presentation) => {
let Some((kind, index)) = self.editing_item else {
let Some((kind, _index)) = self.editing_item else {
error!("Not editing an item");
return Action::None;
};
@ -600,6 +637,14 @@ impl<'a> Library {
error!("Not editing a presentation item");
return Action::None;
}
let index = self
.presentation_library
.items
.iter()
.position(|pres| pres.id == presentation.id)
.unwrap_or_default()
.try_into()
.unwrap_or_default();
match self
.presentation_library
@ -652,6 +697,7 @@ impl<'a> Library {
Message::AddFiles(items) => {
let mut tasks = Vec::new();
let last_item = &items.last();
let after_task = match last_item {
Some(ServiceItemKind::Image(_image)) => {
Task::done(Message::OpenItem(Some((
@ -896,6 +942,7 @@ impl<'a> Library {
container(library_dnd).padding(2).into()
}
#[allow(clippy::too_many_lines)]
pub fn library_item<T>(
&'a self,
model: &'a Model<T>,
@ -953,18 +1000,28 @@ impl<'a> Library {
.style(|t| {
container::Style::default()
.background({
match self.library_hovered {
Some(lib) => Background::Color(
if lib == model.kind {
t.cosmic().button.hover.into()
} else {
t.cosmic().button.base.into()
},
),
None => Background::Color(
t.cosmic().button.base.into(),
),
}
self.library_hovered.map_or_else(
|| {
Background::Color(
t.cosmic().button.base.into(),
)
},
|library| {
Background::Color(
if library == model.kind {
t.cosmic()
.button
.hover
.into()
} else {
t.cosmic()
.button
.base
.into()
},
)
},
)
})
.border(Border::default().rounded(
t.cosmic().corner_radii.radius_s,
@ -987,6 +1044,7 @@ impl<'a> Library {
column({
model.items.iter().enumerate().map(
|(index, item)| {
let i32_index = i32::try_from(index).expect("shouldn't be negative");
let kind = model.kind;
let visual_item = self
.single_item(index, item, model)
@ -997,21 +1055,21 @@ impl<'a> Library {
let mouse_area = mouse_area.on_enter(Message::HoverItem(
Some((
model.kind,
index as i32,
i32_index ,
)),
))
.on_double_click(
Message::OpenItem(Some((
model.kind,
index as i32,
i32_index,
))),
)
.on_right_press(Message::OpenContext(index as i32))
.on_right_press(Message::OpenContext(i32_index ))
.on_exit(Message::HoverItem(None))
.on_press(Message::SelectItem(
Some((
model.kind,
index as i32,
i32_index,
)),
));
@ -1038,7 +1096,7 @@ impl<'a> Library {
)
}})
.drag_content(move || {
KindWrapper((kind, index as i32))
KindWrapper((kind, i32_index))
})
.into()
},
@ -1065,6 +1123,7 @@ impl<'a> Library {
column![library_button, lib_container].into()
}
#[allow(clippy::too_many_lines)]
fn single_item<T>(
&'a self,
index: usize,
@ -1084,30 +1143,28 @@ impl<'a> Library {
.center_x(Length::Fill);
let subtext = container(responsive(move |size| {
let color: Color = if item.background().is_some() {
if let Some(items) = &self.selected_items {
if items.contains(&(model.kind, index as i32)) {
theme::active().cosmic().control_0().into()
} else {
theme::active()
.cosmic()
.accent_text_color()
.into()
}
if let Some(items) = &self.selected_items
&& items.contains(&(
model.kind,
i32::try_from(index)
.expect("Should never be negative"),
))
{
theme::active().cosmic().control_0().into()
} else {
theme::active()
.cosmic()
.accent_text_color()
.into()
}
} else if let Some(items) = &self.selected_items {
if items.contains(&(model.kind, index as i32)) {
theme::active().cosmic().control_0().into()
} else {
theme::active()
.cosmic()
.destructive_text_color()
.into()
}
} else if let Some(items) = &self.selected_items
&& items.contains(&(
model.kind,
i32::try_from(index)
.expect("Should never be negative"),
))
{
theme::active().cosmic().control_0().into()
} else {
theme::active()
.cosmic()
@ -1135,15 +1192,16 @@ impl<'a> Library {
.style(move |t| {
container::Style::default()
.background(Background::Color(
if let Some(items) = &self.selected_items {
if items.contains(&(model.kind, index as i32))
{
if let Some(items) = &self.selected_items
&& let Ok(index) = i32::try_from(index)
{
if items.contains(&(model.kind, index)) {
t.cosmic().accent.selected.into()
} else if let Some((library, hovered)) =
self.hovered_item
{
if model.kind == library
&& hovered == index as i32
&& hovered == index
{
t.cosmic().button.hover.into()
} else {
@ -1154,10 +1212,9 @@ impl<'a> Library {
}
} else if let Some((library, hovered)) =
self.hovered_item
&& let Ok(index) = i32::try_from(index)
{
if model.kind == library
&& hovered == index as i32
{
if model.kind == library && hovered == index {
t.cosmic().button.hover.into()
} else {
t.cosmic().button.base.into()
@ -1209,45 +1266,36 @@ impl<'a> Library {
query: String,
) -> Vec<ServiceItemKind> {
let query = query.to_lowercase();
let mut items: Vec<ServiceItemKind> = self
let items = self
.song_library
.items
.clone()
.into_iter()
.iter()
.filter(|song| song.title.to_lowercase().contains(&query))
.map(ServiceItemKind::Song)
.collect();
let videos: Vec<ServiceItemKind> = self
.map(|song| ServiceItemKind::Song(song.clone()));
let videos = self
.video_library
.items
.clone()
.into_iter()
.iter()
.filter(|vid| vid.title.to_lowercase().contains(&query))
.map(ServiceItemKind::Video)
.collect();
let images: Vec<ServiceItemKind> = self
.map(|video| ServiceItemKind::Video(video.clone()));
let images = self
.image_library
.items
.clone()
.into_iter()
.iter()
.filter(|image| {
image.title.to_lowercase().contains(&query)
})
.map(ServiceItemKind::Image)
.collect();
let presentations: Vec<ServiceItemKind> = self
.map(|image| ServiceItemKind::Image(image.clone()));
let presentations = self
.presentation_library
.items
.clone()
.into_iter()
.iter()
.filter(|pres| pres.title.to_lowercase().contains(&query))
.map(ServiceItemKind::Presentation)
.collect();
items.extend(videos);
items.extend(images);
items.extend(presentations);
.map(|pres| ServiceItemKind::Presentation(pres.clone()));
let items = items.chain(videos);
let items = items.chain(images);
let items = items.chain(presentations);
let mut items: Vec<(usize, ServiceItemKind)> = items
.into_iter()
.map(|item| {
(
levenshtein::distance(
@ -1259,7 +1307,7 @@ impl<'a> Library {
})
.collect();
items.sort_by(|a, b| a.0.cmp(&b.0));
items.sort_by_key(|a| a.0);
items.into_iter().map(|item| item.1).collect()
}
@ -1288,13 +1336,14 @@ impl<'a> Library {
self.modifiers_pressed = modifiers;
}
#[allow(clippy::too_many_lines)]
fn delete_items(&mut self) -> Action {
// Need to make this function collect tasks to be run off of
// who should be deleted
let Some(items) = self.selected_items.as_mut() else {
return Action::None;
};
items.sort_by(|(_, index), (_, other)| index.cmp(other));
items.sort_by_key(|(_, index)| *index);
let tasks: Vec<Task<Message>> = items
.iter()
.rev()
@ -1482,14 +1531,20 @@ async fn add_presentations() -> Option<Vec<Presentation>> {
}
async fn add_db() -> Result<SqlitePool> {
let mut data = dirs::data_local_dir().unwrap();
let mut data = dirs::data_local_dir()
.expect("Should always find a data dir");
data.push("lumina");
data.push("library-db.sqlite3");
let mut db_url = String::from("sqlite://");
db_url.push_str(data.to_str().unwrap());
db_url.push_str(
data.to_str().expect("Should always be a file here"),
);
SqlitePool::connect(&db_url).await.into_diagnostic()
}
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::cast_possible_truncation)]
pub fn elide_text(text: impl AsRef<str>, width: f32) -> String {
const CHAR_SIZE: f32 = 8.0;
let text: String = text.as_ref().to_owned();
@ -1497,10 +1552,25 @@ pub fn elide_text(text: impl AsRef<str>, width: f32) -> String {
if text_length > width {
format!(
"{}...",
text.split_at(
if let Some((first, _second)) = text.split_at_checked(
((width / CHAR_SIZE) - 3.0).floor() as usize
)
.0
) {
first
} else if let Some((first, _second)) = text
.split_at_checked(
((width / CHAR_SIZE) - 5.0).floor() as usize
)
{
first
} else if let Some((first, _second)) = text
.split_at_checked(
((width / CHAR_SIZE) - 7.0).floor() as usize
)
{
first
} else {
&text
}
)
} else {
text

View file

@ -1,6 +1,6 @@
use crate::core::model::LibraryKind;
pub mod double_ended_slider;
// pub mod double_ended_slider;
pub mod image_editor;
pub mod library;
pub mod presentation_editor;

View file

@ -95,6 +95,7 @@ impl PresentationEditor {
context_menu_id: None,
}
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::ChangePresentation(presentation) => {
@ -122,7 +123,7 @@ impl PresentationEditor {
}
}
Message::ChangeTitle(title) => {
self.title = title.clone();
self.title.clone_from(&title);
if let Some(presentation) = &self.presentation {
let mut presentation = presentation.clone();
presentation.title = title;
@ -147,17 +148,17 @@ impl PresentationEditor {
let task = Task::perform(
pick_presentation(),
move |presentation_result| {
if let Ok(presentation) = presentation_result
{
let mut presentation =
Presentation::from(presentation);
presentation.id = presentation_id;
Message::ChangePresentationFile(
presentation,
)
} else {
Message::None
}
presentation_result.map_or(
Message::None,
|presentation| {
let mut presentation =
Presentation::from(presentation);
presentation.id = presentation_id;
Message::ChangePresentationFile(
presentation,
)
},
)
},
);
return Action::Task(task);
@ -191,21 +192,22 @@ impl PresentationEditor {
}
}
Message::AddSlides(slides) => {
debug!(?slides);
self.slides = slides;
}
Message::None => (),
Message::NextPage => {
let next_index =
self.current_slide_index.unwrap_or_default() + 1;
let mut last_index =
self.page_count.unwrap_or_default();
if let Some(presentation) = self.presentation.as_ref()
let last_index = if let Some(presentation) =
self.presentation.as_ref()
&& let PresKind::Pdf { ending_index, .. } =
presentation.kind
{
last_index = ending_index;
}
ending_index
} else {
self.page_count.unwrap_or_default()
};
if next_index > last_index {
return Action::None;
@ -241,14 +243,16 @@ impl PresentationEditor {
Message::PrevPage => {
let previous_index =
self.current_slide_index.unwrap_or_default() - 1;
let mut first_index =
self.page_count.unwrap_or_default();
if let Some(presentation) = self.presentation.as_ref()
let first_index = if let Some(presentation) =
self.presentation.as_ref()
&& let PresKind::Pdf { starting_index, .. } =
presentation.kind
{
first_index = starting_index;
}
starting_index
} else {
self.page_count.unwrap_or_default()
};
if previous_index < first_index {
return Action::None;
@ -279,8 +283,9 @@ impl PresentationEditor {
Message::ChangeSlide(index) => {
self.current_slide =
self.document.as_ref().and_then(|doc| {
let page =
doc.load_page(index as i32).ok()?;
let page = doc
.load_page(i32::try_from(index).ok()?)
.ok()?;
let matrix = Matrix::IDENTITY;
let colorspace = Colorspace::device_rgb();
let pixmap = page
@ -298,16 +303,17 @@ impl PresentationEditor {
pixmap.samples().to_vec(),
))
});
self.current_slide_index = Some(index as i32);
self.current_slide_index = i32::try_from(index).ok();
}
Message::HoverSlide(slide) => {
self.hovered_slide = slide;
}
Message::ContextMenu(index) => {
self.context_menu_id = Some(index as i32);
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,
@ -316,6 +322,7 @@ impl PresentationEditor {
}
Message::SplitAfter => {
if let Ok((first, second)) = self.split_after() {
debug!(?first, ?second);
self.update_entire_presentation(&first);
return Action::SplitAddPresentation((
first, second,
@ -327,66 +334,74 @@ impl PresentationEditor {
}
pub fn view(&self) -> Element<Message> {
let presentation = if let Some(slide) = &self.current_slide {
container(
widget::image(slide)
.content_fit(ContentFit::ScaleDown),
)
.style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
})
} else {
container(Space::new(0, 0))
};
let pdf_pages: Vec<Element<Message>> = if let Some(pages) =
&self.slides
{
pages
.iter()
.enumerate()
.map(|(index, page)| {
let image = widget::image(page)
.height(theme::spacing().space_xxxl * 3)
.content_fit(ContentFit::ScaleDown);
let slide = container(image).style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
});
let clickable_slide = container(
mouse_area(slide)
.on_enter(Message::HoverSlide(Some(
index as i32,
)))
.on_exit(Message::HoverSlide(None))
.on_right_press(Message::ContextMenu(
index,
))
.on_press(Message::ChangeSlide(index)),
)
.padding(theme::spacing().space_m)
.clip(true)
.class(
if let Some(hovered_index) =
self.hovered_slide
{
if index as i32 == hovered_index {
theme::Container::Primary
} else {
theme::Container::Card
}
} else {
theme::Container::Card
},
);
clickable_slide.into()
let presentation = self.current_slide.as_ref().map_or_else(
|| container(Space::new(0, 0)),
|slide| {
container(
widget::image(slide)
.content_fit(ContentFit::ScaleDown),
)
.style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
})
.collect()
} else {
vec![horizontal_space().into()]
};
},
);
let pdf_pages: Vec<Element<Message>> =
self.slides.as_ref().map_or_else(
|| vec![horizontal_space().into()],
|pages| {
pages
.iter()
.enumerate()
.map(|(index, page)| {
let image = widget::image(page)
.height(
theme::spacing().space_xxxl * 3,
)
.content_fit(ContentFit::ScaleDown);
let slide = container(image).style(|_| {
container::background(Background::Color(
cosmic::iced::Color::WHITE,
))
});
let clickable_slide = container(
mouse_area(slide)
.on_enter(Message::HoverSlide(
i32::try_from(index).ok(),
))
.on_exit(Message::HoverSlide(
None,
))
.on_right_press(
Message::ContextMenu(index),
)
.on_press(Message::ChangeSlide(
index,
)),
)
.padding(theme::spacing().space_m)
.clip(true)
.class(self.hovered_slide.map_or(
theme::Container::Card,
|hovered_index| {
if i32::try_from(index).is_ok_and(
|index| {
index == hovered_index
},
) {
theme::Container::Primary
} else {
theme::Container::Card
}
},
));
clickable_slide.into()
})
.collect()
},
);
let pages_column = container(
self.context_menu(
scrollable(
@ -416,13 +431,18 @@ impl PresentationEditor {
}
fn toolbar(&self) -> Element<Message> {
let title_box = text_input("Title...", &self.title)
.on_input(Message::ChangeTitle);
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("Presentation")
.label("Change Presentation")
.tooltip("Select a presentation")
.on_press(Message::PickPresentation)
.padding(10);
@ -485,7 +505,7 @@ impl PresentationEditor {
presentation: &Presentation,
) {
self.presentation = Some(presentation.clone());
self.title = presentation.title.clone();
self.title.clone_from(&presentation.title);
self.document =
Document::open(&presentation.path.as_path()).ok();
self.page_count = self
@ -493,21 +513,51 @@ impl PresentationEditor {
.as_ref()
.and_then(|doc| doc.page_count().ok());
warn!("changing presentation");
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()?;
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(0);
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)> {
@ -533,7 +583,10 @@ impl PresentationEditor {
};
let second_presentation = Presentation {
id: 0,
title: current_presentation.title.clone(),
title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => {
@ -577,7 +630,10 @@ impl PresentationEditor {
};
let second_presentation = Presentation {
id: 0,
title: current_presentation.title.clone(),
title: format!(
"{} (2)",
current_presentation.title.clone()
),
path: current_presentation.path.clone(),
kind: match current_presentation.kind {
PresKind::Pdf { ending_index, .. } => {
@ -615,7 +671,9 @@ fn get_pages(
pages
.enumerate()
.filter_map(|(index, page)| {
if !range.contains(&(index as i32)) {
if !range.contains(&i32::try_from(index).expect(
"looking for a pdf index that is way too large",
)) {
return None;
}
let page = page.ok()?;
@ -649,7 +707,9 @@ async fn pick_presentation() -> Result<PathBuf, PresentationError> {
error!(?e);
PresentationError::DialogClosed
})
.map(|file| file.url().to_file_path().unwrap())
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(

View file

@ -27,6 +27,7 @@ use cosmic::{
menu, mouse_area, responsive, scrollable, text,
},
};
use derive_more::Debug;
use iced_video_player::{Position, Video, VideoPlayer, gst_pbutils};
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink};
use tracing::{debug, error, info, warn};
@ -41,7 +42,7 @@ use crate::{
},
};
const REFERENCE_WIDTH: f32 = 1920.0;
// const REFERENCE_WIDTH: f32 = 1920.0;
static DEFAULT_SLIDE: LazyLock<Slide> = LazyLock::new(Slide::default);
// #[derive(Default, Clone, Debug)]
@ -50,7 +51,6 @@ pub(crate) struct Presenter {
pub current_slide: Slide,
pub current_item: usize,
pub current_slide_index: usize,
pub absolute_slide_index: usize,
pub total_slides: usize,
pub video: Option<Video>,
pub video_position: f32,
@ -74,7 +74,8 @@ pub(crate) enum Action {
None,
}
#[derive(Clone)]
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug)]
pub(crate) enum Message {
NextSlide,
PrevSlide,
@ -83,7 +84,7 @@ pub(crate) enum Message {
ClickSlide(usize, usize),
EndVideo,
StartVideo,
StartAudio,
// StartAudio,
EndAudio,
VideoPos(f32),
VideoFrame,
@ -95,79 +96,19 @@ pub(crate) enum Message {
RightClickSlide(usize, usize),
AssignObsScene(usize),
UpdateObsScenes(Vec<Scene>),
#[debug("AddObsClient")]
AddObsClient(Arc<Client>),
AssignSlideAction(slide_actions::Action),
}
impl std::fmt::Debug for Message {
fn fmt(
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
match self {
Self::NextSlide => write!(f, "NextSlide"),
Self::PrevSlide => write!(f, "PrevSlide"),
Self::SlideChange(arg0) => {
f.debug_tuple("SlideChange").field(arg0).finish()
}
Self::ActivateSlide(arg0, arg1) => f
.debug_tuple("ActivateSlide")
.field(arg0)
.field(arg1)
.finish(),
Self::ClickSlide(arg0, arg1) => f
.debug_tuple("ClickSlide")
.field(arg0)
.field(arg1)
.finish(),
Self::EndVideo => write!(f, "EndVideo"),
Self::StartVideo => write!(f, "StartVideo"),
Self::StartAudio => write!(f, "StartAudio"),
Self::EndAudio => write!(f, "EndAudio"),
Self::VideoPos(arg0) => {
f.debug_tuple("VideoPos").field(arg0).finish()
}
Self::VideoFrame => write!(f, "VideoFrame"),
Self::MissingPlugin(arg0) => {
f.debug_tuple("MissingPlugin").field(arg0).finish()
}
Self::HoveredSlide(arg0) => {
f.debug_tuple("HoveredSlide").field(arg0).finish()
}
Self::ChangeFont(arg0) => {
f.debug_tuple("ChangeFont").field(arg0).finish()
}
Self::Error(arg0) => {
f.debug_tuple("Error").field(arg0).finish()
}
Self::None => write!(f, "None"),
Self::RightClickSlide(arg0, arg1) => f
.debug_tuple("RightClickSlide")
.field(arg0)
.field(arg1)
.finish(),
Self::AssignObsScene(arg0) => {
f.debug_tuple("ObsSceneAssign").field(arg0).finish()
}
Self::UpdateObsScenes(arg0) => {
f.debug_tuple("UpdateObsScenes").field(arg0).finish()
}
Self::AddObsClient(_) => write!(f, "AddObsClient"),
Self::AssignSlideAction(action) => f
.debug_tuple("AssignSlideAction")
.field(action)
.finish(),
}
}
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
ObsSceneAssign(usize),
ObsStartStream,
ObsStopStream,
ObsStartRecord,
ObsStopRecord,
// ObsStartRecord,
// ObsStopRecord,
}
impl menu::Action for MenuAction {
@ -188,14 +129,14 @@ impl menu::Action for MenuAction {
action: ObsAction::StopStream,
},
),
Self::ObsStartRecord => todo!(),
Self::ObsStopRecord => todo!(),
// Self::ObsStartRecord => todo!(),
// Self::ObsStopRecord => todo!(),
}
}
}
impl Presenter {
fn create_video(url: Url) -> Result<Video> {
fn create_video(url: &Url) -> Result<Video> {
// Based on `iced_video_player::Video::new`,
// but without a text sink so that the built-in subtitle functionality triggers.
use gstreamer as gst;
@ -218,16 +159,35 @@ impl Presenter {
let video_sink: gst::Element =
pipeline.property("video-sink");
let pad = video_sink.pads().first().cloned().unwrap();
let pad = pad.dynamic_cast::<gst::GhostPad>().unwrap();
let pad =
video_sink.pads().first().cloned().expect("first pad");
let pad = pad
.dynamic_cast::<gst::GhostPad>()
.map_err(|_| iced_video_player::Error::Cast)
.into_diagnostic()?;
let bin = pad
.parent_element()
.unwrap()
.ok_or_else(|| {
iced_video_player::Error::AppSink(String::from(
"Should have a parent element here",
))
})
.into_diagnostic()?
.downcast::<gst::Bin>()
.unwrap();
let video_sink = bin.by_name("lumina_video").unwrap();
let video_sink =
video_sink.downcast::<gst_app::AppSink>().unwrap();
.map_err(|_| iced_video_player::Error::Cast)
.into_diagnostic()?;
let video_sink = bin
.by_name("lumina_video")
.ok_or_else(|| {
iced_video_player::Error::AppSink(String::from(
"Can't find element lumina_video",
))
})
.into_diagnostic()?;
let video_sink = video_sink
.downcast::<gst_app::AppSink>()
.map_err(|_| iced_video_player::Error::Cast)
.into_diagnostic()?;
let result =
Video::from_gst_pipeline(pipeline, video_sink, None);
result.into_diagnostic()
@ -235,33 +195,29 @@ impl Presenter {
pub fn with_items(items: Vec<ServiceItem>) -> Self {
let video = {
if let Some(item) = items.first() {
if let Some(slide) = item.slides.first() {
items.first().and_then(|item| {
item.slides.first().and_then(|slide| {
let path = slide.background().path.clone();
if path.exists() {
let url = Url::from_file_path(path).unwrap();
let result = Video::new(&url);
match result {
Ok(mut v) => {
v.set_paused(true);
Some(v)
}
Err(e) => {
error!(
"Had an error creating the video object: {e}, likely the first slide isn't a video"
);
None
}
}
} else {
None
if !path.exists() {
return None;
}
} else {
None
}
} else {
None
}
let url = Url::from_file_path(path).expect(
"There should be a video file here",
);
match Video::new(&url) {
Ok(mut v) => {
v.set_paused(true);
Some(v)
}
Err(e) => {
error!(
"Had an error creating the video object: {e}, likely the first slide isn't a video"
);
None
}
}
})
})
};
let total_slides: usize =
items.iter().fold(0, |a, item| a + item.slides.len());
@ -281,7 +237,6 @@ impl Presenter {
current_slide: slide.unwrap_or(&DEFAULT_SLIDE).clone(),
current_item: 0,
current_slide_index: 0,
absolute_slide_index: 0,
total_slides,
video,
audio,
@ -308,6 +263,7 @@ impl Presenter {
}
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::AddObsClient(client) => {
@ -347,17 +303,16 @@ impl Presenter {
self.obs_scenes = Some(scenes);
}
Message::AssignObsScene(scene_index) => {
let slide_id = self.context_menu_id.expect("In this match we should always already have a context menu id");
let Some(scenes) = &self.obs_scenes else {
return Action::None;
};
let new_scene = &scenes[scene_index];
debug!(?scenes, ?new_scene, "updating obs actions");
if let Some(map) = self.slide_action_map.as_mut() {
if let Some(actions) = map.get_mut(
&self.context_menu_id.unwrap_or_default(),
) {
if let Some(actions) = map.get_mut(&slide_id) {
let mut altered_actions = vec![];
actions.iter_mut().for_each(|action| {
for action in actions.iter_mut() {
match action {
slide_actions::Action::Obs {
action: ObsAction::Scene { .. },
@ -371,7 +326,7 @@ impl Presenter {
_ => altered_actions
.push(action.to_owned()),
}
});
}
*actions = altered_actions;
debug!(
"updating the obs scene {:?}",
@ -379,7 +334,7 @@ impl Presenter {
);
} else if map
.insert(
self.context_menu_id.unwrap(),
slide_id,
vec![slide_actions::Action::Obs {
action: ObsAction::Scene {
scene: new_scene.clone(),
@ -401,7 +356,7 @@ impl Presenter {
} else {
let mut map = HashMap::new();
map.insert(
self.context_menu_id.unwrap(),
slide_id,
vec![slide_actions::Action::Obs {
action: ObsAction::Scene {
scene: new_scene.clone(),
@ -412,23 +367,16 @@ impl Presenter {
}
}
Message::AssignSlideAction(action) => {
let slide_id = self.context_menu_id.expect("In this match we should always already have a context menu id");
if let Some(map) = self.slide_action_map.as_mut() {
if let Some(actions) =
map.get_mut(&self.context_menu_id.unwrap())
{
if let Some(actions) = map.get_mut(&slide_id) {
actions.push(action);
} else {
map.insert(
self.context_menu_id.unwrap(),
vec![action],
);
map.insert(slide_id, vec![action]);
}
} else {
let mut map = HashMap::new();
map.insert(
self.context_menu_id.unwrap(),
vec![action],
);
map.insert(slide_id, vec![action]);
self.slide_action_map = Some(map);
}
}
@ -456,8 +404,10 @@ impl Presenter {
// self.current_slide_index = slide;
debug!("cloning slide...");
self.current_slide = slide.clone();
let _ =
self.update(Message::ChangeFont(slide.font()));
let font = slide
.font()
.map_or_else(String::new, |font| font.get_name());
let _ = self.update(Message::ChangeFont(font));
debug!("changing video now...");
if !backgrounds_match {
if let Some(video) = &mut self.video {
@ -490,6 +440,7 @@ impl Presenter {
debug!(target_item);
#[allow(clippy::cast_precision_loss)]
let offset = AbsoluteOffset {
x: {
if target_item > 2 {
@ -515,7 +466,7 @@ impl Presenter {
{
if let Some(stripped_audio) = new_audio
.to_str()
.unwrap()
.expect("Should be no problem")
.to_string()
.strip_prefix(r"file://")
{
@ -523,35 +474,8 @@ impl Presenter {
}
debug!("{:?}", new_audio);
if new_audio.exists() {
let old_audio = self.audio.clone();
match old_audio {
Some(current_audio)
if current_audio != *new_audio =>
{
debug!(
?new_audio,
?current_audio,
"audio needs to change"
);
self.audio = Some(new_audio);
tasks.push(self.start_audio());
}
Some(current_audio) => {
debug!(
?new_audio,
?current_audio,
"Same audio shouldn't change"
);
}
None => {
debug!(
?new_audio,
"could not find audio, need to change"
);
self.audio = Some(new_audio);
tasks.push(self.start_audio());
}
}
self.audio = Some(new_audio);
tasks.push(self.start_audio());
} else {
self.audio = None;
self.update(Message::EndAudio);
@ -650,7 +574,7 @@ impl Presenter {
Message::None
})
.await
.unwrap()
.expect("Spawning a task shouldn't fail")
},
|x| x,
));
@ -658,9 +582,9 @@ impl Presenter {
Message::HoveredSlide(slide) => {
self.hovered_slide = slide;
}
Message::StartAudio => {
return Action::Task(self.start_audio());
}
// Message::StartAudio => {
// return Action::Task(self.start_audio());
// }
Message::EndAudio => {
self.sink.0.stop();
}
@ -673,13 +597,24 @@ impl Presenter {
}
pub fn view(&self) -> Element<Message> {
slide_view(&self.current_slide, &self.video, false, true)
slide_view(
&self.current_slide,
self.video.as_ref(),
false,
true,
)
}
pub fn view_preview(&self) -> Element<Message> {
slide_view(&self.current_slide, &self.video, false, false)
slide_view(
&self.current_slide,
self.video.as_ref(),
false,
false,
)
}
#[allow(clippy::too_many_lines)]
pub fn preview_bar(&self) -> Element<Message> {
let mut items = vec![];
self.service.iter().enumerate().for_each(
@ -696,7 +631,7 @@ impl Presenter {
let container = slide_view(
slide,
&self.video,
self.video.as_ref(),
true,
false,
);
@ -735,18 +670,18 @@ impl Presenter {
style.shadow = Shadow {
color: Color::BLACK,
offset: {
if is_current_slide {
Vector::new(5.0, 5.0)
} else if hovered {
if is_current_slide
|| hovered
{
Vector::new(5.0, 5.0)
} else {
Vector::new(0.0, 0.0)
}
},
blur_radius: {
if is_current_slide {
10.0
} else if hovered {
if is_current_slide
|| hovered
{
10.0
} else {
0.0
@ -934,8 +869,9 @@ impl Presenter {
BackgroundKind::Video => {
let path = &self.current_slide.background().path;
if path.exists() {
let url = Url::from_file_path(path).unwrap();
let result = Self::create_video(url);
let url = Url::from_file_path(path)
.expect("There should be a video file here");
let result = Self::create_video(&url);
match result {
Ok(mut v) => {
v.set_looping(
@ -1012,41 +948,34 @@ impl Presenter {
}
}
#[allow(clippy::unused_async)]
async fn obs_scene_switch(client: Arc<Client>, scene: Scene) {
match client.scenes().set_current_program_scene(&scene.id).await {
Ok(()) => debug!("Set scene to: {:?}", scene),
Err(e) => error!(?e),
}
}
// #[allow(clippy::unused_async)]
// async fn obs_scene_switch(client: Arc<Client>, scene: Scene) {
// match client.scenes().set_current_program_scene(&scene.id).await {
// Ok(()) => debug!("Set scene to: {:?}", scene),
// Err(e) => error!(?e),
// }
// }
// This needs to be async so that rodio's audio will work
#[allow(clippy::unused_async)]
async fn start_audio(sink: Arc<Sink>, audio: PathBuf) {
debug!(?audio);
let file = BufReader::new(File::open(audio).unwrap());
let file = BufReader::new(
File::open(audio)
.expect("There should be an audio file here"),
);
debug!(?file);
let source = Decoder::new(file).unwrap();
let source = Decoder::new(file)
.expect("There should be an audio decoder here");
sink.append(source);
let empty = sink.empty();
let paused = sink.is_paused();
debug!(empty, paused, "Finished running");
}
fn scale_font(font_size: f32, width: f32) -> f32 {
let scale_factor = (REFERENCE_WIDTH / width).sqrt();
// debug!(scale_factor);
if font_size > 0.0 {
font_size / scale_factor
} else {
50.0
}
}
pub(crate) fn slide_view<'a>(
slide: &'a Slide,
video: &'a Option<Video>,
video: Option<&'a Video>,
delegate: bool,
hide_mouse: bool,
) -> Element<'a, Message> {
@ -1116,16 +1045,17 @@ pub(crate) fn slide_view<'a>(
}
}
BackgroundKind::Html => todo!(),
};
}
if let Some(text) = &slide.text_svg
&& let Some(handle) = &text.handle {
stack = stack.push(
image(handle)
.content_fit(ContentFit::ScaleDown)
.width(Length::Shrink)
.height(Length::Shrink),
);
};
&& let Some(handle) = &text.handle
{
stack = stack.push(
image(handle)
.content_fit(ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fill),
);
}
Container::new(stack).center(Length::Fill).into()
})
.into()

View file

@ -25,7 +25,7 @@ pub const fn service<Message: Clone + 'static>(
}
pub struct Service<'a, Message> {
service: &'a Vec<ServiceItem>,
items: &'a Vec<ServiceItem>,
on_start: Option<Message>,
on_cancelled: Option<Message>,
on_finish: Option<Message>,
@ -36,9 +36,9 @@ pub struct Service<'a, Message> {
impl<'a, Message: Clone + 'static> Service<'a, Message> {
#[must_use]
pub const fn new(service: &'a Vec<ServiceItem>) -> Self {
pub const fn new(service_items: &'a Vec<ServiceItem>) -> Self {
Self {
service,
items: service_items,
drag_threshold: 8.0,
on_start: None,
on_cancelled: None,
@ -93,11 +93,13 @@ impl<'a, Message: Clone + 'static> Service<'a, Message> {
// );
// }
#[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>,
@ -106,6 +108,7 @@ impl<'a, Message: Clone + 'static> Service<'a, Message> {
self
}
#[must_use]
pub fn on_finish(mut self, on_finish: Option<Message>) -> Self {
self.on_finish = on_finish;
self
@ -289,7 +292,7 @@ impl<Message: Clone + 'static>
_viewport: &Rectangle,
) {
// let state = tree.state.downcast_mut::<State>();
for _item in self.service {}
for _item in self.items {}
}
// fn overlay<'b>(
@ -341,7 +344,7 @@ struct State {
hovered: bool,
left_pressed_position: Option<Point>,
is_dragging: bool,
cached_bounds: Rectangle,
_cached_bounds: Rectangle,
}
impl State {

View file

@ -13,13 +13,13 @@ 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,
}
@ -35,11 +35,11 @@ pub enum Message {
}
pub struct Text {
text: String,
_text: String,
}
pub struct Image {
source: PathBuf,
_source: PathBuf,
}
pub enum SlideWidget {
@ -55,7 +55,7 @@ pub enum SlideError {
#[derive(Debug, Default)]
struct EditorProgram {
mouse_button_pressed: Option<cosmic::iced::mouse::Button>,
_mouse_button_pressed: Option<cosmic::iced::mouse::Button>,
}
impl SlideEditor {
@ -74,6 +74,7 @@ impl SlideEditor {
/// Ensure to use the `cosmic::Theme and cosmic::Renderer` here
/// or else it will not compile
#[allow(clippy::extra_unused_lifetimes)]
impl<'a> Program<SlideWidget, cosmic::Theme, cosmic::Renderer>
for EditorProgram
{

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,22 @@
use std::{
fmt::Display,
fmt::{Display, Write},
fs,
hash::{Hash, Hasher},
path::PathBuf,
sync::Arc,
};
use cosmic::{
cosmic_theme::palette::Srgb,
cosmic_theme::palette::{IntoColor, Srgb, rgb::Rgba},
iced::{
ContentFit, Length, Size,
font::{Style, Weight},
},
prelude::*,
widget::{Image, image::Handle},
widget::{Image, Space, image::Handle},
};
use miette::{IntoDiagnostic, Result};
use derive_more::Debug;
use miette::{IntoDiagnostic, Result, miette};
use rapidhash::v3::rapidhash_v3;
use resvg::{
tiny_skia::{self, Pixmap},
@ -23,9 +25,9 @@ use resvg::{
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::TextAlignment;
use crate::{TextAlignment, core::slide::Slide};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TextSvg {
text: String,
font: Font,
@ -33,7 +35,11 @@ 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>,
}
@ -46,6 +52,7 @@ impl PartialEq for TextSvg {
&& self.fill == other.fill
&& self.alignment == other.alignment
&& self.handle == other.handle
&& self.path == other.path
}
}
@ -57,10 +64,13 @@ 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)]
#[derive(
Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct Font {
name: String,
weight: Weight,
@ -142,16 +152,19 @@ impl Font {
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
@ -171,10 +184,12 @@ impl Hash for Color {
}
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();
@ -188,6 +203,19 @@ impl Color {
}
}
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)
@ -216,6 +244,7 @@ impl Display for Color {
}
impl TextSvg {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
@ -225,36 +254,43 @@ 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,
@ -263,29 +299,38 @@ impl TextSvg {
self
}
pub fn build(mut self) -> Self {
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::too_many_lines)]
pub fn build(
mut self,
size: Size,
mut cache: Option<PathBuf>,
) -> Self {
// debug!("starting...");
let mut path = dirs::data_local_dir().unwrap();
path.push(PathBuf::from("lumina"));
path.push(PathBuf::from("temp"));
let mut final_svg = String::with_capacity(1024);
let size = Size::new(1920.0, 1080.0);
let font_size = f32::from(self.font.size);
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, "990")
("middle", font_size, center_y.as_str())
}
TextAlignment::TopRight => {
("end", font_size, x_width_padded.as_str())
}
TextAlignment::TopRight => ("end", font_size, "1910"),
TextAlignment::MiddleLeft => {
let middle_position = size.height / 2.0;
let position = half_lines.mul_add(
@ -300,7 +345,7 @@ impl TextSvg {
-text_and_line_spacing,
middle_position,
);
("middle", position, "990")
("middle", position, center_y.as_str())
}
TextAlignment::MiddleRight => {
let middle_position = size.height / 2.0;
@ -308,38 +353,55 @@ impl TextSvg {
-text_and_line_spacing,
middle_position,
);
("end", position, "1910")
("end", position, x_width_padded.as_str())
}
TextAlignment::BottomLeft => {
let position = size.height
- (total_lines as f32
* text_and_line_spacing);
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("start", position, "10")
}
TextAlignment::BottomCenter => {
let position = size.height
- (total_lines as f32
* text_and_line_spacing);
("middle", position, "990")
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("middle", position, center_y.as_str())
}
TextAlignment::BottomRight => {
let position = size.height
- (total_lines as f32
* text_and_line_spacing);
("end", position, "1910")
let position = (total_lines as f32)
.mul_add(-text_and_line_spacing, size.height);
("end", position, x_width_padded.as_str())
}
};
final_svg.push_str(&format!("<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\"><defs>", size.width, size.height, size.width, size.height));
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 {
final_svg.push_str(&format!(
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>");
@ -348,32 +410,42 @@ impl TextSvg {
// "<style> text { letter-spacing: 0em; } </style>",
// );
final_svg.push_str(&format!("<text x=\"0\" y=\"50%\" transform=\"translate({}, 0)\" dominant-baseline=\"middle\" text-anchor=\"{}\" font-weight=\"bold\" font-family=\"{}\" font-size=\"{}\" fill=\"{}\" ", text_x_position, text_anchor, self.font.name, font_size, self.fill));
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 {
final_svg.push_str(&format!(
let _ = write!(
final_svg,
"stroke=\"{}\" stroke-width=\"{}px\" stroke-linejoin=\"arcs\" paint-order=\"stroke\"",
stroke.color, stroke.size
));
);
}
final_svg.push_str(" style=\"filter:url(#shadow);\">");
let text: String = self
.text
.lines()
.enumerate()
.map(|(index, text)| {
format!(
"<tspan x=\"0\" y=\"{}\">{}</tspan>",
(index as f32).mul_add(
text_and_line_spacing,
starting_y_position
),
text
)
})
.collect();
final_svg.push_str(&text);
if self.shadow.is_some() {
final_svg.push_str(" style=\"filter:url(#shadow);\"");
}
final_svg.push('>');
for (index, text) in self.text.lines().enumerate() {
let _ = write!(
final_svg,
"<tspan x=\"0\" y=\"{}\">{}</tspan>",
(index as f32).mul_add(
text_and_line_spacing,
starting_y_position
),
text
);
}
final_svg.push_str("</text></svg>");
@ -389,55 +461,72 @@ impl TextSvg {
// text
// ));
let hashed_title = rapidhash_v3(final_svg.as_bytes());
path.push(PathBuf::from(hashed_title.to_string()));
path.set_extension("png");
if let Some(path) = cache.as_mut() {
let hashed_title = rapidhash_v3(final_svg.as_bytes());
path.push(PathBuf::from(hashed_title.to_string()));
path.set_extension("png");
if path.exists() {
// debug!("cached");
let handle = Handle::from_path(path);
self.handle = Some(handle);
return self;
if path.exists() {
// debug!("cached");
let handle = Handle::from_path(&path);
self.path = Some(path.clone());
self.handle = Some(handle);
return self;
}
}
// debug!("text string built...");
let resvg_tree = Tree::from_data(
let Ok(resvg_tree) = Tree::from_data(
final_svg.as_bytes(),
&resvg::usvg::Options {
fontdb: Arc::clone(&self.fontdb),
..Default::default()
},
)
.expect("Woops mama");
) else {
error!("Couldn't parse the svg into a tree");
return self;
};
// debug!("parsed");
let transform = tiny_skia::Transform::default();
let mut pixmap =
Pixmap::new(size.width as u32, size.height as u32)
.expect("opops");
#[allow(clippy::cast_sign_loss)]
let (size_width, size_height) =
(size.width as u32, size.height as u32);
let Some(mut pixmap) = Pixmap::new(size_width, size_height)
else {
error!("Couldn't create a new pixmap from size");
return self;
};
resvg::render(&resvg_tree, transform, &mut pixmap.as_mut());
// debug!("rendered");
if let Err(e) = pixmap.save_png(&path) {
if let Some(path) = cache.as_ref()
&& let Err(e) = pixmap.save_png(path)
{
error!(?e, "Couldn't save a copy of the text");
}
self.path = cache;
// debug!("saved");
// let handle = Handle::from_path(path);
let handle = Handle::from_rgba(
size.width as u32,
size.height as u32,
pixmap.take(),
);
let handle =
Handle::from_rgba(size_width, size_height, pixmap.take());
self.handle = Some(handle);
// debug!("stored");
self
}
pub fn view<'a>(&self) -> Element<'a, Message> {
Image::new(self.handle.clone().unwrap())
.content_fit(ContentFit::Cover)
.width(Length::Fill)
.height(Length::Fill)
.into()
self.handle.clone().map_or_else(
|| Element::from(Space::new(Length::Fill, Length::Fill)),
|handle| {
Image::new(handle)
.content_fit(ContentFit::Cover)
.width(Length::Fill)
.height(Length::Fill)
.into()
},
)
}
}
@ -467,21 +556,91 @@ pub fn color(color: impl AsRef<str>) -> Color {
}
pub fn text_svg_generator(
slide: &mut crate::core::slide::Slide,
fontdb: Arc<fontdb::Database>,
) {
if !slide.text().is_empty() {
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("#fff")
.shadow(shadow(2, 2, 5, "#000000"))
.stroke(stroke(3, "#000"))
.font(
Font::from(slide.font())
.size(slide.font_size().try_into().unwrap()),
)
.fontdb(Arc::clone(&fontdb))
.build();
.fill(
slide.text_color().unwrap_or_else(|| "#fff".into()),
);
let text_svg = if let Some(stroke) = slide.stroke() {
text_svg.stroke(stroke)
} else {
text_svg
};
let text_svg = if let Some(shadow) = slide.shadow() {
text_svg.shadow(shadow)
} else {
text_svg
};
let text_svg = text_svg.font(font).fontdb(Arc::clone(fontdb));
// debug!(fill = ?text_svg.fill, font = ?text_svg.font, stroke = ?text_svg.stroke, shadow = ?text_svg.shadow, text = ?text_svg.text);
let text_svg =
text_svg.build(Size::new(1280.0, 720.0), cache);
slide.text_svg = Some(text_svg);
Ok(slide)
}
}
#[cfg(test)]
mod tests {
use crate::core::slide::Slide;
use super::*;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use resvg::usvg::fontdb::Database;
use tracing::debug;
#[test]
fn test_generator() {
let slide = Slide::default();
debug!("test");
let mut fontdb = Database::new();
fontdb.load_system_fonts();
let fontdb = Arc::new(fontdb);
(0..400).into_par_iter().for_each(|_| {
let slide = slide
.clone()
.set_font_size(120)
.set_font("")
.set_shadow(shadow(5, 5, 5, "#000"))
.set_stroke(stroke(9, "#000"))
.set_text("This is the first slide of text\nAnd we are singing\nTo save the world!");
match text_svg_generator_with_cache(
slide,
&fontdb,
None,
) {
Ok(slide) => {
assert!(
slide
.text_svg
.is_some_and(|svg| svg.handle.is_some())
)
},
Err(e) => assert!(false, "There was an issue creating the TextSvg: {e}"),
};
});
}
}

View file

@ -59,7 +59,7 @@ impl VideoEditor {
self.update_entire_video(&video);
}
Message::ChangeTitle(title) => {
self.title = title.clone();
self.title.clone_from(&title);
if let Some(video) = &self.core_video {
let mut video = video.clone();
video.title = title;
@ -89,14 +89,12 @@ impl VideoEditor {
let task = Task::perform(
pick_video(),
move |video_result| {
if let Ok(video) = video_result {
video_result.map_or(Message::None, |video| {
let mut video =
videos::Video::from(video);
video.id = video_id;
Message::UpdateVideoFile(video)
} else {
Message::None
}
})
},
);
return Action::Task(task);
@ -111,36 +109,35 @@ impl VideoEditor {
}
pub fn view(&self) -> Element<Message> {
let video_elements = if let Some(video) = &self.video {
let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start")
} else {
icon::from_name("media-playback-pause")
})
.on_press(Message::PauseVideo);
let video_track = progress_bar(
0.0..=video.duration().as_secs_f32(),
video.position().as_secs_f32(),
)
.height(cosmic::theme::spacing().space_s)
.width(Length::Fill);
container(
row![play_button, video_track]
.align_y(Vertical::Center)
.spacing(cosmic::theme::spacing().space_m),
)
.padding(cosmic::theme::spacing().space_s)
.center_x(Length::FillPortion(2))
} else {
container(horizontal_space())
};
let video_elements = self.video.as_ref().map_or_else(
|| container(horizontal_space()),
|video| {
let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start")
} else {
icon::from_name("media-playback-pause")
})
.on_press(Message::PauseVideo);
let video_track = progress_bar(
0.0..=video.duration().as_secs_f32(),
video.position().as_secs_f32(),
)
.height(cosmic::theme::spacing().space_s)
.width(Length::Fill);
container(
row![play_button, video_track]
.align_y(Vertical::Center)
.spacing(cosmic::theme::spacing().space_m),
)
.padding(cosmic::theme::spacing().space_s)
.center_x(Length::FillPortion(2))
},
);
let video_player = self
.video
.as_ref()
.map_or(Element::from(Space::new(0, 0)), |video| {
Element::from(VideoPlayer::new(video))
});
let video_player = self.video.as_ref().map_or_else(
|| Element::from(Space::new(0, 0)),
|video| Element::from(VideoPlayer::new(video)),
);
let video_section = column![video_player, video_elements]
.spacing(cosmic::theme::spacing().space_s);
@ -185,13 +182,13 @@ impl VideoEditor {
.map(|url| Video::new(&url).expect("Should be here"))
else {
self.video = None;
self.title = video.title.clone();
self.title.clone_from(&video.title);
self.core_video = Some(video.clone());
return;
};
player_video.set_paused(true);
self.video = Some(player_video);
self.title = video.title.clone();
self.title.clone_from(&video.title);
self.core_video = Some(video.clone());
}
}
@ -217,7 +214,9 @@ async fn pick_video() -> Result<PathBuf, VideoError> {
error!(?e);
VideoError::DialogClosed
})
.map(|file| file.url().to_file_path().unwrap())
.map(|file| {
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new()
// .set_title("Choose a background...")
// .add_filter(

View file

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

View file

@ -8,7 +8,7 @@ use cosmic::iced_wgpu::Primitive;
use cosmic::iced_wgpu::primitive::Renderer as PrimitiveRenderer;
pub struct SlideText {
text: String,
_text: String,
font_size: f32,
}
@ -16,7 +16,7 @@ impl SlideText {
pub fn new(text: impl AsRef<str>) -> Self {
let text = text.as_ref();
Self {
text: text.to_string(),
_text: text.to_string(),
font_size: 50.0,
}
}
@ -88,8 +88,8 @@ where
#[derive(Debug, Clone)]
pub(crate) struct TextPrimitive {
text_id: u64,
size: (u32, u32),
_text_id: u64,
_size: (u32, u32),
}
impl TextPrimitive {

View file

@ -1,12 +1,12 @@
use cosmic::{
Element, Task,
cosmic_theme::palette::WithAlpha,
iced::{Background, Border, Color, Point},
iced::{Background, Border},
iced_widget::{column, row},
theme,
widget::{
button, combo_box, container, horizontal_space, icon, text,
text_editor, text_input,
button, combo_box, container, horizontal_space, icon,
text_editor,
},
};
@ -35,16 +35,17 @@ pub enum Action {
UpdateVerse((VerseName, String)),
UpdateVerseName(String),
DeleteVerse(VerseName),
ScrollVerses(f32),
None,
}
impl VerseEditor {
#[must_use]
pub fn new(verse: VerseName, lyric: String) -> Self {
pub fn new(verse: VerseName, lyric: &str) -> Self {
Self {
verse_name: verse,
lyric: lyric.clone(),
content: text_editor::Content::with_text(&lyric),
lyric: lyric.to_string(),
content: text_editor::Content::with_text(lyric),
editing_verse_name: false,
verse_name_combo: combo_box::State::new(
VerseName::all_names(),
@ -53,18 +54,27 @@ impl VerseEditor {
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::UpdateLyric(action) => {
self.content.perform(action.clone());
match action {
text_editor::Action::Edit(_edit) => {
let lyrics = self.content.text();
self.lyric = lyrics.clone();
let verse = self.verse_name;
Action::UpdateVerse((verse, lyrics))
}
_ => Action::None,
Message::UpdateLyric(action) => match action {
text_editor::Action::Edit(ref _edit) => {
self.content.perform(action);
let lyrics = self.content.text();
self.lyric.clone_from(&lyrics);
let verse = self.verse_name;
Action::UpdateVerse((verse, lyrics))
}
}
text_editor::Action::Scroll { pixels } => {
if self.content.line_count() > 6 {
self.content.perform(action);
Action::None
} else {
Action::ScrollVerses(pixels)
}
}
_ => {
self.content.perform(action);
Action::None
}
},
Message::UpdateVerseName(verse_name) => {
Action::UpdateVerseName(verse_name)
}
@ -79,7 +89,7 @@ impl VerseEditor {
pub fn view(&self) -> Element<Message> {
let cosmic::cosmic_theme::Spacing {
space_xxs,
space_xxs: _,
space_s,
space_m,
..
@ -142,11 +152,8 @@ impl VerseEditor {
.color(t.cosmic().accent.hover);
match s {
text_editor::Status::Active => base_style,
text_editor::Status::Hovered => {
base_style.border = hovered_border;
base_style
}
text_editor::Status::Focused => {
text_editor::Status::Hovered
| text_editor::Status::Focused => {
base_style.border = hovered_border;
base_style
}
@ -165,13 +172,4 @@ impl VerseEditor {
.class(theme::Container::Card)
.into()
}
// TODO not done yet. This doesn't work, need to find a way to either reset the
// cursor position or not make new widgets
pub fn set_cursor_position(&mut self, position: (usize, usize)) {
self.content.perform(text_editor::Action::Click(Point::new(
position.0 as f32,
position.1 as f32,
)));
}
}

BIN
test.db Normal file

Binary file not shown.

View file

@ -6,10 +6,15 @@
kind: Image
),
text: "This is Frodo",
font: "Quicksand",
font_size: 50,
stroke_size: 0,
stroke_color: None,
font: Some(Font(
name: "Quicksand",
weight: Normal,
style: Normal,
size: 130,
)),
font_size: 130,
stroke: None,
shadow: None,
text_alignment: MiddleCenter,
video_loop: false,
video_start_time: 0.0,
@ -24,10 +29,15 @@
kind: Video
),
text: "This is Frodo",
font: "Quicksand",
font_size: 50,
stroke_size: 0,
stroke_color: None,
font: Some(Font(
name: "Quicksand",
weight: Normal,
style: Normal,
size: 130,
)),
font_size: 130,
stroke: None,
shadow: None,
text_alignment: MiddleCenter,
video_loop: false,
video_start_time: 0.0,