Compare commits

..

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 23333 additions and 7116 deletions

9
.gitignore vendored
View file

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

2003
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 = "0.26.3"
strum_macros = "0.26.4" strum_macros = "0.26.4"
ron = "0.8.1" 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" dirs = "6.0.0"
tokio = "1.41.1" tokio = "1.41.1"
crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" } 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-video = "0.23"
# gstreamer-allocators = "0.23" # gstreamer-allocators = "0.23"
# cosmic-time = { git = "https://githubg.com/pop-os/cosmic-time" } # cosmic-time = { git = "https://githubg.com/pop-os/cosmic-time" }
url = "2" url = { version = "2", features = ["serde"] }
# colors-transform = "0.2.11" # colors-transform = "0.2.11"
rayon = "1.11.0" rayon = "1.11.0"
resvg = "0.47.0" resvg_exposed = "0.47.0"
image = "0.25.8" image = "0.25.8"
rapidhash = "4.0.0" rapidhash = "4.0.0"
rapidfuzz = "0.5.0" rapidfuzz = "0.5.0"
@ -38,7 +35,7 @@ rapidfuzz = "0.5.0"
# femtovg = { version = "0.16.0", features = ["wgpu"] } # femtovg = { version = "0.16.0", features = ["wgpu"] }
# wgpu = "26.0.1" # wgpu = "26.0.1"
# mupdf = "0.5.0" # 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" tar = "0.4.44"
zstd = "0.13.3" zstd = "0.13.3"
fastrand = "2.3.0" fastrand = "2.3.0"
@ -48,13 +45,21 @@ reqwest = "0.13.1"
scraper = "0.25.0" scraper = "0.25.0"
itertools = "0.14.0" itertools = "0.14.0"
serde_json = "1.0.149" 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"] } # 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] [dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic" git = "https://github.com/pop-os/libcosmic"
default-features = false default-features = false
features = ["debug", "winit", "desktop", "wayland", "tokio", "rfd", "dbus-config", "a11y", "wgpu", "multi-window", "process"] features = ["debug", "winit", "tokio", "rfd", "wgpu", "multi-window",]
[dependencies.iced_video_player] [dependencies.iced_video_player]
git = "https://github.com/wash2/iced_video_player.git" git = "https://github.com/wash2/iced_video_player.git"
@ -64,9 +69,75 @@ features = ["wgpu"]
# [profile.dev] # [profile.dev]
# opt-level = 3 # 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] [profile.release]
opt-level = 3 opt-level = 3
debug = true debug = true
# [profile.production]
# opt-level = 3
# lto = true
# codegen-units = 1
# panic = 'abort'
# strip = "symbols"
[lints.rust] [lints.rust]
mismatched_lifetime_syntaxes = "allow" 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,33 +3,63 @@
#+CATEGORY: dev #+CATEGORY: dev
* TODO [#A] Add Action system * TODO [#A] Deployment pipeline and get a MVP going
This will be based on each slide having the ability to activate an action (i.e. OBS scene switch, OBS start or stop) when it is active.
This is working but the right click context menu is all the way on the edge of the ui so you can't control all the slides. It also needs a lot of help in making the system more robust and potentially lest reliant on the Presenter struct itself. * TODO [#B] 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 * 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
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 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
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. CLOSED: [2026-05-30 Sat 15:15]
* DONE Grid mode needs to use the actual aspect ratio correctly for the slide preview
We should probably return a tuple with the original vector of items in case the db function fails somehow. 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 * 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. 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 * 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 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 * 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. 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 ui for settings
* TODO [#B] Develop library system for slides that are more than images or video i.e. content * 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. * 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. 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.
@ -64,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] 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 * DONE [#A] Create a view of all slides in a PDF presenation
@ -102,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 * 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. 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 * DONE [#B] Functions for text alignments
This will need to be matched on for the =TextAlignment= from the user This will need to be matched on for the =TextAlignment= from the user
@ -145,3 +202,7 @@ This will make it so that we can add styling to the text like borders and backgr
* DONE Build Menu * DONE Build Menu
* DONE Find a way for text to pass through a service item to a slide i.e. content piece * 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=. 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": { "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": { "fenix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1770794449, "lastModified": 1778662605,
"narHash": "sha256-1nFkhcZx9+Sdw5OXwJqp5TxvGncqRqLeK781v0XV3WI=", "narHash": "sha256-nGPpWsLZ1dX1Dirf98GsCsFDE/diXkUP0PaAqZlTpkA=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "b19d93fdf9761e6101f8cb5765d638bacebd9a1b", "rev": "5c80141c6215ed0a1cdc06ddb68e9bb55e9edfca",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -65,11 +80,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1769799857, "lastModified": 1778151388,
"narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=", "narHash": "sha256-lldMJPUeouEjO8/7aLuwhcsIw29vVihm2ZALzjiqfec=",
"owner": "nix-community", "owner": "nix-community",
"repo": "naersk", "repo": "naersk",
"rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339", "rev": "efdddff9ff4d8e7d0056d57ec67dac50f75ab8f6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -80,11 +95,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770562336, "lastModified": 1778443072,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -112,11 +127,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1770562336, "lastModified": 1778443072,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f", "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -144,6 +159,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"fenix": "fenix", "fenix": "fenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"naersk": "naersk", "naersk": "naersk",
@ -154,11 +170,11 @@
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1770702974, "lastModified": 1778611623,
"narHash": "sha256-CbvWu72rpGHK5QynoXwuOnVzxX7njF2LYgk8wRSiAQ0=", "narHash": "sha256-oNgaKN3iKM1Cud3bKhEXFHXNRRc+j/JDl05d2jYa2Sg=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "07a594815f7c1d6e7e39f21ddeeedb75b21795f4", "rev": "7c28934677b1e7a1c6ef952422e6ef730540f85f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -190,11 +206,11 @@
"nixpkgs": "nixpkgs_4" "nixpkgs": "nixpkgs_4"
}, },
"locked": { "locked": {
"lastModified": 1770779462, "lastModified": 1778642276,
"narHash": "sha256-ykcXTKtV+dOaKlOidAj6dpewBHjni9/oy/6VKcqfzfY=", "narHash": "sha256-bhk4lawR4ZnFhPtamB5WkCyvfgyZmsEUbWfT/3FRxFY=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "8a53b3ade61914cdb10387db991b90a3a6f3c441", "rev": "77265d2dc1e61b2abfd3b1d6609dbb66fe75e0a5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -7,6 +7,7 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
fenix.url = "github:nix-community/fenix"; fenix.url = "github:nix-community/fenix";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
crane.url = "github:ipetkov/crane";
}; };
outputs = outputs =
@ -21,10 +22,21 @@
# overlays = [ rust-overlay.overlays.default ]; # overlays = [ rust-overlay.overlays.default ];
# overlays = [cargo2nix.overlays.default]; # overlays = [cargo2nix.overlays.default];
}; };
inherit (pkgs) lib;
craneLib = crane.mkLib pkgs;
naersk' = pkgs.callPackage naersk { }; naersk' = pkgs.callPackage naersk { };
# toolchain = (with pkgs.fenix.default; [cargo clippy rust-std rust-src rustc rustfmt rust-analyzer-nightly]); # 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; [ nativeBuildInputs = with pkgs; [
# Rust tools # Rust tools
@ -37,9 +49,9 @@
# "rustc" # "rustc"
# "rustfmt" # "rustfmt"
# ]) # ])
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { (rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" ]; extensions = [ "rust-src" "rust-analyzer" "clippy" ];
})) })
cargo-nextest cargo-nextest
cargo-criterion cargo-criterion
# rust-analyzer-nightly # rust-analyzer-nightly
@ -49,6 +61,21 @@
libxkbcommon libxkbcommon
pkg-config pkg-config
sccache 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; [ buildInputs = with pkgs; [
@ -60,16 +87,17 @@
cmake cmake
clang clang
libclang libclang
makeWrapper
vulkan-headers vulkan-headers
vulkan-loader vulkan-loader
vulkan-tools vulkan-tools
libGL libGL
libinput
cargo-flamegraph cargo-flamegraph
bacon bacon
openssl openssl
freetype
fontconfig fontconfig
libglvnd
glib glib
alsa-lib alsa-lib
gst_all_1.gst-libav gst_all_1.gst-libav
@ -83,11 +111,6 @@
ffmpeg-full ffmpeg-full
mupdf mupdf
# yt-dlp # yt-dlp
just
sqlx-cli
cargo-watch
samply
]; ];
LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${ LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
@ -112,6 +135,28 @@
pkgs.libclang 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 in
rec { rec {
devShell = devShell =
@ -125,15 +170,12 @@
DATABASE_URL = "sqlite://./test.db"; DATABASE_URL = "sqlite://./test.db";
# RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library"; # RUST_SRC_PATH = "${toolchain.rust-src}/lib/rustlib/src/rust/library";
}; };
defaultPackage = naersk'.buildPackage { defaultPackage = lumina;
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH;
src = ./.;
};
packages = { packages = {
default = naersk'.buildPackage { postInstall = ''
inherit nativeBuildInputs buildInputs LD_LIBRARY_PATH; libcosmicAppWrapperArgs+=(--prefix GST_PLUGIN_SYSTEM_PATH_1_0 : "$GST_PLUGIN_SYSTEM_PATH_1_0")
src = ./.; '';
}; default = lumina;
}; };
} }
); );

View file

@ -1,8 +1,8 @@
ui := "-i"
verbose := "-v" verbose := "-v"
file := "~/dev/lumina-iced/test_presentation.lisp" file := "~/dev/lumina-iced/test_presentation.lisp"
sdk-version := "25.08"
export RUSTC_WRAPPER := "sccache" # export RUSTC_WRAPPER := "sccache"
# export RUST_LOG := "debug" # export RUST_LOG := "debug"
default: default:
@ -11,23 +11,29 @@ build:
cargo build cargo build
build-release: build-release:
cargo build --release cargo build --release
build-offline:
cargo build --release --offline
run: run:
cargo run -- {{verbose}} {{ui}} cargo run -- {{verbose}}
run-release: run-release:
cargo run --release -- {{verbose}} {{ui}} cargo run --release -- {{verbose}}
run-file: 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: clean:
cargo clean cargo clean
watch-clippy:
cargo watch --why -x "clippy --all-targets --all-features"
test: test:
cargo nextest run cargo nextest run
ci-test: 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: bench:
export NEXTEST_EXPERIMENTAL_BENCHMARKS=1 export NEXTEST_EXPERIMENTAL_BENCHMARKS=1
cargo nextest bench cargo nextest bench
profile: profile:
samply record cargo run --release -- {{verbose}} {{ui}} samply record cargo run --release -- {{verbose}}
alias b := build alias b := build
alias r := run alias r := run
@ -35,3 +41,54 @@ alias br := build-release
alias rr := run-release alias rr := run-release
alias rf := run-file alias rf := run-file
alias c := clean 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. 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? * 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. 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" style_edition = "2024"
# version = "Two" # version = "Two"
imports_granularity = "Module"

View file

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

View file

@ -1,22 +1,20 @@
use crate::core::{ use crate::core::kinds::ServiceItemKind;
kinds::ServiceItemKind, service_items::ServiceItem, use crate::core::service_items::ServiceItem;
slide::Background, use crate::core::slide::Background;
};
use cosmic::widget::image::Handle; use cosmic::widget::image::Handle;
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use std::{ use std::fs::{self, File};
fs::{self, File}, use std::io::Write;
io::Write, use std::iter;
iter, use std::path::{Path, PathBuf};
path::{Path, PathBuf}, use std::sync::Arc;
};
use tar::{Archive, Builder}; use tar::{Archive, Builder};
use tracing::{debug, error}; use tracing::{debug, error};
use zstd::{Decoder, Encoder}; use zstd::{Decoder, Encoder};
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn save( pub fn save(
list: Vec<ServiceItem>, list: &Arc<Vec<ServiceItem>>,
path: impl AsRef<Path>, path: impl AsRef<Path>,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<()> {
@ -26,8 +24,7 @@ pub fn save(
} }
let save_file = File::create(path).into_diagnostic()?; let save_file = File::create(path).into_diagnostic()?;
let ron_pretty = ron::ser::PrettyConfig::default(); let ron_pretty = ron::ser::PrettyConfig::default();
let ron = ron::ser::to_string_pretty(&list, ron_pretty) let ron = ron::ser::to_string_pretty(&list, ron_pretty).into_diagnostic()?;
.into_diagnostic()?;
let encoder = Encoder::new(save_file, 3) let encoder = Encoder::new(save_file, 3)
.expect("file encoder shouldn't fail") .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", "there should be a data directory, ~/.local/share/ for linux, but couldn't find it",
); );
temp_dir.push("lumina"); temp_dir.push("lumina");
let mut s: String = let mut s: String = iter::repeat_with(fastrand::alphanumeric).take(5).collect();
iter::repeat_with(fastrand::alphanumeric).take(5).collect();
s.insert_str(0, "temp_"); s.insert_str(0, "temp_");
temp_dir.push(s); temp_dir.push(s);
fs::create_dir_all(&temp_dir).into_diagnostic()?; fs::create_dir_all(&temp_dir).into_diagnostic()?;
@ -62,9 +58,7 @@ pub fn save(
} }
match tar.append_file("serviceitems.ron", &mut f) { match tar.append_file("serviceitems.ron", &mut f) {
Ok(()) => { Ok(()) => {
debug!( debug!("should have added serviceitems.ron to the file");
"should have added serviceitems.ron to the file"
);
} }
Err(e) => { Err(e) => {
error!(?e); error!(?e);
@ -85,7 +79,7 @@ pub fn save(
Ok(()) Ok(())
}; };
for item in list { for item in list.iter() {
let background; let background;
let audio: Option<PathBuf>; let audio: Option<PathBuf>;
match &item.kind { match &item.kind {
@ -94,23 +88,18 @@ pub fn save(
audio = song.audio.clone(); audio = song.audio.clone();
} }
ServiceItemKind::Image(image) => { ServiceItemKind::Image(image) => {
background = Some( background =
Background::try_from(image.path.clone()) Some(Background::try_from(image.path.clone()).into_diagnostic()?);
.into_diagnostic()?,
);
audio = None; audio = None;
} }
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
background = Some( background =
Background::try_from(video.path.clone()) Some(Background::try_from(video.path.clone()).into_diagnostic()?);
.into_diagnostic()?,
);
audio = None; audio = None;
} }
ServiceItemKind::Presentation(presentation) => { ServiceItemKind::Presentation(presentation) => {
background = Some( background = Some(
Background::try_from(presentation.path.clone()) Background::try_from(presentation.path.clone()).into_diagnostic()?,
.into_diagnostic()?,
); );
audio = None; audio = None;
} }
@ -131,11 +120,11 @@ pub fn save(
debug!(?path); debug!(?path);
append_file(path)?; append_file(path)?;
} }
for slide in item.slides { for slide in &item.slides {
if let Some(svg) = slide.text_svg if let Some(svg) = &slide.text_svg
&& let Some(path) = svg.path && let Some(path) = &svg.path
{ {
append_file(path)?; append_file(path.clone())?;
} }
} }
} }
@ -153,12 +142,10 @@ pub fn save(
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> { pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
let decoder = let decoder =
Decoder::new(fs::File::open(&path).into_diagnostic()?) Decoder::new(fs::File::open(&path).into_diagnostic()?).into_diagnostic()?;
.into_diagnostic()?;
let mut tar = Archive::new(decoder); let mut tar = Archive::new(decoder);
let mut cache_dir = let mut cache_dir = dirs::cache_dir().expect("Should be a cache dir");
dirs::cache_dir().expect("Should be a cache dir");
cache_dir.push("lumina"); cache_dir.push("lumina");
cache_dir.push("cached_save_files"); cache_dir.push("cached_save_files");
@ -175,8 +162,7 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.to_os_string() .to_os_string()
.into_string() .into_string()
.expect("Should be fine"); .expect("Should be fine");
let save_name = save_name_string let save_name = save_name_string.trim_end_matches(&format!(".{save_name_ext}"));
.trim_end_matches(&format!(".{save_name_ext}"));
cache_dir.push(save_name); cache_dir.push(save_name);
if let Err(e) = fs::remove_dir_all(&cache_dir) { 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 mut dir = fs::read_dir(&cache_dir).into_diagnostic()?;
let ron_file = dir let ron_file = dir
.find_map(|file| { .find_map(|file| {
if file.as_ref().ok()?.path().extension()?.to_str()? if file.as_ref().ok()?.path().extension()?.to_str()? == "ron" {
== "ron"
{
Some(file.ok()?.path()) Some(file.ok()?.path())
} else { } else {
None None
@ -202,12 +186,10 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
}) })
.expect("Should have a ron file"); .expect("Should have a ron file");
let ron_string = let ron_string = fs::read_to_string(ron_file).into_diagnostic()?;
fs::read_to_string(ron_file).into_diagnostic()?;
let mut items = let mut items =
ron::de::from_str::<Vec<ServiceItem>>(&ron_string) ron::de::from_str::<Vec<ServiceItem>>(&ron_string).into_diagnostic()?;
.into_diagnostic()?;
for item in &mut items { for item in &mut items {
let dir = fs::read_dir(&cache_dir).into_diagnostic()?; 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 { for slide in &mut item.slides {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
let audio_path = let audio_path = slide.audio().clone().unwrap_or_default();
slide.audio().clone().unwrap_or_default(); let text_path =
let text_path = slide slide.text_svg.as_ref().and_then(|svg| svg.path.clone());
.text_svg if Some(file_name.as_os_str()) == slide.background.path.file_name() {
.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(); slide.background.path = file.path();
} else if Some(file_name.as_os_str()) } else if Some(file_name.as_os_str()) == audio_path.file_name() {
== audio_path.file_name() let new_slide = slide.clone().set_audio(Some(file.path()));
{
let new_slide = slide
.clone()
.set_audio(Some(file.path()));
*slide = new_slide; *slide = new_slide;
} else if Some(file_name.as_os_str()) } else if Some(file_name.as_os_str())
== text_path == text_path.clone().unwrap_or_default().file_name()
.clone()
.unwrap_or_default()
.file_name()
&& let Some(svg) = slide.text_svg.as_mut() && let Some(svg) = slide.text_svg.as_mut()
{ {
svg.path = Some(file.path()); svg.path = Some(file.path());
svg.handle = svg.handle = Some(Handle::from_path(file.path()));
Some(Handle::from_path(file.path()));
} }
} }
} }
@ -250,8 +219,7 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Song(song) => { ServiceItemKind::Song(song) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
let audio_path = let audio_path = song.audio.clone().unwrap_or_default();
song.audio.clone().unwrap_or_default();
if Some(file_name.as_os_str()) if Some(file_name.as_os_str())
== song == song
.background .background
@ -261,14 +229,11 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
.file_name() .file_name()
{ {
let background = song.background.clone(); let background = song.background.clone();
song.background = song.background = background.map(|mut background| {
background.map(|mut background| { background.path = file.path();
background.path = file.path(); background
background });
}); } else if Some(file_name.as_os_str()) == audio_path.file_name() {
} else if Some(file_name.as_os_str())
== audio_path.file_name()
{
song.audio = Some(file.path()); song.audio = Some(file.path());
} }
} }
@ -276,9 +241,7 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) if Some(file_name.as_os_str()) == video.path.file_name() {
== video.path.file_name()
{
video.path = file.path(); video.path = file.path();
} }
} }
@ -286,9 +249,7 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Image(image) => { ServiceItemKind::Image(image) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) if Some(file_name.as_os_str()) == image.path.file_name() {
== image.path.file_name()
{
image.path = file.path(); image.path = file.path();
} }
} }
@ -296,9 +257,7 @@ pub fn load(path: impl AsRef<Path>) -> Result<Vec<ServiceItem>> {
ServiceItemKind::Presentation(presentation) => { ServiceItemKind::Presentation(presentation) => {
if let Ok(file) = file.as_ref() { if let Ok(file) = file.as_ref() {
let file_name = file.file_name(); let file_name = file.file_name();
if Some(file_name.as_os_str()) if Some(file_name.as_os_str()) == presentation.path.file_name() {
== presentation.path.file_name()
{
presentation.path = file.path(); presentation.path = file.path();
} }
} }
@ -316,20 +275,18 @@ mod test {
use resvg::usvg::fontdb; use resvg::usvg::fontdb;
use super::*; use super::*;
use crate::{ use crate::core::service_items::ServiceTrait;
core::{ use crate::core::slide::{Slide, TextAlignment};
service_items::ServiceTrait, use crate::core::songs::{Song, VerseName};
slide::{Slide, TextAlignment}, use crate::ui::text_svg::text_svg_generator;
songs::{Song, VerseName}, use std::collections::HashMap;
}, use std::path::PathBuf;
ui::text_svg::text_svg_generator, use std::sync::Arc;
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
fn test_song() -> Song { 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 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>> = let verse_map: Option<HashMap<VerseName, String>> =
ron::from_str(&lyrics).unwrap(); ron::from_str(&lyrics).expect("");
Song { Song {
id: 7, id: 7,
title: "Death Was Arrested".to_string(), title: "Death Was Arrested".to_string(),
@ -340,7 +297,7 @@ mod test {
ccli: None, ccli: None,
audio: Some("/home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3".into()), 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()]), 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), text_alignment: Some(TextAlignment::MiddleCenter),
font: None, font: None,
font_size: Some(120), font_size: Some(120),
@ -362,20 +319,12 @@ mod test {
let fontdb = Arc::new(fontdb); let fontdb = Arc::new(fontdb);
let slides = song let slides = song
.to_slides() .to_slides()
.unwrap() .expect("")
.into_par_iter() .into_par_iter()
.map(|slide| { .map(|slide| {
text_svg_generator( text_svg_generator(slide, &Arc::clone(&fontdb)).unwrap_or_else(|e| {
slide.clone(), panic!("Couldn't create svg: {e}");
&Arc::clone(&fontdb), })
)
.map_or_else(
|e| {
assert!(false, "Couldn't create svg: {e}");
slide
},
|slide| slide,
)
}) })
.collect::<Vec<Slide>>(); .collect::<Vec<Slide>>();
let items = vec![ let items = vec![
@ -391,7 +340,7 @@ mod test {
kind: ServiceItemKind::Song(song), kind: ServiceItemKind::Song(song),
id: 1, id: 1,
title: "Death was Arrested".into(), title: "Death was Arrested".into(),
slides: slides, slides,
}, },
]; ];
items items
@ -404,7 +353,7 @@ mod test {
let result = load(&path); let result = load(&path);
match result { match result {
Ok(items) => { Ok(items) => {
assert!(items.len() > 0); assert!(!items.is_empty());
// assert_eq!(items, get_items()); // assert_eq!(items, get_items());
let cache_dir = cache_dir(); let cache_dir = cache_dir();
assert!(fs::read_dir(&cache_dir).is_ok()); assert!(fs::read_dir(&cache_dir).is_ok());
@ -415,37 +364,58 @@ mod test {
find_svgs(&items)?; find_svgs(&items)?;
Ok(()) 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(); 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| { items.iter().try_for_each(|item| {
if let ServiceItemKind::Song(..) = item.kind { if let ServiceItemKind::Song(..) = item.kind {
item.slides.iter().try_for_each(|slide| { 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| { slide.text_svg.as_ref().map_or_else(
|| Err(String::from("There is no TextSvg for this song")),
if text_svg.handle.is_none() { |text_svg| {
return Err(String::from("There is no handle in this song's TextSvg")); 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"))
} }
})
}) 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 { } else {
Ok(()) Ok(())
@ -454,20 +424,20 @@ mod test {
} }
// checks to make sure all paths in slides and items point to cache_dir // 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(); let cache_dir = cache_dir();
items.iter().all(|item| { items.iter().all(|item| {
match &item.kind { match &item.kind {
ServiceItemKind::Song(song) => { ServiceItemKind::Song(song) => {
if let Some(bg) = &song.background { if let Some(bg) = &song.background
if !bg.path.starts_with(&cache_dir) { && !bg.path.starts_with(&cache_dir)
return false; {
} return false;
} }
if let Some(audio) = &song.audio { if let Some(audio) = &song.audio
if !audio.starts_with(&cache_dir) { && !audio.starts_with(&cache_dir)
return false; {
} return false;
} }
} }
ServiceItemKind::Video(video) => { ServiceItemKind::Video(video) => {
@ -491,9 +461,10 @@ mod test {
if !slide.background().path.starts_with(&cache_dir) { if !slide.background().path.starts_with(&cache_dir) {
return false; return false;
} }
if !slide.audio().map_or(true, |audio| { if !slide
audio.starts_with(&cache_dir) .audio()
}) { .is_none_or(|audio| audio.starts_with(&cache_dir))
{
return false; return false;
} }
} }
@ -502,7 +473,7 @@ mod test {
} }
fn cache_dir() -> PathBuf { 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("lumina");
cache_dir.push("cached_save_files"); cache_dir.push("cached_save_files");
cache_dir.push("test"); cache_dir.push("test");
@ -513,22 +484,18 @@ mod test {
fn test_save() { fn test_save() {
let path = PathBuf::from("./test.pres"); let path = PathBuf::from("./test.pres");
let list = get_items(); let list = get_items();
match save(list, &path, true) { match save(&Arc::new(list), &path, true) {
Ok(_) => { Ok(()) => {
assert!(path.is_file()); assert!(path.is_file());
let Ok(file) = fs::File::open(path) else { 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()) let Ok(size) = file.metadata().map(|data| data.len()) else {
else { panic!("couldn't get file metadata");
return assert!(
false,
"couldn't get file metadata"
);
}; };
assert!(size > 0); 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 crate::{Background, Slide, SlideBuilder, TextAlignment};
use super::{ use super::content::Content;
content::Content, use super::kinds::ServiceItemKind;
kinds::ServiceItemKind, use super::model::{LibraryKind, Model};
model::{LibraryKind, Model}, use super::service_items::ServiceTrait;
service_items::ServiceTrait,
};
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result}; use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::types::chrono::{DateTime, Local};
Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection, use sqlx::{AssertSqlSafe, SqliteConnection, SqlitePool, query, query_as};
query, query_as, use std::mem::replace;
};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{debug, error}; use std::sync::Arc;
use tracing::error;
#[derive( #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub struct Image { pub struct Image {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
pub path: PathBuf, pub path: PathBuf,
#[serde(skip)]
pub created_at: DateTime<Local>,
#[serde(skip)]
pub accessed_at: DateTime<Local>,
} }
impl From<PathBuf> for Image { impl From<PathBuf> for Image {
@ -37,6 +39,8 @@ impl From<PathBuf> for Image {
id: 0, id: 0,
title, title,
path: value.canonicalize().unwrap_or(value), 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 { fn from(value: &Value) -> Self {
match value { match value {
Value::List(list) => { Value::List(list) => {
let path = if let Some(path_pos) = let path = if let Some(path_pos) = list
list.iter().position(|v| { .iter()
v == &Value::Keyword(Keyword::from("source")) .position(|v| v == &Value::Keyword(Keyword::from("source")))
}) { {
let pos = path_pos + 1; let pos = path_pos + 1;
list.get(pos) list.get(pos).map(|p| PathBuf::from(String::from(p)))
.map(|p| PathBuf::from(String::from(p)))
} else { } else {
None None
}; };
let title = path.clone().map(|p| { let title = path.clone().map(|p| {
let path = let path = p.to_str().unwrap_or_default().to_string();
p.to_str().unwrap_or_default().to_string(); let title = path.rsplit_once('/').unwrap_or_default().1;
let title =
path.rsplit_once('/').unwrap_or_default().1;
title.to_string() title.to_string()
}); });
Self { Self {
@ -133,10 +134,7 @@ impl ServiceTrait for Image {
fn to_slides(&self) -> Result<Vec<Slide>> { fn to_slides(&self) -> Result<Vec<Slide>> {
let slide = SlideBuilder::new() let slide = SlideBuilder::new()
.background( .background(Background::try_from(self.path.clone()).into_diagnostic()?)
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("") .text("")
.audio("") .audio("")
.font("") .font("")
@ -156,10 +154,11 @@ impl ServiceTrait for Image {
} }
impl Model<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 { let mut model = Self {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let mut db = db.acquire().await.expect("probs"); 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) { pub async fn load_from_db(&mut self, db: &mut SqliteConnection) {
let result = query_as!( let result = query_as!(
Image, 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) .fetch_all(db)
.await; .await;
@ -182,83 +181,150 @@ impl Model<Image> {
} }
} }
Err(e) => { Err(e) => {
error!( error!("There was an error in converting images: {e}");
"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( pub async fn remove_images(
db: PoolConnection<Sqlite>, db: Arc<SqlitePool>,
id: i32, images: Vec<Image>,
) -> Result<()> { ids: Vec<i32>,
query!("DELETE FROM images WHERE id = $1", id) ) -> Result<Vec<Image>> {
.execute(&mut db.detach()) 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 .await
.into_diagnostic() .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, image: Image,
db: PoolConnection<Sqlite>, mut images: Vec<Image>,
) -> Result<()> { db: Arc<SqlitePool>,
) -> Result<Vec<Image>> {
let path = image let path = image
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(std::string::ToString::to_string)
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach();
query!( 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"#, r#"UPDATE images SET title = $2, file_path = $3 WHERE id = $1"#,
image.id, image.id,
image.title, image.title,
path, path,
) )
.execute(&mut db) .execute(&*db)
.await.into_diagnostic(); .await
.into_diagnostic()?;
match result { let current_image = images
Ok(_) => { .iter()
debug!("should have been updated"); .position(|current_image| current_image.id == image.id)
Ok(()) .ok_or_else(|| miette!("Could not find image in model"))
} .map(|index| {
Err(e) => { images
error! {?e}; .get_mut(index)
Err(e) .expect("We should have this image already")
} })?;
}
let _ = replace(current_image, image);
Ok(images)
} }
pub async fn get_image_from_db( pub async fn get_from_db(database_id: i32, db: &mut SqliteConnection) -> Result<Image> {
database_id: i32, 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()
db: &mut SqliteConnection,
) -> Result<Image> {
query_as!(Image, r#"SELECT title as "title!", file_path as "path!", id as "id: i32" from images where id = ?"#, database_id).fetch_one(db).await.into_diagnostic()
} }
#[cfg(test)] #[cfg(test)]
@ -269,9 +335,7 @@ mod test {
fn test_image(title: String) -> Image { fn test_image(title: String) -> Image {
Image { Image {
title, title,
path: PathBuf::from( path: PathBuf::from("/home/chris/pics/memes/no-i-dont-think.gif"),
"/home/chris/pics/memes/no-i-dont-think.gif",
),
..Default::default() ..Default::default()
} }
} }
@ -281,14 +345,20 @@ mod test {
let mut image_model: Model<Image> = Model { let mut image_model: Model<Image> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Image, 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; image_model.load_from_db(&mut db).await;
if let Some(image) = image_model.find(|i| i.id == 23) { if let Some(image) = image_model.find(|i| i.id == 23) {
let test_image = test_image("no-i-dont-think.gif".into()); let test_image = test_image("no-i-dont-think.gif".into());
assert_eq!(test_image.title, image.title); assert_eq!(test_image.title, image.title);
} else { } else {
assert!(false); panic!();
} }
} }
@ -298,25 +368,18 @@ mod test {
let mut image_model: Model<Image> = Model { let mut image_model: Model<Image> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Image, kind: LibraryKind::Image,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let result = image_model.add_item(image.clone()); let result = image_model.add_item(image.clone());
let new_image = test_image("A newer image".into()); let new_image = test_image("A newer image".into());
match result { match result {
Ok(_) => { Ok(()) => {
assert_eq!( assert_eq!(&image, image_model.find(|i| i.id == 0).expect(""));
&image, assert_ne!(&new_image, image_model.find(|i| i.id == 0).expect(""));
image_model.find(|i| i.id == 0).unwrap() }
); Err(e) => {
assert_ne!( panic!("There was an error adding the image: {e:?}",)
&new_image,
image_model.find(|i| i.id == 0).unwrap()
);
} }
Err(e) => assert!(
false,
"There was an error adding the image: {:?}",
e
),
} }
} }

View file

@ -1,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 serde::{Deserialize, Serialize};
use crate::{ use crate::Slide;
Slide, use crate::core::content::Content;
core::{content::Content, service_items::ServiceItem}, use crate::core::service_items::ServiceItem;
};
use super::{ use super::images::Image;
images::Image, presentations::Presentation, songs::Song, use super::presentations::Presentation;
videos::Video, use super::songs::Song;
}; use super::videos::Video;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServiceItemKind { pub enum ServiceItemKind {
@ -28,18 +29,10 @@ impl TryFrom<PathBuf> for ServiceItemKind {
let ext = path let ext = path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.ok_or_else(|| { .ok_or_else(|| miette::miette!("There isn't an extension on this file"))?;
miette::miette!(
"There isn't an extension on this file"
)
})?;
match ext { match ext {
"png" | "jpg" | "jpeg" => { "png" | "jpg" | "jpeg" => Ok(Self::Image(Image::from(path))),
Ok(Self::Image(Image::from(path))) "mp4" | "mkv" | "webm" => Ok(Self::Video(Video::from(path))),
}
"mp4" | "mkv" | "webm" => {
Ok(Self::Video(Video::from(path)))
}
"pdf" => Ok(Self::Presentation(Presentation::from(path))), "pdf" => Ok(Self::Presentation(Presentation::from(path))),
_ => Err(miette::miette!("Unknown item")), _ => Err(miette::miette!("Unknown item")),
} }
@ -52,9 +45,7 @@ impl ServiceItemKind {
Self::Song(song) => song.title.clone(), Self::Song(song) => song.title.clone(),
Self::Video(video) => video.title.clone(), Self::Video(video) => video.title.clone(),
Self::Image(image) => image.title.clone(), Self::Image(image) => image.title.clone(),
Self::Presentation(presentation) => { Self::Presentation(presentation) => presentation.title.clone(),
presentation.title.clone()
}
Self::Content(_slide) => todo!(), Self::Content(_slide) => todo!(),
} }
} }
@ -64,9 +55,7 @@ impl ServiceItemKind {
Self::Song(song) => song.to_service_item(), Self::Song(song) => song.to_service_item(),
Self::Video(video) => video.to_service_item(), Self::Video(video) => video.to_service_item(),
Self::Image(image) => image.to_service_item(), Self::Image(image) => image.to_service_item(),
Self::Presentation(presentation) => { Self::Presentation(presentation) => presentation.to_service_item(),
presentation.to_service_item()
}
Self::Content(_slide) => { Self::Content(_slide) => {
todo!() todo!()
} }
@ -111,9 +100,7 @@ impl From<ServiceItemKind> for String {
ServiceItemKind::Song(_) => "song".to_owned(), ServiceItemKind::Song(_) => "song".to_owned(),
ServiceItemKind::Video(_) => "video".to_owned(), ServiceItemKind::Video(_) => "video".to_owned(),
ServiceItemKind::Image(_) => "image".to_owned(), ServiceItemKind::Image(_) => "image".to_owned(),
ServiceItemKind::Presentation(_) => { ServiceItemKind::Presentation(_) => "presentation".to_owned(),
"presentation".to_owned()
}
ServiceItemKind::Content(_) => "content".to_owned(), ServiceItemKind::Content(_) => "content".to_owned(),
} }
} }
@ -127,10 +114,7 @@ pub enum ParseError {
impl Error for ParseError {} impl Error for ParseError {}
impl Display for ParseError { impl Display for ParseError {
fn fmt( fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
let message = match self { let message = match self {
Self::UnknownType => { Self::UnknownType => {
"The type does not exist. It needs to be one of 'song', 'video', 'image', 'presentation', or 'content'" "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 { mod test {
#[test] #[test]
pub fn test_kinds() { 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 songs;
pub mod thumbnail; pub mod thumbnail;
pub mod videos; 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 cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
@ -10,11 +13,10 @@ use tracing::debug;
pub struct Model<T> { pub struct Model<T> {
pub items: Vec<T>, pub items: Vec<T>,
pub kind: LibraryKind, pub kind: LibraryKind,
pub sorting_method: Sort,
} }
#[derive( #[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize)]
Debug, Clone, PartialEq, Eq, Copy, Hash, Serialize, Deserialize,
)]
pub enum LibraryKind { pub enum LibraryKind {
Song, Song,
Video, Video,
@ -22,9 +24,21 @@ pub enum LibraryKind {
Presentation, Presentation,
} }
#[derive( #[derive(Debug, Clone, Eq, PartialEq, Copy, Serialize, Deserialize)]
Debug, Clone, PartialEq, Eq, Hash, 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)); pub struct KindWrapper(pub (LibraryKind, i32));
impl From<PathBuf> for LibraryKind { impl From<PathBuf> for LibraryKind {
@ -36,14 +50,10 @@ impl From<PathBuf> for LibraryKind {
impl TryFrom<(Vec<u8>, String)> for KindWrapper { impl TryFrom<(Vec<u8>, String)> for KindWrapper {
type Error = miette::Error; type Error = miette::Error;
fn try_from( fn try_from(value: (Vec<u8>, String)) -> std::result::Result<Self, Self::Error> {
value: (Vec<u8>, String),
) -> std::result::Result<Self, Self::Error> {
let (data, mime) = value; let (data, mime) = value;
match mime.as_str() { match mime.as_str() {
"application/service-item" => { "application/service-item" => ron::de::from_bytes(&data).into_diagnostic(),
ron::de::from_bytes(&data).into_diagnostic()
}
_ => Err(miette!("Wrong mime type: {mime}")), _ => Err(miette!("Wrong mime type: {mime}")),
} }
} }
@ -61,10 +71,7 @@ impl AsMimeTypes for KindWrapper {
Cow::from(vec!["application/service-item".to_string()]) Cow::from(vec!["application/service-item".to_string()])
} }
fn as_bytes( fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
&self,
mime_type: &str,
) -> Option<std::borrow::Cow<'static, [u8]>> {
debug!(?self); debug!(?self);
debug!(mime_type); debug!(mime_type);
let ron = ron::ser::to_string(self).ok()?; let ron = ron::ser::to_string(self).ok()?;
@ -83,37 +90,41 @@ impl<T> Model<T> {
todo!() 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 self.items
.get_mut( .iter()
usize::try_from(index) .position(predicate)
.expect("Shouldn't be negative"), .ok_or_else(|| miette!("Item cannot be found"))
) .map(|index| {
.map_or_else( self.items
|| { .get_mut(index)
Err(miette!( .expect("Since we found position this should always exist")
"Item doesn't exist in model. Id was {index}" })
)) .map(|current_item| {
}, let _old_item = replace(current_item, item);
|current_item| { })
let _old_item = replace(current_item, item);
Ok(())
},
)
} }
pub fn remove_item(&mut self, index: i32) -> Result<()> { pub fn remove_item<P>(&mut self, predicate: P) -> Result<()>
self.items.remove( where
usize::try_from(index).expect("Shouldn't be negative"), P: Fn(&T) -> bool,
); {
Ok(()) self.items
.iter()
.position(predicate)
.ok_or_else(|| miette!("Item cannot be found"))
.map(|index| {
self.items.remove(index);
})
} }
#[must_use] #[must_use]
pub fn get_item(&self, index: i32) -> Option<&T> { pub fn get_item(&self, index: i32) -> Option<&T> {
self.items.get( self.items
usize::try_from(index).expect("shouldn't be negative"), .get(usize::try_from(index).expect("shouldn't be negative"))
)
} }
pub fn find<P>(&self, f: P) -> Option<&T> pub fn find<P>(&self, f: P) -> Option<&T>
@ -123,11 +134,8 @@ impl<T> Model<T> {
self.items.iter().find(f) self.items.iter().find(f)
} }
pub fn insert_item(&mut self, item: T, index: i32) -> Result<()> { pub fn insert_item(&mut self, item: T, index: usize) -> Result<()> {
self.items.insert( self.items.insert(index, item);
usize::try_from(index).expect("Shouldn't be negative"),
item,
);
Ok(()) Ok(())
} }
} }
@ -144,8 +152,7 @@ impl<T> Model<T> {
// } // }
pub async fn get_db() -> SqliteConnection { pub async fn get_db() -> SqliteConnection {
let mut data = dirs::data_local_dir() let mut data = dirs::data_local_dir().expect("Should be able to find a data dir");
.expect("Should be able to find a data dir");
data.push("lumina"); data.push("lumina");
let _ = fs::create_dir_all(&data); let _ = fs::create_dir_all(&data);
data.push("library-db.sqlite3"); data.push("library-db.sqlite3");

View file

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

View file

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

View file

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

View file

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

View file

@ -1,37 +1,146 @@
use crate::core::songs::{Song, VerseName};
use itertools::Itertools; use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette}; 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 reqwest::header;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use std::fmt::Display;
use tracing::error;
#[derive( #[derive(
Clone, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
Debug,
Default,
PartialEq,
PartialOrd,
Ord,
Eq,
Serialize,
Deserialize,
)] )]
pub struct OnlineSong { pub struct OnlineSong {
pub lyrics: String, pub lyrics: String,
pub title: String, pub title: String,
pub author: String, pub author: String,
pub site: String, pub provider: Provider,
pub link: String, pub link: String,
} }
pub async fn search_genius_links( #[derive(
query: impl AsRef<str> + std::fmt::Display, Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
) -> Result<Vec<OnlineSong>> { )]
let auth_token = env!("GENIUS_TOKEN"); 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(); let mut headers = header::HeaderMap::new();
headers.insert( headers.insert(header::AUTHORIZATION, head_value);
header::AUTHORIZATION,
header::HeaderValue::from_static(auth_token),
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.default_headers(headers) .default_headers(headers)
.build() .build()
@ -46,8 +155,7 @@ pub async fn search_genius_links(
.text() .text()
.await .await
.into_diagnostic()?; .into_diagnostic()?;
let json: Value = let json: Value = serde_json::from_str(&response).into_diagnostic()?;
serde_json::from_str(&response).into_diagnostic()?;
let hits = json let hits = json
.get("response") .get("response")
.expect("respose") .expect("respose")
@ -55,12 +163,11 @@ pub async fn search_genius_links(
.expect("hits") .expect("hits")
.as_array() .as_array()
.expect("array"); .expect("array");
Ok(hits let songs: Vec<Option<OnlineSong>> =
.iter() cosmic::iced::futures::future::join_all(hits.iter().map(|hit| async {
.map(|hit| {
let result = hit.get("result").expect("result"); let result = hit.get("result").expect("result");
let title = result let title = result
.get("full_title") .get("title")
.expect("title") .expect("title")
.as_str() .as_str()
.expect("title") .expect("title")
@ -78,20 +185,27 @@ pub async fn search_genius_links(
.as_str() .as_str()
.expect("url") .expect("url")
.to_string(); .to_string();
OnlineSong { let song = OnlineSong {
lyrics: String::new(), lyrics: String::new(),
title, title,
author, author,
site: String::from("https://genius.com"), provider: Provider::Genius { parsable: false },
link, 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( pub async fn get_genius_lyrics(mut song: OnlineSong) -> Result<OnlineSong> {
mut song: OnlineSong,
) -> Result<OnlineSong> {
let html = reqwest::get(&song.link) let html = reqwest::get(&song.link)
.await .await
.into_diagnostic()? .into_diagnostic()?
@ -101,31 +215,64 @@ pub async fn get_genius_lyrics(
.await .await
.into_diagnostic()?; .into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(lyrics_root_selector) = scraper::Selector::parse( let Ok(lyrics_root_selector) =
r#"div[data-lyrics-container="true"]"#, scraper::Selector::parse(r#"div[data-lyrics-container="true"]"#)
) else { else {
return Err(miette!("error in finding lyrics_root")); return Err(miette!("error in finding lyrics_root"));
}; };
let lyrics = document let lyrics = document
.select(&lyrics_root_selector) .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); // 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>(); .collect::<String>();
let lyrics = lyrics.find('[').map_or_else( let lyrics = lyrics.find('[').map_or_else(
|| { || {
lyrics.find("</div></div></div>").map_or_else( lyrics.find("</div></div></div>").map_or_else(
|| lyrics.clone(), || lyrics.clone(),
|position| { |position| lyrics.split_at(position + 18).1.to_string(),
lyrics.split_at(position + 18).1.to_string()
},
) )
}, },
|position| lyrics.split_at(position).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; song.lyrics = lyrics;
Ok(song) Ok(song)
} }
@ -133,20 +280,17 @@ pub async fn get_genius_lyrics(
pub async fn search_lyrics_com_links( pub async fn search_lyrics_com_links(
query: impl AsRef<str> + std::fmt::Display, query: impl AsRef<str> + std::fmt::Display,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let html = let html = reqwest::get(format!("http://www.lyrics.com/lyrics/{query}"))
reqwest::get(format!("http://www.lyrics.com/lyrics/{query}")) .await
.await .into_diagnostic()?
.into_diagnostic()? .error_for_status()
.error_for_status() .into_diagnostic()?
.into_diagnostic()? .text()
.text() .await
.await .into_diagnostic()?;
.into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(best_matches_selector) = let Ok(best_matches_selector) = scraper::Selector::parse(".best-matches") else {
scraper::Selector::parse(".best-matches")
else {
return Err(miette!("error in finding matches")); return Err(miette!("error in finding matches"));
}; };
let Ok(lyric_selector) = scraper::Selector::parse("a") else { let Ok(lyric_selector) = scraper::Selector::parse("a") else {
@ -156,9 +300,7 @@ pub async fn search_lyrics_com_links(
Ok(document Ok(document
.select(&best_matches_selector) .select(&best_matches_selector)
.flat_map(|best_section| best_section.select(&lyric_selector)) .flat_map(|best_section| best_section.select(&lyric_selector))
.map(|a| { .map(|a| a.value().attr("href").unwrap_or("").trim().to_string())
a.value().attr("href").unwrap_or("").trim().to_string()
})
.filter(|a| a.contains("/lyric/")) .filter(|a| a.contains("/lyric/"))
.dedup() .dedup()
.map(|link| { .map(|link| {
@ -198,9 +340,7 @@ pub async fn lyrics_com_link_to_song(
.into_diagnostic()?; .into_diagnostic()?;
let document = scraper::Html::parse_document(&html); let document = scraper::Html::parse_document(&html);
let Ok(lyric_selector) = let Ok(lyric_selector) = scraper::Selector::parse(".lyric-body") else {
scraper::Selector::parse(".lyric-body")
else {
return Err(miette!("error in finding lyric-body",)); return Err(miette!("error in finding lyric-body",));
}; };
@ -215,7 +355,7 @@ pub async fn lyrics_com_link_to_song(
lyrics, lyrics,
title: title.clone(), title: title.clone(),
author: author.clone(), author: author.clone(),
site: "https://www.lyrics.com".into(), provider: Provider::LyricsCom,
link, link,
}; };
@ -236,35 +376,57 @@ mod test {
async fn genius() -> Result<(), String> { async fn genius() -> Result<(), String> {
let song = OnlineSong { let song = OnlineSong {
lyrics: String::new(), 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(), author: "North Point Worship (Ft. Seth Condrey)".to_string(),
site: "https://genius.com".to_string(), provider: Provider::Genius { parsable: false },
link: "https://genius.com/North-point-worship-death-was-arrested-lyrics".to_string(), link: "https://genius.com/North-point-worship-death-was-arrested-lyrics"
.to_string(),
}; };
let hits = search_genius_links("Death was arrested") let hits = search_genius(
.await "Death was arrested".to_string(),
.map_err(|e| e.to_string())?; env!("GENIUS_TOKEN").to_string(),
)
.await
.map_err(|e| e.to_string())?;
assert!( assert!(
hits.iter().find(|hit| **hit == song).is_some(), hits[0].title == song.title,
"There was no song that matched on Genius" "There was no song that matched on Genius"
); );
let titles: Vec<String> = let titles: Vec<String> = hits.iter().map(|song| song.title.clone()).collect();
hits.iter().map(|song| song.title.clone()).collect();
dbg!(titles); dbg!(titles);
for hit in hits { for hit in hits {
let new_song = get_genius_lyrics(hit) let new_song = get_genius_lyrics(hit).await.map_err(|e| e.to_string())?;
.await
.map_err(|e| e.to_string())?;
dbg!(&new_song); dbg!(&new_song);
if !new_song.lyrics.starts_with("[Verse 1]") { dbg!(&new_song.provider);
assert!(new_song.lyrics.len() > 10); if new_song.lyrics.starts_with("[Verse 1]") {
} else {
assert!(new_song.lyrics.contains("[Verse 2]")); assert!(new_song.lyrics.contains("[Verse 2]"));
if !new_song.lyrics.contains("[Chorus]") { 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(), 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(), title: "Death Was Arrested".to_string(),
author: "North Point InsideOut".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(), link: "https://www.lyrics.com/lyric/35090938/North+Point+InsideOut/Death+Was+Arrested".to_string(),
}; };
let links = search_lyrics_com_links("Death was arrested") let links = search_lyrics_com_links("Death was arrested")
@ -286,47 +448,131 @@ mod test {
let songs = lyrics_com_link_to_song(links) let songs = lyrics_com_link_to_song(links)
.await .await
.map_err(|e| format!("{e}"))?; .map_err(|e| format!("{e}"))?;
if let Some(first) = songs.iter().find_or_first(|song| { if let Some(first) = songs
song.author == "North Point InsideOut" .iter()
}) { .find_or_first(|song| song.author == "North Point InsideOut")
{
assert_eq!(&song, first); assert_eq!(&song, first);
online_song_to_song(song)? // online_song_to_song(song)?;
} }
Ok(()) Ok(())
} }
#[allow(dead_code)]
fn online_song_to_song(song: OnlineSong) -> Result<(), String> { fn online_song_to_song(song: OnlineSong) -> Result<(), String> {
let song = Song::from(song); let song = Song::from(song);
if let Some(verse_map) = song.verse_map.as_ref() { if let Some(verse_map) = song.verse_map.as_ref() {
if verse_map.len() < 2 { if verse_map.is_empty() {
return Err(format!( return Err(format!("VerseMap wasn't built right likely: {song:?}",));
"VerseMap wasn't built right likely: {:?}",
song
));
} }
} else { } else {
return Err(String::from( return Err(String::from("There is no VerseMap in this song"));
"There is no VerseMap in this song", }
));
};
Ok(()) Ok(())
} }
#[tokio::test] // #[tokio::test]
async fn online_search() { // async fn online_search() {
let search = // let search =
search_lyrics_com_links("Death was arrested").await; // search_lyrics_com_links("Death was arrested").await;
match search { // match search {
Ok(songs) => { // Ok(songs) => {
assert_eq!( // assert_eq!(
songs, // songs,
vec![ // vec![
"33755723/Various+Artists/Death+Was+Arrested", // "33755723/Various+Artists/Death+Was+Arrested",
"35090938/North+Point+InsideOut/Death+Was+Arrested" // "35090938/North+Point+InsideOut/Death+Was+Arrested"
] // ]
); // );
} // }
Err(e) => assert!(false, "{}", e), // 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 dirs;
use std::error::Error; use std::error::Error;
use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::str; use std::{fs, str};
use tracing::debug; use tracing::debug;
pub fn bg_from_video( pub fn bg_from_video(video: &Path, screenshot: &Path) -> Result<(), Box<dyn Error>> {
video: &Path,
screenshot: &Path,
) -> Result<(), Box<dyn Error>> {
if screenshot.exists() { if screenshot.exists() {
debug!("Screenshot already exists"); debug!("Screenshot already exists");
} else { } else {
@ -40,10 +36,8 @@ pub fn bg_from_video(
} }
} }
let hours: i32 = hours.parse().unwrap_or_default(); let hours: i32 = hours.parse().unwrap_or_default();
let mut minutes: i32 = let mut minutes: i32 = minutes.parse().unwrap_or_default();
minutes.parse().unwrap_or_default(); let mut seconds: i32 = seconds.parse().unwrap_or_default();
let mut seconds: i32 =
seconds.parse().unwrap_or_default();
minutes += hours * 60; minutes += hours * 60;
seconds += minutes * 60; seconds += minutes * 60;
at_second = seconds / 5; at_second = seconds / 5;
@ -71,18 +65,15 @@ pub fn bg_from_video(
pub fn bg_path_from_video(video: &Path) -> PathBuf { pub fn bg_path_from_video(video: &Path) -> PathBuf {
let video = PathBuf::from(video); let video = PathBuf::from(video);
debug!(?video); debug!(?video);
let mut data_dir = let mut data_dir = dirs::cache_dir().expect("Can't find cache dir");
dirs::cache_dir().expect("Can't find cache dir");
data_dir.push("lumina"); data_dir.push("lumina");
data_dir.push("thumbnails"); data_dir.push("thumbnails");
let _ = fs::create_dir_all(&data_dir); let _ = fs::create_dir_all(&data_dir);
if !data_dir.exists() { if !data_dir.exists() {
fs::create_dir(&data_dir) fs::create_dir(&data_dir).expect("Could not create thumbnails dir");
.expect("Could not create thumbnails dir");
} }
let mut screenshot = data_dir.clone(); let mut screenshot = data_dir.clone();
screenshot screenshot.push(video.file_name().expect("Should have file name"));
.push(video.file_name().expect("Should have file name"));
screenshot.set_extension("png"); screenshot.set_extension("png");
screenshot screenshot
} }
@ -97,11 +88,9 @@ mod test {
let screenshot = bg_path_from_video(video); let screenshot = bg_path_from_video(video);
match bg_from_video(video, &screenshot) { match bg_from_video(video, &screenshot) {
Ok(_o) => assert!(screenshot.exists()), Ok(_o) => assert!(screenshot.exists()),
Err(e) => debug_assert!( Err(e) => {
false, debug_assert!(false, "There was an error in the runtime future. {e}",);
"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 crate::{Background, SlideBuilder, TextAlignment};
use super::{ use super::content::Content;
content::Content, use super::kinds::ServiceItemKind;
kinds::ServiceItemKind, use super::model::{LibraryKind, Model};
model::{LibraryKind, Model}, use super::service_items::ServiceTrait;
service_items::ServiceTrait, use super::slide::Slide;
slide::Slide,
};
use crisp::types::{Keyword, Symbol, Value}; use crisp::types::{Keyword, Symbol, Value};
use miette::{IntoDiagnostic, Result}; use itertools::Itertools;
use miette::{IntoDiagnostic, Result, miette};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::types::chrono::{DateTime, Local};
Sqlite, SqliteConnection, SqlitePool, pool::PoolConnection, use sqlx::{AssertSqlSafe, Decode, SqliteConnection, SqlitePool, query, query_as};
query, query_as, use std::mem::replace;
};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{debug, error}; use std::sync::Arc;
use tracing::error;
#[derive( #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Decode)]
Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
pub struct Video { pub struct Video {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
@ -27,6 +25,10 @@ pub struct Video {
pub start_time: Option<f32>, pub start_time: Option<f32>,
pub end_time: Option<f32>, pub end_time: Option<f32>,
pub looping: bool, pub looping: bool,
#[serde(skip)]
pub accessed_at: DateTime<Local>,
#[serde(skip)]
pub created_at: DateTime<Local>,
} }
impl From<&Video> for Value { impl From<&Video> for Value {
@ -97,30 +99,21 @@ impl From<&Value> for Video {
Value::List(list) => { Value::List(list) => {
let path = list let path = list
.iter() .iter()
.position(|v| { .position(|v| v == &Value::Keyword(Keyword::from("source")))
v == &Value::Keyword(Keyword::from("source"))
})
.and_then(|path_pos| { .and_then(|path_pos| {
let pos = path_pos + 1; let pos = path_pos + 1;
list.get(pos) list.get(pos).map(|p| PathBuf::from(String::from(p)))
.map(|p| PathBuf::from(String::from(p)))
}); });
let title = path.clone().map(|p| { let title = path.clone().map(|p| {
let path = let path = p.to_str().unwrap_or_default().to_string();
p.to_str().unwrap_or_default().to_string(); let title = path.rsplit_once('/').unwrap_or_default().1;
let title =
path.rsplit_once('/').unwrap_or_default().1;
title.to_string() title.to_string()
}); });
let start_time = list let start_time = list
.iter() .iter()
.position(|v| { .position(|v| v == &Value::Keyword(Keyword::from("start-time")))
v == &Value::Keyword(Keyword::from(
"start-time",
))
})
.and_then(|start_pos| { .and_then(|start_pos| {
let pos = start_pos + 1; let pos = start_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32) list.get(pos).map(|p| i32::from(p) as f32)
@ -128,11 +121,7 @@ impl From<&Value> for Video {
let end_time = list let end_time = list
.iter() .iter()
.position(|v| { .position(|v| v == &Value::Keyword(Keyword::from("end-time")))
v == &Value::Keyword(Keyword::from(
"end-time",
))
})
.and_then(|end_pos| { .and_then(|end_pos| {
let pos = end_pos + 1; let pos = end_pos + 1;
list.get(pos).map(|p| i32::from(p) as f32) list.get(pos).map(|p| i32::from(p) as f32)
@ -140,14 +129,10 @@ impl From<&Value> for Video {
let looping = list let looping = list
.iter() .iter()
.position(|v| { .position(|v| v == &Value::Keyword(Keyword::from("loop")))
v == &Value::Keyword(Keyword::from("loop"))
})
.is_some_and(|loop_pos| { .is_some_and(|loop_pos| {
let pos = loop_pos + 1; let pos = loop_pos + 1;
list.get(pos).is_some_and(|l| { list.get(pos).is_some_and(|l| String::from(l) == *"true")
String::from(l) == *"true"
})
}); });
Self { Self {
@ -175,10 +160,7 @@ impl ServiceTrait for Video {
fn to_slides(&self) -> Result<Vec<Slide>> { fn to_slides(&self) -> Result<Vec<Slide>> {
let slide = SlideBuilder::new() let slide = SlideBuilder::new()
.background( .background(Background::try_from(self.path.clone()).into_diagnostic()?)
Background::try_from(self.path.clone())
.into_diagnostic()?,
)
.text("") .text("")
.audio("") .audio("")
.font("") .font("")
@ -198,19 +180,18 @@ impl ServiceTrait for Video {
} }
impl Model<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 { let mut model = Self {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let mut db = db.acquire().await.expect("probs"); model.load_from_db(db).await;
model.load_from_db(&mut db).await;
model 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_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; 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 { match result {
Ok(v) => { Ok(v) => {
for video in v { for video in v {
@ -218,61 +199,129 @@ impl Model<Video> {
} }
} }
Err(e) => { Err(e) => {
error!( error!("There was an error in converting videos: {e}");
"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( pub async fn remove_videos(
db: PoolConnection<Sqlite>, db: Arc<SqlitePool>,
id: i32, videos: Vec<Video>,
) -> Result<()> { ids: Vec<i32>,
query!("DELETE FROM videos WHERE id = $1", id) ) -> Result<Vec<Video>> {
.execute(&mut db.detach()) 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 .await
.into_diagnostic() .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, video: Video,
db: PoolConnection<Sqlite>, mut videos: Vec<Video>,
) -> Result<()> { db: Arc<SqlitePool>,
) -> Result<Vec<Video>> {
let path = video let path = video
.path .path
.to_str() .to_str()
.map(std::string::ToString::to_string) .map(std::string::ToString::to_string)
.unwrap_or_default(); .unwrap_or_default();
let mut db = db.detach();
query!( 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"#, r#"UPDATE videos SET title = $2, file_path = $3, start_time = $4, end_time = $5, loop = $6 WHERE id = $1"#,
video.id, video.id,
video.title, video.title,
@ -281,26 +330,25 @@ pub async fn update_video_in_db(
video.end_time, video.end_time,
video.looping, video.looping,
) )
.execute(&mut db) .execute(&*db)
.await.into_diagnostic(); .await.into_diagnostic()?;
match result { let current_video = videos
Ok(_) => { .iter()
debug!("should have been updated"); .position(|current_video| current_video.id == video.id)
Ok(()) .ok_or_else(|| miette!("Could not find video in model"))
} .map(|index| {
Err(e) => { videos
error! {?e}; .get_mut(index)
Err(e) .expect("We should have this video already")
} })?;
}
let _ = replace(current_video, video);
Ok(videos)
} }
pub async fn get_video_from_db( pub async fn get_from_db(database_id: i32, db: &mut SqliteConnection) -> Result<Video> {
database_id: i32, 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()
db: &mut SqliteConnection,
) -> Result<Video> {
query_as!(Video, r#"SELECT title as "title!", file_path as "path!", start_time as "start_time!: f32", end_time as "end_time!: f32", loop as "looping!", id as "id: i32" from videos where id = ?"#, database_id).fetch_one(db).await.into_diagnostic()
} }
#[cfg(test)] #[cfg(test)]
@ -312,7 +360,7 @@ mod test {
Video { Video {
title, title,
path: PathBuf::from( path: PathBuf::from(
"/home/chris/docs/notes/lessons/christ-our-hope.mp4", "/home/chris/nc/tfc/Documents/lessons/videos/christ-nutshell.mp4",
), ),
..Default::default() ..Default::default()
} }
@ -323,14 +371,15 @@ mod test {
let mut video_model: Model<Video> = Model { let mut video_model: Model<Video> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let mut db = add_db().await.unwrap().acquire().await.unwrap(); let db = Arc::new(add_db().await.expect(""));
video_model.load_from_db(&mut db).await; video_model.load_from_db(db).await;
if let Some(video) = video_model.find(|v| v.id == 2) { if let Some(video) = video_model.find(|v| v.id == 2) {
let test_video = test_video("christ-our-hope.mp4".into()); let test_video = test_video("christ-our-hope.mp4".into());
assert_eq!(test_video.title, video.title); assert_eq!(test_video.title, video.title);
} else { } else {
assert!(false); panic!();
} }
} }
@ -340,25 +389,18 @@ mod test {
let mut video_model: Model<Video> = Model { let mut video_model: Model<Video> = Model {
items: vec![], items: vec![],
kind: LibraryKind::Video, kind: LibraryKind::Video,
sorting_method: Sort::AccessTime(SortDirection::Descending),
}; };
let result = video_model.add_item(video.clone()); let result = video_model.add_item(video.clone());
let new_video = test_video("A newer video".into()); let new_video = test_video("A newer video".into());
match result { match result {
Ok(_) => { Ok(()) => {
assert_eq!( assert_eq!(&video, video_model.find(|v| v.id == 0).expect(""));
&video, assert_ne!(&new_video, video_model.find(|v| v.id == 0).expect(""));
video_model.find(|v| v.id == 0).unwrap() }
); Err(e) => {
assert_ne!( panic!("There was an error adding the video: {e}",)
&new_video,
video_model.find(|v| v.id == 0).unwrap()
);
} }
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
})
}

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

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

View file

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

View file

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

1901
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::{ use std::fmt::{Display, Write};
fmt::{Display, Write}, use std::fs;
fs, use std::hash::{Hash, Hasher};
hash::{Hash, Hasher}, use std::path::PathBuf;
path::PathBuf, use std::sync::Arc;
sync::Arc,
};
use cosmic::{ use cosmic::cosmic_theme::palette::rgb::Rgba;
cosmic_theme::palette::{IntoColor, Srgb, rgb::Rgba}, use cosmic::cosmic_theme::palette::{IntoColor, Srgb};
iced::{ use cosmic::iced::font::{Style, Weight};
ContentFit, Length, Size, use cosmic::iced::{ContentFit, Length, Size};
font::{Style, Weight}, use cosmic::prelude::*;
}, use cosmic::widget::image::Handle;
prelude::*, use cosmic::widget::{Image, Space};
widget::{Image, Space, image::Handle},
};
use derive_more::Debug; use derive_more::Debug;
use miette::{IntoDiagnostic, Result, miette}; use miette::{IntoDiagnostic, Result, miette};
use rapidhash::v3::rapidhash_v3; use rapidhash::v3::rapidhash_v3;
use resvg::{ use resvg::tiny_skia::{self, Pixmap};
tiny_skia::{self, Pixmap}, use resvg::usvg::{Tree, fontdb};
usvg::{Tree, fontdb},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use crate::{TextAlignment, core::slide::Slide}; use crate::TextAlignment;
use crate::core::slide::Slide;
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TextSvg { pub struct TextSvg {
@ -68,9 +63,7 @@ impl Hash for TextSvg {
} }
} }
#[derive( #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct Font { pub struct Font {
name: String, name: String,
weight: Weight, weight: Weight,
@ -78,9 +71,7 @@ pub struct Font {
size: u8, size: u8,
} }
#[derive( #[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)]
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize,
)]
pub struct Shadow { pub struct Shadow {
pub offset_x: i16, pub offset_x: i16,
pub offset_y: i16, pub offset_y: i16,
@ -88,9 +79,7 @@ pub struct Shadow {
pub color: Color, pub color: Color,
} }
#[derive( #[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)]
Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize,
)]
pub struct Stroke { pub struct Stroke {
size: u16, size: u16,
color: Color, color: Color,
@ -107,9 +96,7 @@ impl From<cosmic::font::Font> for Font {
fn from(value: cosmic::font::Font) -> Self { fn from(value: cosmic::font::Font) -> Self {
Self { Self {
name: match value.family { name: match value.family {
cosmic::iced::font::Family::Name(name) => { cosmic::iced::font::Family::Name(name) => name.to_string(),
name.to_string()
}
_ => "Quicksand Bold".into(), _ => "Quicksand Bold".into(),
}, },
size: 20, size: 20,
@ -235,10 +222,7 @@ impl Default for Color {
} }
impl Display for Color { impl Display for Color {
fn fmt( fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
&self,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(f, "{}", self.to_css_hex_string()) write!(f, "{}", self.to_css_hex_string())
} }
} }
@ -291,23 +275,15 @@ impl TextSvg {
} }
#[must_use] #[must_use]
pub const fn alignment( pub const fn alignment(mut self, alignment: TextAlignment) -> Self {
mut self,
alignment: TextAlignment,
) -> Self {
self.alignment = alignment; self.alignment = alignment;
self self
} }
#[must_use] #[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn build( pub fn build(mut self, size: Size, mut cache: Option<PathBuf>) -> Self {
mut self,
size: Size,
mut cache: Option<PathBuf>,
) -> Self {
// debug!("starting..."); // debug!("starting...");
let mut final_svg = String::with_capacity(1024); let mut final_svg = String::with_capacity(1024);
@ -322,55 +298,47 @@ impl TextSvg {
let center_y = (size.width / 2.0).to_string(); let center_y = (size.width / 2.0).to_string();
let x_width_padded = (size.width - 10.0).to_string(); let x_width_padded = (size.width - 10.0).to_string();
let (text_anchor, starting_y_position, text_x_position) = let (text_anchor, starting_y_position, text_x_position) = match self.alignment {
match self.alignment { TextAlignment::TopLeft => ("start", font_size, "10"),
TextAlignment::TopLeft => ("start", font_size, "10"), TextAlignment::TopCenter => ("middle", font_size, center_y.as_str()),
TextAlignment::TopCenter => { TextAlignment::TopRight => ("end", font_size, x_width_padded.as_str()),
("middle", font_size, center_y.as_str()) TextAlignment::MiddleLeft => {
} let middle_position = size.height / 2.0;
TextAlignment::TopRight => { let position = half_lines
("end", font_size, x_width_padded.as_str()) .mul_add(-text_and_line_spacing, middle_position)
} + text_and_line_spacing / 2.0;
TextAlignment::MiddleLeft => { ("start", position, "10")
let middle_position = size.height / 2.0; }
let position = half_lines.mul_add( TextAlignment::MiddleCenter => {
-text_and_line_spacing, let middle_position = size.height / 2.0;
middle_position, let position = half_lines
); .mul_add(-text_and_line_spacing, middle_position)
("start", position, "10") + text_and_line_spacing / 2.0;
} ("middle", position, center_y.as_str())
TextAlignment::MiddleCenter => { }
let middle_position = size.height / 2.0; TextAlignment::MiddleRight => {
let position = half_lines.mul_add( let middle_position = size.height / 2.0;
-text_and_line_spacing, let position = half_lines
middle_position, .mul_add(-text_and_line_spacing, middle_position)
); + text_and_line_spacing / 2.0;
("middle", position, center_y.as_str()) ("end", position, x_width_padded.as_str())
} }
TextAlignment::MiddleRight => { TextAlignment::BottomLeft => {
let middle_position = size.height / 2.0; let position =
let position = half_lines.mul_add( (total_lines as f32).mul_add(-text_and_line_spacing, size.height);
-text_and_line_spacing, ("start", position, "10")
middle_position, }
); TextAlignment::BottomCenter => {
("end", position, x_width_padded.as_str()) let position =
} (total_lines as f32).mul_add(-text_and_line_spacing, size.height);
TextAlignment::BottomLeft => { ("middle", position, center_y.as_str())
let position = (total_lines as f32) }
.mul_add(-text_and_line_spacing, size.height); TextAlignment::BottomRight => {
("start", position, "10") let position =
} (total_lines as f32).mul_add(-text_and_line_spacing, size.height);
TextAlignment::BottomCenter => { ("end", position, x_width_padded.as_str())
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 { let font_style = match self.font.style {
Style::Normal => "normal", Style::Normal => "normal",
@ -379,9 +347,7 @@ impl TextSvg {
}; };
let font_weight = match self.font.weight { let font_weight = match self.font.weight {
Weight::Thin | Weight::ExtraLight | Weight::Light => { Weight::Thin | Weight::ExtraLight | Weight::Light => "lighter",
"lighter"
}
Weight::Normal | Weight::Medium => "normal", Weight::Normal | Weight::Medium => "normal",
Weight::Semibold | Weight::Bold => "bold", Weight::Semibold | Weight::Bold => "bold",
Weight::ExtraBold | Weight::Black => "bolder", Weight::ExtraBold | Weight::Black => "bolder",
@ -397,10 +363,7 @@ impl TextSvg {
let _ = write!( let _ = write!(
final_svg, final_svg,
"<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>", "<filter id=\"shadow\"><feDropShadow dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\"/></filter>",
shadow.offset_x, shadow.offset_x, shadow.offset_y, shadow.spread, shadow.color
shadow.offset_y,
shadow.spread,
shadow.color
); );
} }
final_svg.push_str("</defs>"); final_svg.push_str("</defs>");
@ -439,10 +402,7 @@ impl TextSvg {
let _ = write!( let _ = write!(
final_svg, final_svg,
"<tspan x=\"0\" y=\"{}\">{}</tspan>", "<tspan x=\"0\" y=\"{}\">{}</tspan>",
(index as f32).mul_add( (index as f32).mul_add(text_and_line_spacing, starting_y_position),
text_and_line_spacing,
starting_y_position
),
text text
); );
} }
@ -489,11 +449,9 @@ impl TextSvg {
let transform = tiny_skia::Transform::default(); let transform = tiny_skia::Transform::default();
#[allow(clippy::cast_sign_loss)] #[allow(clippy::cast_sign_loss)]
let (size_width, size_height) = let (size_width, size_height) = (size.width as u32, size.height as u32);
(size.width as u32, size.height as u32);
let Some(mut pixmap) = Pixmap::new(size_width, size_height) let Some(mut pixmap) = Pixmap::new(size_width, size_height) else {
else {
error!("Couldn't create a new pixmap from size"); error!("Couldn't create a new pixmap from size");
return self; return self;
}; };
@ -509,8 +467,7 @@ impl TextSvg {
// debug!("saved"); // debug!("saved");
// let handle = Handle::from_path(path); // let handle = Handle::from_path(path);
let handle = let handle = Handle::from_rgba(size_width, size_height, pixmap.take());
Handle::from_rgba(size_width, size_height, pixmap.take());
self.handle = Some(handle); self.handle = Some(handle);
// debug!("stored"); // debug!("stored");
self self
@ -518,13 +475,7 @@ impl TextSvg {
pub fn view<'a>(&self) -> Element<'a, Message> { pub fn view<'a>(&self) -> Element<'a, Message> {
self.handle.clone().map_or_else( self.handle.clone().map_or_else(
|| { || Element::from(Space::new().height(Length::Fill).width(Length::Fill)),
Element::from(
Space::new()
.height(Length::Fill)
.width(Length::Fill),
)
},
|handle| { |handle| {
Image::new(handle) Image::new(handle)
.content_fit(ContentFit::Cover) .content_fit(ContentFit::Cover)
@ -587,9 +538,7 @@ pub fn text_svg_generator_with_cache(
let font = slide.font().unwrap_or_default(); let font = slide.font().unwrap_or_default();
let text_svg = TextSvg::new(slide.text()) let text_svg = TextSvg::new(slide.text())
.alignment(slide.text_alignment()) .alignment(slide.text_alignment())
.fill( .fill(slide.text_color().unwrap_or_else(|| "#fff".into()));
slide.text_color().unwrap_or_else(|| "#fff".into()),
);
let text_svg = if let Some(stroke) = slide.stroke() { let text_svg = if let Some(stroke) = slide.stroke() {
text_svg.stroke(stroke) text_svg.stroke(stroke)
} else { } else {
@ -602,8 +551,7 @@ pub fn text_svg_generator_with_cache(
}; };
let text_svg = text_svg.font(font).fontdb(Arc::clone(fontdb)); 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); // debug!(fill = ?text_svg.fill, font = ?text_svg.font, stroke = ?text_svg.stroke, shadow = ?text_svg.shadow, text = ?text_svg.text);
let text_svg = let text_svg = text_svg.build(Size::new(1280.0, 720.0), cache);
text_svg.build(Size::new(1280.0, 720.0), cache);
slide.text_svg = Some(text_svg); slide.text_svg = Some(text_svg);
Ok(slide) Ok(slide)
} }
@ -643,10 +591,10 @@ mod tests {
slide slide
.text_svg .text_svg
.is_some_and(|svg| svg.handle.is_some()) .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,22 +1,21 @@
use std::{io, path::PathBuf}; use std::io;
use std::path::PathBuf;
use cosmic::{ use cosmic::dialog::file_chooser::FileFilter;
Element, Task, use cosmic::dialog::file_chooser::open::Dialog;
dialog::file_chooser::{FileFilter, open::Dialog}, use cosmic::iced::Length;
iced::{Length, alignment::Vertical}, use cosmic::iced::alignment::Vertical;
iced_widget::{column, row}, use cosmic::iced::widget::{column, row};
theme, use cosmic::prelude::*;
widget::{ use cosmic::widget::space::{self, horizontal};
Space, button, container, icon, progress_bar, use cosmic::widget::{Space, button, container, icon, slider, text, text_input};
space::{self, horizontal}, use cosmic::{Element, Task, theme};
text, text_input, use iced_video_player::{Position, Video, VideoPlayer};
},
};
use iced_video_player::{Video, VideoPlayer};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use url::Url; use url::Url;
use crate::core::videos; use crate::core::videos;
use crate::ui::gst_video;
#[derive(Debug)] #[derive(Debug)]
pub struct VideoEditor { pub struct VideoEditor {
@ -24,6 +23,7 @@ pub struct VideoEditor {
core_video: Option<videos::Video>, core_video: Option<videos::Video>,
title: String, title: String,
editing: bool, editing: bool,
position: f64,
} }
pub enum Action { pub enum Action {
@ -42,6 +42,8 @@ pub enum Message {
None, None,
PauseVideo, PauseVideo,
UpdateVideoFile(videos::Video), UpdateVideoFile(videos::Video),
VideoPos(f64),
NewFrame,
} }
impl VideoEditor { impl VideoEditor {
@ -52,6 +54,7 @@ impl VideoEditor {
core_video: None, core_video: None,
title: "Death was Arrested".to_string(), title: "Death was Arrested".to_string(),
editing: false, editing: false,
position: 0.0,
} }
} }
pub fn update(&mut self, message: Message) -> Action { pub fn update(&mut self, message: Message) -> Action {
@ -81,29 +84,41 @@ impl VideoEditor {
warn!(?video); warn!(?video);
return Action::UpdateVideo(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 => { Message::PickVideo => {
let video_id = self let video_id = self.core_video.as_ref().map(|v| v.id).unwrap_or_default();
.core_video let task = Task::perform(pick_video(), move |video_result| {
.as_ref() video_result.map_or(Message::None, |video| {
.map(|v| v.id) let mut video = videos::Video::from(video);
.unwrap_or_default(); video.id = video_id;
let task = Task::perform( Message::UpdateVideoFile(video)
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); return Action::Task(task);
} }
Message::UpdateVideoFile(video) => { Message::UpdateVideoFile(video) => {
self.update_entire_video(&video); self.update_entire_video(&video);
return Action::UpdateVideo(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 => (), Message::None => (),
} }
Action::None Action::None
@ -114,17 +129,19 @@ impl VideoEditor {
|| container(horizontal()), || container(horizontal()),
|video| { |video| {
let play_button = button::icon(if video.paused() { let play_button = button::icon(if video.paused() {
icon::from_name("media-playback-start") icon::from_name("media-playback-start-symbolic")
} else { } else {
icon::from_name("media-playback-pause") icon::from_name("media-playback-pause-symbolic")
}) })
.on_press(Message::PauseVideo); .on_press(Message::PauseVideo);
let video_track = progress_bar( let video_track = slider(
0.0..=video.duration().as_secs_f32(), 0.0..=video.duration().as_secs_f64(),
video.position().as_secs_f32(), video.position().as_secs_f64(),
Message::VideoPos,
) )
.girth(cosmic::theme::spacing().space_s) .step(0.1)
.length(Length::Fill); .width(Length::Fill)
.height(cosmic::theme::spacing().space_s);
container( container(
row![play_button, video_track] row![play_button, video_track]
.align_y(Vertical::Center) .align_y(Vertical::Center)
@ -135,10 +152,18 @@ impl VideoEditor {
}, },
); );
let video_player = self.video.as_ref().map_or_else( let video_player = self
|| Element::from(Space::new()), .video
|video| Element::from(VideoPlayer::new(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] let video_section = column![video_player, video_elements]
.spacing(cosmic::theme::spacing().space_s); .spacing(cosmic::theme::spacing().space_s);
@ -151,16 +176,15 @@ impl VideoEditor {
} }
fn toolbar(&self) -> Element<Message> { fn toolbar(&self) -> Element<Message> {
let title_box = text_input("Title...", &self.title) let title_box =
.on_input(Message::ChangeTitle); text_input("Title...", &self.title).on_input(Message::ChangeTitle);
let video_selector = button::icon( let video_selector =
icon::from_name("folder-videos-symbolic").scale(2), button::icon(icon::from_name("folder-videos-symbolic").scale(2))
) .label("Video")
.label("Video") .tooltip("Select a video")
.tooltip("Select a video") .on_press(Message::PickVideo)
.on_press(Message::PickVideo) .padding(10);
.padding(10);
row![ row![
text::body("Title:"), text::body("Title:"),
@ -179,15 +203,26 @@ impl VideoEditor {
fn update_entire_video(&mut self, video: &videos::Video) { fn update_entire_video(&mut self, video: &videos::Video) {
debug!(?video); debug!(?video);
let Ok(mut player_video) = let Ok(url) = Url::from_file_path(video.path.clone()) else {
Url::from_file_path(video.path.clone())
.map(|url| Video::new(&url).expect("Should be here"))
else {
self.video = None; self.video = None;
self.title.clone_from(&video.title); self.title.clone_from(&video.title);
self.core_video = Some(video.clone()); self.core_video = Some(video.clone());
return; 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); player_video.set_paused(true);
self.video = Some(player_video); self.video = Some(player_video);
self.title.clone_from(&video.title); self.title.clone_from(&video.title);
@ -216,9 +251,7 @@ async fn pick_video() -> Result<PathBuf, VideoError> {
error!(?e); error!(?e);
VideoError::DialogClosed VideoError::DialogClosed
}) })
.map(|file| { .map(|file| file.url().to_file_path().expect("Should be a file here"))
file.url().to_file_path().expect("Should be a file here")
})
// rfd::AsyncFileDialog::new() // rfd::AsyncFileDialog::new()
// .set_title("Choose a background...") // .set_title("Choose a background...")
// .add_filter( // .add_filter(

View file

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

View file

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

View file

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