Compare commits

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

231 commits

Author SHA1 Message Date
f384f6bec5 [work] trying to fix the scroll when changing slides from keyboard
Some checks failed
/ clippy (push) Failing after 10m22s
/ test (push) Failing after 9m4s
2026-06-02 10:26:20 -05:00
075eed809a [work] update todo 2026-06-02 10:26:09 -05:00
e67efe3bd6 [work] update todo 2026-05-30 15:19:31 -05:00
95de215e2c [fix] better width for preview of slide 2026-05-30 15:13:43 -05:00
4479a11dff [feat] filtering and work for sorting
Some checks failed
/ clippy (push) Failing after 26m51s
/ test (push) Failing after 8m58s
2026-05-29 14:33:20 -05:00
a05b918729 [fix] smaller dividers and longer titles if more slides
Some checks failed
/ clippy (push) Failing after 6m18s
/ test (push) Failing after 6m33s
2026-05-28 12:30:57 -05:00
da2e3c6267 [fix] some ui issues with previews
Some checks failed
/ test (push) Failing after 6m35s
/ clippy (push) Failing after 20m7s
2026-05-28 12:15:20 -05:00
2f5d7b4d15 [work] adding an access and created_at metadata to items for sorting
Some checks failed
/ clippy (push) Failing after 6m56s
/ test (push) Failing after 11m59s
2026-05-27 16:50:37 -05:00
0a77ead382 [fix] context menu not disappearing after selecting an option
Some checks failed
/ clippy (push) Failing after 6m17s
/ test (push) Failing after 6m31s
2026-05-27 10:26:56 -05:00
784e13b6ac [fix] Right click menu in service and library
Some checks failed
/ clippy (push) Failing after 6m32s
/ test (push) Failing after 6m38s
2026-05-27 10:05:07 -05:00
f746227ae2 [chore] update todo 2026-05-27 10:04:57 -05:00
f0d0fed79b [chore] update todo
Some checks failed
/ clippy (push) Failing after 6m47s
/ test (push) Failing after 6m18s
2026-05-21 06:45:27 -05:00
478e6eb86b [work] switch to a multi executor 2026-05-21 06:45:04 -05:00
f6879d7c2d [fix] better spacing and logic for footer messages
Some checks failed
/ clippy (push) Failing after 6m47s
/ test (push) Failing after 6m24s
2026-05-20 10:12:14 -05:00
ecbb5bda42 [feat] footer_messages with saving reports
Some checks failed
/ clippy (push) Failing after 6m22s
/ test (push) Failing after 6m43s
2026-05-19 15:46:27 -05:00
40fc176e77 [feat] recent_files state is kept and the most recent gets loaded
Some checks failed
/ clippy (push) Failing after 6m15s
/ test (push) Failing after 6m16s
2026-05-19 15:16:52 -05:00
bac1948f09 [fix] make item mutable
Some checks failed
/ clippy (push) Failing after 6m36s
/ test (push) Failing after 6m20s
2026-05-19 09:19:15 -05:00
ed048f20ec [fix] ensure that text is generated always when adding 2026-05-19 09:15:15 -05:00
118da387c9 [chore] formatting
Some checks failed
/ clippy (push) Failing after 6m8s
/ test (push) Has been cancelled
2026-05-19 09:12:08 -05:00
7e7e321091 [fix] thumbnails not working on loading of service 2026-05-19 09:11:48 -05:00
476f85b673 [chore] update todo
Some checks failed
/ clippy (push) Failing after 6m18s
/ test (push) Failing after 6m20s
2026-05-19 06:25:52 -05:00
57dc64c900 [fix] test using wrong file
Need to fix these so they can use some test files in CI
2026-05-19 06:25:24 -05:00
1844ad83b3 [fix] use shorter name for font size
Some checks failed
/ clippy (push) Failing after 6m24s
/ test (push) Failing after 7m1s
2026-05-18 14:53:26 -05:00
2f368bff41 [work] do not clone the song
Some checks failed
/ clippy (push) Failing after 6m19s
/ test (push) Failing after 6m24s
2026-05-18 14:23:33 -05:00
51619d83ce [chore] remove debug messages
Some checks failed
/ clippy (push) Failing after 6m19s
/ test (push) Failing after 6m26s
2026-05-18 13:33:38 -05:00
f4fcaa75f8 [fix] song changes don't remove old slides and verses
Some checks failed
/ clippy (push) Failing after 9m6s
/ test (push) Failing after 6m35s
2026-05-18 12:11:25 -05:00
780662b712 [fix] title and author not changing when switching songs
Some checks failed
/ clippy (push) Failing after 8m31s
/ test (push) Failing after 6m31s
2026-05-18 11:40:37 -05:00
1617dbe694 [fix] loading songs not loading some blank songs
Some checks failed
/ clippy (push) Failing after 6m10s
/ test (push) Has been cancelled
2026-05-18 11:30:51 -05:00
2081eb2720 [fix] better ui for new songs
Some checks failed
/ clippy (push) Failing after 8m52s
/ test (push) Has been cancelled
2026-05-18 11:13:05 -05:00
47d15f4ba8 [fix] video slider not working on preview screen
Some checks failed
/ clippy (push) Failing after 6m32s
/ test (push) Failing after 6m32s
2026-05-14 14:13:29 -05:00
4ef31a7b47 [fix] use the same format of time
Some checks failed
/ clippy (push) Failing after 6m14s
/ test (push) Has been cancelled
2026-05-14 14:00:35 -05:00
63c0774292 [fix] add all pathed icons to svg_bytes
Some checks failed
/ clippy (push) Failing after 6m54s
/ test (push) Failing after 7m15s
2026-05-14 11:38:27 -05:00
2109193bc1 [fix] move icons into binary so they be reachable no matter the OS
Some checks failed
/ clippy (push) Failing after 6m14s
/ test (push) Failing after 6m17s
2026-05-14 06:39:21 -05:00
c0b852d592 [chore] updates 2026-05-14 06:39:07 -05:00
6da55a33dd [grr] just try this instead
Some checks failed
/ clippy (push) Failing after 6m31s
/ test (push) Failing after 6m59s
2026-05-12 15:16:29 -05:00
ec6d43e94f [wow] grr
Some checks failed
/ clippy (push) Failing after 6m53s
/ test (push) Has been cancelled
2026-05-12 15:08:08 -05:00
bf7ba902a7 [work] trying to find working directory
Some checks failed
/ clippy (push) Failing after 5m30s
/ test (push) Failing after 5m23s
2026-05-12 14:56:31 -05:00
b203e29087 [fix] uhoh
Some checks failed
/ clippy (push) Failing after 6m54s
/ test (push) Failing after 7m11s
2026-05-12 14:32:58 -05:00
6c70cb5371 [fix] typo
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-12 14:27:21 -05:00
67cc530f84 [fix] macos icons?
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-12 14:26:00 -05:00
c5f0a1a3ee [AHA] aha?
Some checks failed
/ clippy (push) Failing after 7m21s
/ test (push) Failing after 6m54s
2026-05-12 13:58:06 -05:00
44502c8421 [work] try this
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-12 13:53:01 -05:00
cc5364973a [work] try to setup runtime linking to the framework
Some checks failed
/ clippy (push) Failing after 6m20s
/ test (push) Has been cancelled
2026-05-12 13:45:32 -05:00
2131e0cea2 [thegrr]
Some checks failed
/ clippy (push) Failing after 6m16s
/ test (push) Failing after 6m29s
2026-05-12 13:30:56 -05:00
3e144cc4ec [grrr] add back build script
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-12 13:25:28 -05:00
363747dd5c [chore] setup macos packager commands
Some checks failed
/ clippy (push) Failing after 6m27s
/ test (push) Failing after 6m33s
2026-05-12 12:17:27 -05:00
b7383c387a [ugh] remove build script
Some checks failed
/ clippy (push) Failing after 6m17s
/ test (push) Has been cancelled
2026-05-12 12:06:56 -05:00
5529fda45b [chore] add macos build script
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-12 12:02:58 -05:00
8c4f24751c [work] update deps
Some checks failed
/ clippy (push) Failing after 6m47s
/ test (push) Failing after 6m29s
2026-05-12 10:42:37 -05:00
a0b6638a46 [fix] Not using sign_command right
Some checks failed
/ clippy (push) Failing after 6m2s
/ test (push) Failing after 6m38s
2026-05-10 14:04:00 -05:00
ea0019f7ed [work] add sign_command
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-10 14:01:58 -05:00
f2e59b03af [work] try to fix gstreamer detection
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-10 13:59:50 -05:00
8a0e6e1814 [chore] update todo with cert info
Some checks failed
/ clippy (push) Failing after 6m4s
/ test (push) Failing after 6m18s
2026-05-10 07:19:03 -05:00
85f56efebe [fix] windows_subsystem
Some checks failed
/ clippy (push) Failing after 6m37s
/ test (push) Failing after 6m24s
2026-05-10 06:48:56 -05:00
9841601605 [fix] don't log on windows
Some checks failed
/ clippy (push) Failing after 6m32s
/ test (push) Failing after 6m39s
2026-05-09 07:22:47 -05:00
1f9f152dae try adding install from flathub and extensions 2026-05-09 07:22:47 -05:00
a5b3918d7f [fix] desktop file using old cli layout
Some checks failed
/ clippy (push) Failing after 6m23s
/ test (push) Failing after 6m35s
2026-05-08 15:50:39 -05:00
9dadf8bb92 [chore] add help to cli command
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 15:44:22 -05:00
88706b4198 [fix] incorrect command line layout
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 15:41:16 -05:00
3d22cf0020 [grr] grrrrr
Some checks failed
/ clippy (push) Failing after 6m16s
/ test (push) Failing after 6m30s
2026-05-08 10:00:06 -05:00
478e6275ed [chore] try to fix flatpak builds
Some checks failed
/ clippy (push) Failing after 6m23s
/ test (push) Failing after 6m30s
2026-05-08 07:23:09 -05:00
1221a8197f [chore] rename icon
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 07:22:17 -05:00
756de3357c [fix] incorrect installer_mode key
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 07:19:41 -05:00
090f604bde [fix] missing commas
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 07:18:39 -05:00
3ad8ad4771 [chore] fix install mode to be default of perMachine
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-08 07:17:53 -05:00
c7a1c9c96b [chore] tweaks to packager pieces
Some checks failed
/ clippy (push) Failing after 1m59s
/ test (push) Failing after 1m51s
2026-05-08 07:13:40 -05:00
7d5a17c765 [chore]: tweaks to flatpak stuff
Some checks failed
/ clippy (push) Failing after 6m6s
/ test (push) Failing after 6m33s
2026-05-07 15:25:51 -05:00
755f00dd04 [chore]: trying to fix flapak install
Some checks failed
/ clippy (push) Failing after 6m36s
/ test (push) Failing after 6m49s
2026-05-07 12:05:14 -05:00
3597f13c55 [fix]: oops use stable not nightly
Some checks failed
/ clippy (push) Failing after 6m15s
/ test (push) Failing after 16m25s
2026-05-07 09:52:32 -05:00
433396fcf2 [chore]: flatpaks build now, need to finish website for lumina 2026-05-07 09:51:35 -05:00
f8a7f07a24 [chore]: add unzip for building mupdf 2026-05-07 09:51:22 -05:00
b5d9a06387 [idk]
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-07 09:50:03 -05:00
38dadc9150 [chore]: try to build a windows packaging pipeline
Some checks failed
/ clippy (push) Failing after 7m33s
/ test (push) Failing after 6m58s
2026-05-05 14:07:57 -05:00
f7d6b6be96 [fix]: tweak mouse interaction logic in draggable::flex_row
Some checks failed
/ clippy (push) Failing after 12m49s
/ test (push) Failing after 7m12s
We need to show the content interaction first, unless dragging, and by
default uses no interaction so developer can choose which to really show.
2026-05-05 11:29:13 -05:00
11739283f8 [fix]: again... more icons 2026-05-05 11:29:02 -05:00
23aa0e1751 [fix]: broken icon
Some checks failed
/ clippy (push) Failing after 6m50s
/ test (push) Has been cancelled
2026-05-05 11:18:10 -05:00
5f5e654faa [fix]: Again, more icons....
Some checks failed
/ clippy (push) Failing after 7m9s
/ test (push) Has been cancelled
2026-05-05 11:06:10 -05:00
72767336ab [fix]: use panning icons instead
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-05 11:03:36 -05:00
487d7c5f4d [fix]: more icons in song_editor
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-05 10:58:21 -05:00
821382ef7b [chore]: update todo 2026-05-05 10:38:34 -05:00
a713907e49 [chore]: more work for packaging flatpaks
Some checks failed
/ clippy (push) Failing after 6m53s
/ test (push) Failing after 7m2s
2026-05-05 09:49:00 -05:00
b72b115bcc [chore]: setting up flatpak building commands 2026-05-04 09:29:14 -05:00
24708826fe [chore]: fixing some more icons 2026-05-04 09:28:58 -05:00
c250b3f505 [chore]: reorganize icons
Some checks failed
/ clippy (push) Failing after 7m1s
/ test (push) Failing after 7m33s
2026-05-03 14:04:48 -05:00
f61087e3b7 [chore]: update images
Some checks failed
/ clippy (push) Failing after 5m47s
/ test (push) Failing after 6m21s
2026-05-03 08:25:40 -05:00
4fb58a3d71 [chore]: update images
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-03 08:22:00 -05:00
53b9bb4857 [feat]: reordering song verses now uses draggable flex_row
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-03 08:16:09 -05:00
406233e95b [fix]: making alignment icons work on other platforms too
Some checks failed
/ clippy (push) Failing after 6m8s
/ test (push) Failing after 6m38s
2026-05-01 17:06:05 -05:00
5243b48e3d [fix]: use more extensions for bg
Some checks failed
/ clippy (push) Failing after 6m13s
/ test (push) Failing after 10m21s
2026-05-01 10:43:30 -05:00
9490e61e1f [fix]: fixing a lot more icons
Some checks failed
/ clippy (push) Failing after 6m7s
/ test (push) Has been cancelled
2026-05-01 10:32:10 -05:00
65b619d571 [fix]: Use more symbolic icons from cosmic-icons 2026-05-01 10:01:14 -05:00
53f1eef235 [fix]: remove noisy debug info
Some checks failed
/ clippy (push) Failing after 5m57s
/ test (push) Failing after 6m29s
2026-05-01 07:19:37 -05:00
da5b8d2710 [fix]: some more icons
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-01 07:16:27 -05:00
c4c869d4a3 [work]: try buttons without size
Some checks failed
/ clippy (push) Failing after 6m7s
/ test (push) Failing after 6m37s
2026-05-01 06:48:43 -05:00
5a0cad95a0 [fix]: buttons being mismatched in layout bar
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-05-01 06:45:44 -05:00
a686ed7d52 [fix]: a lot more custom icons and learing how cfg! works
Some checks failed
/ clippy (push) Failing after 6m12s
/ test (push) Failing after 6m49s
2026-05-01 06:32:15 -05:00
193a745cff [fix]: adding more icons 2026-04-30 20:39:46 -05:00
a712926c01 [fix]: icons for presentation controls
Some checks failed
/ clippy (push) Failing after 6m15s
/ test (push) Failing after 8m50s
2026-04-30 15:19:51 -05:00
deb6e99452 [fix]: coloring of icons
Some checks failed
/ clippy (push) Failing after 6m26s
/ test (push) Failing after 6m53s
2026-04-30 14:57:24 -05:00
818e908e0a idk
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 14:56:30 -05:00
3d893816f2 idk
Some checks are pending
/ clippy (push) Waiting to run
/ test (push) Waiting to run
2026-04-30 14:56:06 -05:00
ae7b752996 [fix]: use our own icons
Some checks failed
/ clippy (push) Failing after 6m16s
/ test (push) Has been cancelled
2026-04-30 14:49:00 -05:00
7b8fe9cacb [work]: Try this
Some checks failed
/ clippy (push) Failing after 6m4s
/ test (push) Failing after 6m55s
2026-04-30 14:30:30 -05:00
4bb7b9615f [fix]: presenter icon fallbacks
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 14:26:29 -05:00
fbf44980e7 [work]: Might work if the dir is there
Some checks failed
/ clippy (push) Failing after 6m14s
/ test (push) Failing after 6m53s
2026-04-30 13:56:03 -05:00
c160f52b72 [work]: grrrrrrrr
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 13:52:59 -05:00
5cb914aedf [work]: grrr
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 13:51:03 -05:00
b6506f0217 [work]: more error info
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 13:49:18 -05:00
c1eae15026 [work]: idk
Some checks failed
/ clippy (push) Failing after 6m19s
/ test (push) Has been cancelled
2026-04-30 13:41:08 -05:00
34dd3e72fb [fix]: db not being created
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-30 13:35:14 -05:00
dc2985beac [fix]: Using strings for mupdf documents to work on windows
Some checks failed
/ clippy (push) Failing after 6m4s
/ test (push) Failing after 7m0s
2026-04-30 11:36:17 -05:00
cea2c87aa7 [feat]: thumbnailing videos and loading images into slides
Some checks failed
/ clippy (push) Failing after 6m7s
/ test (push) Has been cancelled
2026-04-30 11:29:07 -05:00
cafd113a3b [fix]: preview videos not showing up
Some checks failed
/ clippy (push) Failing after 6m20s
/ test (push) Failing after 6m42s
2026-04-30 06:24:45 -05:00
65002511e9 [fix]: missing chip color implementation 2026-04-30 06:24:28 -05:00
f4640d5f72 [fix]: Make sure the audio exists before trying to load it 2026-04-29 14:32:53 -05:00
61e0fb8e49 [work]: Allow for using an allocation for image backgrounds
Some checks failed
/ clippy (push) Failing after 5m58s
/ test (push) Failing after 6m40s
2026-04-29 13:45:07 -05:00
29de582f84 [fix]: Use os_str on mupdf::FilePath such for builds on windows
Some checks failed
/ clippy (push) Failing after 6m38s
/ test (push) Failing after 7m31s
2026-04-29 10:20:35 -05:00
f1b1c053c7 [fix]: Using iced_video_player's rexport of gstreamer
Some checks failed
/ clippy (push) Failing after 6m20s
/ test (push) Failing after 6m47s
2026-04-29 09:51:08 -05:00
e1bcae7438 [work]: starting to build the video downloader
Some checks failed
/ clippy (push) Failing after 8m15s
/ test (push) Failing after 6m29s
2026-04-28 15:27:28 -05:00
98d8ede443 [chore]: tweaking deps for windows build 2026-04-28 15:27:13 -05:00
500d22452a [chore]: refactoring 2026-04-28 15:27:02 -05:00
6b4c6adf50 [chore]: removing some deps to try to build on windows 2026-04-28 15:26:40 -05:00
44749e154f [formatting]
Some checks failed
/ clippy (push) Failing after 5m37s
/ test (push) Failing after 6m8s
2026-04-28 12:24:03 -05:00
42c3bd3068 [fix]: Text not being centered when using center alignment
Some checks failed
/ clippy (push) Failing after 5m32s
/ test (push) Failing after 6m10s
2026-04-28 09:33:12 -05:00
495a28180f [chore]: switch to resvg_exposed
Some checks failed
/ clippy (push) Failing after 5m39s
/ test (push) Failing after 6m12s
This will allow us to potentially skip the parsing part of building
SVG text. If that performance boost isn't drastic, we will have to
build our own text system someday.
2026-04-28 06:05:28 -05:00
7e62520ef4 [chore]: refactor 2026-04-28 06:05:17 -05:00
ae9c362e35 [fix]: More flashes when loading images
Some checks failed
/ clippy (push) Failing after 5m33s
/ test (push) Failing after 6m28s
2026-04-27 15:08:18 -05:00
6c6d071b2b [fix]: bug when selecting slides in split pdf presentations
Some checks failed
/ clippy (push) Failing after 5m24s
/ test (push) Has been cancelled
2026-04-27 15:01:39 -05:00
1b2a0c9761 [chore]: Try to start denesting with some clippy lints
Some checks failed
/ clippy (push) Failing after 5m41s
/ test (push) Failing after 6m23s
2026-04-27 14:32:13 -05:00
ef00b745a5 [chore]: adding some dependencies 2026-04-27 14:09:28 -05:00
8a39583533 [chore]: some refactoring to allow for more flexibility and clarity 2026-04-27 14:09:03 -05:00
a24e174a47 [feat]: Adding audio controls to song_editor 2026-04-27 14:08:25 -05:00
48854b5b65 [chore]: Update todo
Some checks failed
/ clippy (push) Failing after 5m36s
/ test (push) Failing after 44s
2026-04-26 06:52:13 -05:00
b2b21a6d58 [work]: Adding tick subscription to track time change
This will eventually help us to drive animations and track keyframes
and such.
2026-04-26 06:51:27 -05:00
be47681fa7 [fix]: panic from audio not existing in slide 2026-04-25 06:40:16 -05:00
20c91ee868 [chore]: Fixup clippy lints
Some checks failed
/ clippy (push) Failing after 5m26s
/ test (push) Failing after 6m7s
2026-04-24 16:43:34 -05:00
bc302f9731 [fix]: audio repeating at end of song 2026-04-24 16:43:14 -05:00
be4fc8d370 [feat]: Changing preview size of the slides
Some checks failed
/ clippy (push) Failing after 5m15s
/ test (push) Failing after 5m57s
2026-04-24 14:44:18 -05:00
9b6287a3e6 [fix]: activating service_item not fully activating slide's audio
Some checks failed
/ clippy (push) Successful in 5m25s
/ test (push) Failing after 5m56s
2026-04-24 14:08:37 -05:00
bdaa64c9fe [chore]: update todo 2026-04-24 14:08:04 -05:00
09b1b03429 [fix]: Images not loading after switching slides when not redrawing
Some checks failed
/ clippy (push) Successful in 15m14s
/ test (push) Failing after 7m51s
Iced/cosmic seems to have a bug where images sometimes don't show up
when they are not constantly redrawn, so I am using some help from
cosmic-files and creating a loaded_image widget. This forces the
images to be loaded into the renderer before being drawn on the
initial view
2026-04-24 12:27:19 -05:00
9a4334db58 [work]: work on creating an image loader to cache images
Some checks failed
/ clippy (push) Failing after 5m20s
/ test (push) Failing after 5m49s
2026-04-24 09:43:07 -05:00
bead9fd781 uhhhhhhh
Some checks failed
/ clippy (push) Failing after 5m22s
/ test (push) Failing after 6m3s
2026-04-23 15:30:23 -05:00
0f8655fd60 [work]: Trying to find a way to ensure images are loaded early
Some checks failed
/ clippy (push) Failing after 5m36s
/ test (push) Failing after 6m3s
2026-04-23 13:17:07 -05:00
5c9fc6a38d [work]: Add extra video pieces to songs for help in creating
Some checks failed
/ clippy (push) Successful in 5m23s
/ test (push) Failing after 6m18s
2026-04-22 15:27:57 -05:00
dd2bbc2a0a [fix]: missing test skip for ci
Some checks failed
/ clippy (push) Successful in 5m32s
/ test (push) Failing after 6m12s
2026-04-22 11:48:21 -05:00
e91a6795e4 [chore]: getting tests to pass again
Some checks failed
/ clippy (push) Successful in 5m13s
/ test (push) Failing after 6m11s
I'll need to work on making a lot of tests better someday
2026-04-22 11:29:24 -05:00
aabe0397d6 [chore]: removing a lot of booleans in favor of a state machine
Some checks failed
/ clippy (push) Successful in 5m26s
/ test (push) Failing after 6m12s
2026-04-22 10:18:17 -05:00
23b2a52839 [feat]: loading spinner for the song searching
Some checks failed
/ clippy (push) Successful in 5m30s
/ test (push) Failing after 6m15s
I am also going to start adding state machines to all the ui pages to
capture the state and not use booleans everywhere
2026-04-22 06:39:19 -05:00
6e670068d2 [fix]: clippy lints
Some checks failed
/ clippy (push) Successful in 5m11s
/ test (push) Failing after 6m5s
2026-04-21 10:37:07 -05:00
e7d4c10ad6 [feat]: Song importing works now
Some checks failed
/ clippy (push) Failing after 5m22s
/ test (push) Has been cancelled
This means we have a decent flow for creating and importing songs. By
default this will use Genius as the lyric backend. In the future we
will support more options, but for now this means you can get the
lyrics you need and start building songs rather fast.
2026-04-21 10:27:14 -05:00
d043caae27 [work]: More ui tweaks
Some checks failed
/ clippy (push) Failing after 5m29s
/ test (push) Failing after 6m9s
2026-04-21 09:14:46 -05:00
0cd029ed39 [work]: almost have a ui built for the importing of songs
Some checks failed
/ clippy (push) Failing after 6m3s
/ test (push) Failing after 6m27s
2026-04-20 16:50:19 -05:00
cae76c8d72 [work]: settings system for genius_token setup
The last thing needed is the ui for searching songs
2026-04-19 15:27:26 -05:00
2c72b9f6a2 [work]: library of searching online songs and parsing them is sorta working 2026-04-18 14:18:48 -05:00
12d748db85 [work]: work on parsing online genius songs
Some checks failed
/ clippy (push) Successful in 5m6s
/ test (push) Failing after 5m58s
2026-04-17 16:52:31 -05:00
761695905c [chore]: add watch-clippy command for just 2026-04-17 13:23:29 -05:00
8a2773c510 [chore]: fixing clippy lints
Some checks failed
/ clippy (push) Successful in 5m27s
/ test (push) Failing after 6m12s
2026-04-17 12:59:07 -05:00
b2298fe99e update gitignore 2026-04-17 11:29:11 -05:00
4f3458a76f [fix-lints] 2026-04-17 11:26:08 -05:00
60b6dcefa7 [fix]: A much better implementation, and gatekeeping the tasks
Some checks failed
/ clippy (push) Failing after 5m26s
/ test (push) Failing after 6m2s
2026-04-17 07:28:51 -05:00
9084fe7fe4 [fix]: dropping multiple items into library only adds one to model 2026-04-17 07:05:21 -05:00
2db560242a [fix]: bugs in library adding and some ui tweaks
Some checks failed
/ clippy (push) Failing after 5m19s
/ test (push) Failing after 6m15s
2026-04-16 15:21:58 -05:00
6cd6395cc5 [chore]: update todo
Some checks failed
/ clippy (push) Failing after 5m27s
/ test (push) Failing after 6m18s
2026-04-16 13:55:12 -05:00
46bba94cf8 [fix]: Fix performance when running video
Some checks failed
/ clippy (push) Failing after 5m31s
/ test (push) Failing after 6m12s
The same video was being shown twice making the performance tank a
little. Creating two video objects, one running at a lower framerate
makes the viewer work much better.
2026-04-16 13:11:53 -05:00
d7098dcbca [chore]: update todo
Some checks failed
/ clippy (push) Failing after 5m19s
/ test (push) Failing after 6m19s
2026-04-15 15:59:51 -05:00
9b09da3904 [fix]: Opus files not playing because outdated rodio package
Some checks failed
/ clippy (push) Failing after 5m27s
/ test (push) Has been cancelled
2026-04-15 15:51:01 -05:00
41f887ae01 [fix]: Adding an audio picking mechanism 2026-04-15 15:50:43 -05:00
b13f94cf8b [fix]: Flashing library due to removing items for a brief second 2026-04-15 14:19:11 -05:00
9c47830330 [fix]: Deleting not taking a list of items 2026-04-15 14:14:21 -05:00
34105a517a [fix]: children have precedence in draggable row and column 2026-04-15 14:13:45 -05:00
eee28286cf [fix]: context_menu in the presenter works now
Some checks failed
/ clippy (push) Failing after 5m23s
/ test (push) Failing after 6m3s
2026-04-15 10:14:08 -05:00
07741504e3 [work]: on the context_popover to use in place of context_menu
Some checks failed
/ clippy (push) Failing after 5m7s
/ test (push) Failing after 6m4s
This is hopefully our own dynamic setup for context menus that won't
break due to a positioning error
2026-04-14 15:23:34 -05:00
7c82503510 [lint]: Ensure that it is clear Arc::clones are just cloning pointers 2026-04-14 10:32:41 -05:00
fb14180e15 adding new context_popover widget
Some checks failed
/ clippy (push) Failing after 5m12s
/ test (push) Failing after 5m49s
2026-04-14 09:43:35 -05:00
3e4bf0a12e [chore]: formatting stuff 2026-04-14 09:43:13 -05:00
85e9e262f9 [feat]: adding a grid view in main presenter
Some checks failed
/ clippy (push) Failing after 5m15s
/ test (push) Failing after 6m6s
2026-04-13 14:24:25 -05:00
729d2f050a [fix]: video slider bouncing from the video reading position 0
This seems to happen when buffering new parts of the video to be played.
2026-04-13 14:23:05 -05:00
b8e2209e23 [chore]: update todo
Some checks failed
/ clippy (push) Failing after 5m3s
/ test (push) Failing after 6m5s
2026-04-12 06:41:20 -05:00
e104887f2b [chore]: update libcosmic
Some checks failed
/ clippy (push) Failing after 5m51s
/ test (push) Failing after 6m12s
This included a fix for dnd not working again
2026-04-11 15:03:03 -05:00
d7a041d245 [chore]: update todo
Some checks failed
/ clippy (push) Failing after 5m7s
/ test (push) Failing after 6m12s
2026-04-11 07:20:34 -05:00
d77c5e58bb [work]: make larger stroke and shadow sizes possible 2026-04-11 07:20:09 -05:00
97253475d0 [work]: some tweaks to the flatpak
Some checks failed
/ clippy (push) Failing after 5m4s
/ test (push) Failing after 6m3s
2026-04-10 16:24:53 -05:00
123fdf15b4 making the name make more sense 2026-04-10 16:12:17 -05:00
e551f9e7e7 adding some metadata stuff 2026-04-10 15:57:58 -05:00
01068a5896 try again
Some checks failed
/ clippy (push) Failing after 5m6s
/ test (push) Failing after 6m6s
2026-04-10 15:48:54 -05:00
69847e01d6 add screenshot
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-10 15:48:12 -05:00
d0df1ec201 [res]: adding some screenshots
Some checks failed
/ clippy (push) Failing after 5m10s
/ test (push) Has been cancelled
2026-04-10 15:38:56 -05:00
a391f52ecc [dev]: add flatpak-builder to attempt to build flatpaks
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-10 15:38:28 -05:00
ce2e021b4c [deploy]: Nix build works now
Some checks failed
/ clippy (push) Failing after 5m4s
/ test (push) Failing after 5m51s
This will be my first step to having a build that folks can use
2026-04-10 14:41:28 -05:00
22c9b02fbc [fix]: performance issue by creating service_item on mouse hovers
Some checks failed
/ clippy (push) Failing after 5m6s
/ test (push) Failing after 5m48s
2026-04-10 13:08:38 -05:00
62a631873d [fix]: use separate thread and stream text_svgs back one at a time
Some checks failed
/ clippy (push) Failing after 5m1s
/ test (push) Failing after 5m36s
Going to see if this gives some perceived responses back since the
svgs will be created and returned one at a time rather than needing
all of them done
2026-04-10 12:01:07 -05:00
4ace700fb0 [broken]: trying to switch the stream to using an os thread
Some checks failed
/ clippy (push) Failing after 5m9s
/ test (push) Failing after 5m44s
2026-04-10 10:45:33 -05:00
13d20b05e5 [feat]: stream back the results of song text changes
Some checks failed
/ clippy (push) Failing after 5m1s
/ test (push) Failing after 5m55s
This still needs some work since we might need to ensure that the old
list of songs is entirely changed from the new list if we are
switching songs or removing verses or something that changes the
number of generated slides
2026-04-10 07:27:59 -05:00
cdbeccc8a3 [fix]: crash on missing video 2026-04-10 07:27:43 -05:00
17b71e7ba3 idk
Some checks failed
/ clippy (push) Failing after 5m0s
/ test (push) Failing after 5m57s
2026-04-09 15:30:55 -05:00
ea2d40a224 [work]: converting the service to an Arc<Vec<ServiceItem>>
This could likely save us some performance
2026-04-09 12:51:37 -05:00
d955551cd2 [fix]: play pause not working in presenter 2026-04-09 11:40:26 -05:00
9fa26e41cd [fix]: Fixing bugs when adding items to the service
Some checks failed
/ clippy (push) Failing after 5m6s
/ test (push) Failing after 5m40s
2026-04-09 11:33:12 -05:00
030fc23ac2 fix the presenter not having the service items when they are added
Some checks failed
/ clippy (push) Failing after 4m56s
/ test (push) Failing after 5m47s
2026-04-09 11:06:43 -05:00
7f0a637cc2 presenter tests and fixing the bugs with changing slides
Some checks failed
/ clippy (push) Failing after 5m6s
/ test (push) Has been cancelled
2026-04-09 10:56:45 -05:00
7dcad39d1c [work]: more tweaks to the parsing of genius lyrics
Some checks failed
/ clippy (push) Failing after 5m20s
/ test (push) Failing after 5m48s
2026-04-09 09:47:11 -05:00
252ca2d872 [work]: Think we have a roughly working thing here
Some checks failed
/ clippy (push) Failing after 4m56s
/ test (push) Failing after 5m42s
2026-04-08 16:41:49 -05:00
3be778606b [work]: parsing for verse_name works now
Some checks failed
/ clippy (push) Failing after 5m18s
/ test (push) Failing after 6m7s
2026-04-08 15:26:02 -05:00
9de9b1784d [work]: Setting up parsing for genius lyrics
Some checks failed
/ clippy (push) Failing after 5m6s
/ test (push) Failing after 5m38s
2026-04-08 15:09:36 -05:00
3a155ed122 [fix]: broken song model/db tests
Some checks failed
/ clippy (push) Failing after 5m7s
/ test (push) Failing after 5m59s
The new model and db functions changed alot of things and this gets
the tests working again
2026-04-08 09:54:01 -05:00
4c92bc1f43 [fix]: broken loading test
After making some optimizations to text_svg, some were smaller than
the anticipated amount
2026-04-08 09:52:03 -05:00
cf2866e019 sorta broken tests
Some checks failed
/ clippy (push) Failing after 4m54s
/ test (push) Failing after 5m47s
2026-04-07 15:13:24 -05:00
694b84cd6e tests build now
Some checks failed
/ clippy (push) Failing after 4m50s
/ test (push) Failing after 5m47s
2026-04-07 14:55:31 -05:00
01993ea7eb fixing lints
Some checks failed
/ clippy (push) Failing after 6m55s
/ test (push) Failing after 8m11s
2026-04-07 13:53:54 -05:00
ab01a4bba8 formatting and other fixes 2026-04-07 13:44:47 -05:00
a647a123ee somehow we are fixing the frame issues i had
Some checks failed
/ clippy (push) Failing after 5m50s
/ test (push) Has started running
2026-04-07 13:35:09 -05:00
94c00c7b23 better names for db_functions
Some checks failed
/ clippy (push) Failing after 4m42s
/ test (push) Failing after 5m37s
2026-04-07 12:46:57 -05:00
79401bb95e add button for adding item from library
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-07 12:44:05 -05:00
73054a6709 presenter should use the same create_video fn
Some checks failed
/ clippy (push) Failing after 5m0s
/ test (push) Failing after 5m41s
2026-04-07 11:51:40 -05:00
1d0f26d7be fix video framerate crashes
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-07 11:49:54 -05:00
3ced9786c0 update todo
Some checks failed
/ test (push) Waiting to run
/ clippy (push) Has been cancelled
2026-04-07 11:42:56 -05:00
59b032fbdd fix git merge error
Some checks are pending
/ clippy (push) Waiting to run
/ test (push) Waiting to run
2026-04-07 11:41:33 -05:00
7e582edbbe update todo 2026-04-07 11:41:13 -05:00
fbb4490a73 videos to reuse the correct pipeline in editor as in presenter 2026-04-07 11:40:52 -05:00
9a814665fb working using the passing of model items around
Moving the model into and out of the db functions creates more
messages, but allows for less complexity in the overall library
module.

This means that the library can just use those functions to change the
model and db, but need to remember to readd the items to the model
once the function is finished in the async function.
2026-04-07 11:40:52 -05:00
82bed2c9b8 more functions for db actions 2026-04-07 11:40:52 -05:00
4d8e2cf270 note the way 2026-04-07 11:40:52 -05:00
0e94874ca9 this may be the way.
By making the db functions take the vector of items in the model, we
can drain the model, pass an owned version of those items to the async
db function(adding, updating, deleting, etc) and then return an
updated list of the items back in the Result.

We should probably return a tuple with the original vector of items in
case the db function fails somehow.
2026-04-07 11:40:52 -05:00
45b4a27113 tried to reroute through runtime, but PoolConnections not clonable
Without being able to clone a PoolConnection, we can't pass the
connection back through the runtime. Maybe we could do it with an Arc?
But I'm really not sure.
2026-04-07 11:40:52 -05:00
64faaf0c1a trying to remodel the models...
This may not be possible since the models are owned by the
library. This means that in order to run the sql queries, you will
need to pass owned versions of the models to the task and therefore
clone the entire model perhaps. Not ideal....
2026-04-07 11:40:49 -05:00
8c59688e2f fixing lint issues and style issues 2026-04-07 11:40:43 -05:00
f3d0ec9aa2 a working build of the new update 2026-04-07 11:40:43 -05:00
ff2bfc4f86 getting closer 2026-04-07 11:40:43 -05:00
679a2cafa5 working on updating 2026-04-07 11:40:43 -05:00
cb3cefd326 update todo
Some checks failed
/ clippy (push) Failing after 4m51s
/ test (push) Failing after 5m47s
2026-04-06 16:49:51 -05:00
92 changed files with 24900 additions and 8538 deletions

9
.gitignore vendored
View file

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

4828
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

13285
cargo-sources.json Normal file

File diff suppressed because one or more lines are too long

1
clippy.toml Normal file
View file

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

52
flake.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

After

Width:  |  Height:  |  Size: 832 B

View file

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

After

Width:  |  Height:  |  Size: 839 B

View file

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

After

Width:  |  Height:  |  Size: 849 B

View file

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

After

Width:  |  Height:  |  Size: 737 B

View file

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

After

Width:  |  Height:  |  Size: 822 B

View file

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

After

Width:  |  Height:  |  Size: 834 B

View file

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

After

Width:  |  Height:  |  Size: 822 B

View file

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

After

Width:  |  Height:  |  Size: 831 B

View file

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

After

Width:  |  Height:  |  Size: 841 B

1
res/icons/caret-left.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 621 B

View file

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

After

Width:  |  Height:  |  Size: 660 B

5
res/icons/carousel.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

After

Width:  |  Height:  |  Size: 398 B

1
res/icons/edit.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 617 B

View file

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

After

Width:  |  Height:  |  Size: 331 B

View file

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

After

Width:  |  Height:  |  Size: 578 B

View file

Before

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

After

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

Before After
Before After

View file

Before

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

After

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

Before After
Before After

BIN
res/icons/lumina.icns Normal file

Binary file not shown.

BIN
res/icons/lumina.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

105
res/icons/lumina.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 5.4 KiB

1
res/icons/plus.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 348 B

8
res/icons/presenting.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 2.8 KiB

14
res/icons/preview.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 799 B

1
res/icons/search.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 378 B

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

8
res/icons/slides.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 373 B

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 573 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 976 B

Before After
Before After

1
res/icons/x.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View file

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

View file

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

12
rust-analyzer.toml Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

25
src/core/ytdl.rs Normal file
View file

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

2292
src/main.rs Normal file → Executable file

File diff suppressed because it is too large Load diff

172
src/ui/gst_video.rs Normal file
View file

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

View file

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

61
src/ui/image_loader.rs Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@ use cosmic::iced::advanced::renderer;
use cosmic::iced::advanced::widget::{self, Widget};
use cosmic::iced::border;
use cosmic::iced::mouse;
use cosmic::iced::wgpu::Primitive;
use cosmic::iced::wgpu::primitive::Renderer as PrimitiveRenderer;
use cosmic::iced::{Color, Element, Length, Rectangle, Size};
use cosmic::iced_wgpu::Primitive;
use cosmic::iced_wgpu::primitive::Renderer as PrimitiveRenderer;
pub struct SlideText {
_text: String,
@ -40,7 +40,7 @@ where
}
fn layout(
&self,
&mut self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
_limits: &layout::Limits,

View file

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

BIN
test.db

Binary file not shown.

77
xyz.cochrun.lumina.yml Normal file
View file

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