Compare commits

..

428 Commits

Author SHA1 Message Date
36eef45971 Fill out an alphabet and animate the screen 2025-03-08 01:49:43 -05:00
596de6525f Create a 5x8 bitmap font 2025-03-08 01:23:06 -05:00
9175b9d4cc Create a print function and fill out more sixteen-segment glyphs 2025-03-07 22:23:03 -05:00
958d18b9a8 Set up the seven segment font 2025-03-07 18:48:56 -05:00
bc9e24c0c9 Remove dead code 2025-03-05 09:58:42 -05:00
b444326c1c Add a link to the sixteen-segment font source 2025-03-05 09:48:33 -05:00
8288fdbb6b Refactor the canvas and font 2025-03-05 09:47:00 -05:00
155d2ba18e Use bitflags to represent the font 2025-03-05 09:25:15 -05:00
132c85e99d Fix a bunch of the letters. Add a dot. 2025-03-05 00:50:17 -05:00
aea858dd17 Do a static buffer allocation for the Canvas 2025-03-03 23:18:28 -05:00
85e5d0bb5e Worked out a font and a canvas
I'm adding a 16-segment-based font here, and have encoded the numbers 0-10.

I've also worked out a way to make a Canvas structure not crash the pico.
2025-03-03 10:24:50 -05:00
21c6f30a7d Add an (unused) DISPOFF step 2025-02-27 09:58:15 -05:00
45dc19c329 Move board control into a self-contained object 2025-02-27 09:58:15 -05:00
a69a864dca Rename framebuf 2025-02-27 09:58:15 -05:00
f004aa3514 Use the onboard LED and try to transmit at 2MB 2025-02-27 09:58:15 -05:00
54dd004915 Tweak the hell out of the code until it shows a small square in the center of the screen 2025-02-27 09:58:15 -05:00
fb0e914edf This gets the screen working, though not correctly 2025-02-27 09:58:15 -05:00
47e90cc6f9 Set up an app for the adafruit TFT 2025-02-27 09:58:14 -05:00
afa846f7e0 Add a crane build for l10n-db 2025-02-24 23:37:59 -05:00
254a2aefd7 Remove remaining warnings 2025-02-24 23:10:15 -05:00
a07ecae04a Mvoe teh message and variant types into the bundle 2025-02-24 22:54:39 -05:00
76de75210f Improve the report format 2025-02-24 22:50:06 -05:00
e5b3c7e4e1 Write a rudimentary report 2025-02-24 22:18:42 -05:00
704009b76c Import translated xliff data 2025-02-24 21:11:12 -05:00
cd5837a437 Fix the ICU message in TimeDistance 2025-02-24 12:35:54 -05:00
a8a61cf03f Fill out the xliff exporter 2025-02-24 09:44:44 -05:00
0df0ff9419 Start on an xliff output 2025-02-23 21:48:13 -05:00
44ee6ec8a5 Export to json 2025-02-22 19:11:29 -05:00
52a0d6e3f2 Put in more meaningful working text 2025-02-22 18:45:36 -05:00
200c13a14e Add a configuration file 2025-02-22 18:39:32 -05:00
1c3d0711e1 Start reading the bundle 2025-02-22 16:40:23 -05:00
e16fef2b14 Write a rudimentary editor 2025-02-22 11:22:14 -05:00
e0392a4150 Write a single phrase to disk 2025-02-22 10:23:46 -05:00
359ab96779 Prototype for an l10n message bundle database 2025-02-21 10:28:41 -05:00
151876bcd4 Create the three parts of the app 2025-02-13 09:54:09 -05:00
9802124822 Convert the current Visions code into a prototype.
This is now present for learning and figuring out how to handle certain individual components.
2025-02-13 09:39:22 -05:00
87b187c8f1 Add the ability to delete a session 2025-02-10 00:25:16 -05:00
4a0dc5b87a Disable a lot of code and start setting up tests for the authentication view 2025-02-10 00:17:28 -05:00
94a821d657 Improve the user profile, create a Modal, and provide a way to create a user 2025-01-26 21:30:05 -05:00
dcd5514433 Let the admin see a list of users and the state of each one 2025-01-26 19:58:59 -05:00
90224a6841 Be able to authenticate and get back Success, PasswordReset, and Locked 2025-01-20 22:19:16 -05:00
84ee790f0b Create an API-friendly version of the User object 2025-01-20 20:59:40 -05:00
ac3a21f3f0 Remove printouts 2025-01-20 19:51:04 -05:00
ef0e9f16b8 Update password expiration management 2025-01-20 19:44:04 -05:00
06bb0811e0 Fix the core tests 2025-01-19 15:07:18 -05:00
b138e6da0a realign database queries and begin restoring check_password 2025-01-19 15:07:18 -05:00
e19d97663d Change the data types of the internal interfaces, and switch password expiration to an AccountState 2025-01-19 15:07:17 -05:00
d0ba8d921d Create a user expiration time and make new users immediately expired
This is to give the ability to force the user to create a new password as soon as they log in.
2025-01-19 15:05:02 -05:00
f9e903da54 Expand out the profile to start including a list of characters and games 2025-01-19 14:58:13 -05:00
a2cdaef689 Tweak the task file and update the cargo files 2025-01-19 13:59:29 -05:00
49c7a37d28 Restore file-service 2025-01-19 13:44:20 -05:00
aa155dc090 Update the nix flake build 2025-01-19 13:33:52 -05:00
da5144caea Switch cyberpunk-splash from deprecated glib channels 2025-01-19 13:18:31 -05:00
f9e4dcd68a Resolve warnings in dashboard 2025-01-19 12:58:54 -05:00
35abcfcf28 Resolve warnings in screenplay 2025-01-19 12:53:31 -05:00
cc4f8c1515 Fix warnings mostly in the SGF parser 2025-01-19 12:31:26 -05:00
d5b4d051a5 Remove the deprecated glib channel from the bike lights simulator 2025-01-19 10:32:53 -05:00
81143f0b9c Fix a lot of very simple, easy warnings 2025-01-18 22:38:10 -05:00
f59c3544b4 Set up the landing page, which shows the user their profile 2025-01-03 16:50:56 -05:00
208083d39e Authenticate the user and populate AppState with the stored session ID 2025-01-03 16:19:43 -05:00
08462388ea Move the assertNever utility to a utilities file 2025-01-03 11:59:33 -05:00
dc8cb834e0 Handle all applications errors in one location 2025-01-02 22:34:10 -05:00
4dc7a50000 Get games that the user is GMing in the user profile 2025-01-02 14:45:06 -05:00
5bb9f00a0d Extract the user management handlers 2025-01-02 13:57:20 -05:00
f6eb942371 Add the ability to create a game 2025-01-02 11:57:17 -05:00
792437af44 Add the ability for a user to set their password 2025-01-01 00:19:12 -05:00
d9f1efb8d3 Add the ability to create users and to get profiles 2024-12-31 23:47:40 -05:00
b2a7577c9d Make handlers asynchronous 2024-12-31 16:34:59 -05:00
82e41d711b Extract authentication into a wrapper function 2024-12-31 14:17:15 -05:00
822dfe2a13 authenticate an endpoint for getting the user profile 2024-12-31 14:09:21 -05:00
a0f1a0b81c Test user authentication 2024-12-31 12:56:05 -05:00
a33b94e5b3 Resolve many warnings 2024-12-31 12:12:24 -05:00
a18cdb0710 Create a test for the healthcheck endpoint 2024-12-31 01:12:26 -05:00
c31870367f Switch to Axum and implement the password check 2024-12-30 21:05:31 -05:00
e4c5ce0236 Set up a bit of code that rejects requests that have no authorization header 2024-12-29 23:39:43 -05:00
085a82776e Write a macro that eases communication between DbConn and DiskDb 2024-12-27 14:59:24 -05:00
2b1a0b99f8 Lots of linting and refactoring 2024-12-27 14:29:07 -05:00
fb34d0d965 Move the database into a more complex sub-module 2024-12-27 14:05:41 -05:00
d5f4b7cfa5 Add session creation and lookup 2024-12-27 14:02:43 -05:00
1d400ce38b Start wrapping routes into standalone functions 2024-12-24 14:37:37 -05:00
e62ff9aa7a Check username and password 2024-12-22 09:17:00 -05:00
2a616ef6c9 Set the admin password on a new server
This sets up the client state manager and state model. It has all of the
functions to support the set admin password endpoint, and some extras
which will be helpful in saving users generally.
2024-12-17 23:43:36 -05:00
f6a45a9223 Merge the auth state into a tabletop in the AppState provider 2024-12-17 00:50:25 -05:00
7d7e6ef300 Start trying to set up providers 2024-12-16 23:46:02 -05:00
af0ab5d020 Create a status endpoint that shows the onboarding UI if there's no admin password 2024-12-16 00:27:55 -05:00
7ca1581b55 Set up a state provider 2024-12-15 23:20:09 -05:00
5e89b8257d Set up the authentication page 2024-12-15 22:49:53 -05:00
7466ef2a6f Remove the original UI files 2024-12-15 21:42:01 -05:00
e505c21bc8 Set up an admin panel that shows the list of users 2024-12-10 22:43:15 -05:00
e8bc0590c6 Make the interface to show users in the system 2024-12-01 14:07:37 -05:00
5c23f326b6 Add the ability to save users and games. Link games more tightly to characters 2024-12-01 11:14:28 -05:00
d7e4293da0 Start using ResultExt to improve error handling 2024-12-01 00:51:08 -05:00
afb510d92e Set up new tables to handle users and roles 2024-11-30 23:03:52 -05:00
82c8f3f96e Clean up the database schema 2024-11-30 18:55:51 -05:00
d8ea2aac40 Retrieve the charsheet from the database and render it in the UI 2024-11-30 18:43:20 -05:00
995390ae4b Just make the entire core asynchronous 2024-11-30 15:24:57 -05:00
970e957143 Prepopulate the database 2024-11-30 12:20:38 -05:00
b506d479d3 Switch all channels to async-std 2024-11-30 12:05:31 -05:00
d78a471437 Create a shareable connection to the database 2024-11-30 11:48:35 -05:00
341e184947 Set up a database and store a character sheet in it 2024-11-29 23:14:52 -05:00
38d76e0048 Update sql-based database dependencies 2024-11-29 17:26:06 -05:00
253940c2ae Add a side panel character sheet 2024-11-28 22:28:41 -05:00
d3db9d60c2 Fix asset providing 2024-11-28 21:32:13 -05:00
b382c68382 Add role and specialty 2024-11-27 18:40:14 -05:00
0202b7bd59 Set up a drive guage for candela drives 2024-11-27 10:56:11 -05:00
311cd9c9a5 Set up rendering and formatting for actions and action groups 2024-11-27 09:37:48 -05:00
db8e67420f Start on a Candela Obscura plugin 2024-11-25 08:28:22 -05:00
c79610bd79 Add a test for update notifications 2024-11-24 09:50:20 -05:00
cadb3ab435 Verify that the tabletop can be set and retrieved 2024-11-24 09:35:25 -05:00
71b114c9b2 Set up some asset retrieval tests. 2024-11-24 09:21:58 -05:00
0f42ebcc30 Isolate error handling from Warp 2024-11-21 18:46:05 -05:00
5535632466 available_images now only lists image files from the asset database 2024-11-21 09:08:36 -05:00
5d66558180 Set up a test to validate the function which gets available images
There's a lot of work here that sets up dependency injection traits
which will make it easier for me to keep writing tests and will make it
easier for me to separate the Core from the support infrastructure.
2024-11-20 09:52:26 -05:00
154efcb6df Set up a GM control panel that can control the currently selected background 2024-11-19 22:48:36 -05:00
2ab6e88634 Start using the code-generated types module 2024-11-19 16:21:16 -05:00
e20ec206a8 Add a package for shared server types 2024-11-19 16:02:32 -05:00
c1ee4074b0 Organize the player view and tabletop 2024-11-19 14:53:42 -05:00
f0ce3a9fab Rename playfield to tabletop 2024-11-19 08:53:04 -05:00
e5deaa51d9 Extract the websocket code into a wrapper component 2024-11-19 00:09:48 -05:00
45275be11b Serve up the background image via the websocket 2024-11-18 23:32:54 -05:00
54162d0072 Move client construction up to app root 2024-11-18 20:52:04 -05:00
a8170fd5c6 Try out rendering some basic components with a websocket 2024-11-18 20:35:35 -05:00
0237393c0b Set up a websocket that relays messages 2024-11-18 19:08:49 -05:00
962ea66506 Move the handlers out of main.rs 2024-11-12 09:45:34 -05:00
69ef3c3892 Load up thumbnails of all images in the image directory 2024-11-12 00:16:54 -05:00
6416931c67 Apply a maximum size to the playing field 2024-11-11 23:22:41 -05:00
c35cbd75d7 Overhaul the UI application and build a placeholder for loading the background 2024-11-11 23:13:52 -05:00
addfd2072c Create an image server and create the playing field 2024-11-11 19:58:50 -05:00
911bc97b69 Add a water pattern and disable the brake sensor 2024-11-08 14:45:34 +00:00
019d9e7a6b Add channels for wires embedded in the lids. 2024-11-08 14:42:31 +00:00
8235ef0646 Work out a lid that contains integrated lights and buttons 2024-11-08 14:42:31 +00:00
dd861fbbd4 Adjust the clearances based on real board fit 2024-11-08 14:42:31 +00:00
427c5d2a72 First print edition of the bike light case 2024-11-08 14:42:31 +00:00
39391fb2fe Rename teh dotstar pi project 2024-11-03 22:46:37 -05:00
99573ff7cf Add extensive explanation of the code. 2024-11-03 22:46:37 -05:00
5ed39f814a Remove unused imports 2024-11-03 22:46:37 -05:00
82ec50f519 Set up properly for a single light 2024-11-03 22:46:37 -05:00
1601d2d806 Bare-bones control of the first 30 leds 2024-11-03 22:46:34 -05:00
3e297a5986 Merge pull request 'Create a slideshow application in my cyberpunk style' (#252) from cybperpunk-billboard into main
Reviewed-on: #252
2024-11-03 21:16:38 +00:00
b0383292fe Merge branch 'main' into cybperpunk-billboard 2024-11-03 16:15:13 -05:00
a0f037c9cd Fix up broken parts fo cyberpunk-splash 2024-11-03 13:36:35 -05:00
8e63e5210c Add full-screen support 2024-11-03 13:30:03 -05:00
db34e69cdf Make the text larger 2024-11-03 13:12:48 -05:00
20623284ed Set up command line options 2024-11-02 14:08:58 -04:00
5d04c84437 Update to rust 1.81 2024-10-14 18:04:10 -04:00
6e26740a40 Fix a bug with the bottom section 2024-10-09 22:32:15 -04:00
a56c0d141c Set up a nix build command for cyber-slides 2024-10-09 14:09:56 -04:00
1bc146beaf Rename to cyber-slides 2024-10-08 23:23:33 -04:00
bb08064b9a Add word wrapping 2024-10-08 23:19:56 -04:00
f226a83cf6 Add a lower line of tracery 2024-10-08 22:27:27 -04:00
fc70bb3955 Set up the cross-fade animation 2024-10-08 22:19:22 -04:00
7b50a71369 Set up a main animation loop 2024-10-07 23:47:17 -04:00
7a7548c78f Set up screen via transitions from state to state 2024-10-07 22:42:27 -04:00
9c56e988b2 Improve the Text and line APIs 2024-10-04 20:56:37 -04:00
de35ebb644 Extract the cyberpunk objects into a library, start on the slideshow 2024-10-04 20:27:34 -04:00
791f2be3c5 Largely design the control panel case 2024-09-27 02:18:09 +00:00
74b7f1c6f7 Add gaps to allow access to the voltage converter 2024-09-27 02:18:09 +00:00
9c490a84a4 add the slot to hold the power converter 2024-09-27 02:18:09 +00:00
724cc1a3f0 Add a channel for running wires 2024-09-27 02:18:09 +00:00
8f71760604 Apply bevels to everything 2024-09-27 02:18:09 +00:00
11abde345e First draft of the battery enclosure. 2024-09-27 02:18:09 +00:00
a5b76c8171 Add the enclosure 2024-09-27 02:18:09 +00:00
9b23dd5acd Update the Dashboard distribution 2024-09-23 23:19:24 -04:00
54225ca729 Bump the version number 2024-09-24 03:04:57 +00:00
95b46de7fc Set up a header bar 2024-09-24 03:04:57 +00:00
caaf9c57c6 Remove IFC from the dashboard app 2024-09-24 03:04:57 +00:00
81d452694d Reverse the blinker pins 2024-09-15 23:57:06 -04:00
88cf32047b Enable the brake light 2024-09-08 12:53:35 -04:00
6cae7dbb0e Set up a basic server with a device listing endpoint 2024-08-26 10:41:17 -04:00
80776c65d8 Write a program that enumerates audio sinks on the device 2024-08-21 09:40:58 -04:00
1c54e0832b Make a design system page. Build up CSS. 2024-08-20 17:01:36 +00:00
aee4528fb3 Rename the Dashboard 2024-08-20 17:01:36 +00:00
0535b6da5a Rename Launcher components 2024-08-20 17:01:36 +00:00
b55324feab Add Activator groups 2024-08-20 17:01:36 +00:00
50d8a9670e Start creating some UI components 2024-08-20 17:01:36 +00:00
9cda35e766 UI placeholder 2024-08-20 17:01:36 +00:00
d0f461a5eb Create the dashboard placeholder 2024-08-20 17:01:36 +00:00
70c013218a Update pins for the realities of the board layout 2024-07-30 14:50:14 -04:00
37c7e04820 Turn on the built-in LED when software starts up 2024-07-20 11:21:16 -04:00
291663d4a3 Re-add the armv6 toolchain 2024-07-08 09:35:44 -04:00
2b0fc7639e Debounce buttons, fix colors, and add a new water pattern 2024-07-08 09:29:34 -04:00
80d8dedbaf Adjust colors and the blinker patterns 2024-07-08 09:29:34 -04:00
d7a70119c8 Send out the full set of lights 2024-07-08 09:29:34 -04:00
54c4b99ab6 Improve the blinker animations and state transitions when switching blinkers 2024-07-08 09:29:34 -04:00
ef5415303b Start monitoring events 2024-07-08 09:29:34 -04:00
8d183d6d8c Build some of the framework for the bike application
This now sends a set of lights to the dashboard from a pico. I had to
adjust some of the colors as they do not look nearly as good in lights
as they do in the screen. There is no real application loop yet, no the
ability to get feedback from external controls.
2024-07-08 09:29:32 -04:00
0b949111d2 Switch to a fixed point arithmatic library 2024-07-08 09:28:40 -04:00
6164cb3b39 Refactor the bike library until it compiles with no_std
Theoretically, this is the first step to getting to running on the pico
2024-07-08 09:28:40 -04:00
22f0f9061c Rotate the right side
The actual bike is going to be a long loop which folds from the end of
the left side to the back end of the right side. This requires that the
colors get moved around for proper mirroring.
2024-07-08 09:28:40 -04:00
0bb5e62f96 Set up a bunch of animations and some state transitions! 2024-07-08 09:28:40 -04:00
06aedc34bb Now I'm able to send messages from the UI to the core 2024-07-08 09:28:40 -04:00
84b077e20c Build the core of the application. 2024-07-08 09:28:40 -04:00
fc2e88add2 Set up a GTK simulator for the bike lights engine 2024-07-08 09:28:38 -04:00
15c4ae9bad Update the review tree when navigating 2024-05-07 08:49:49 -04:00
7dd531b493 It is now possible to move backwards and forwards on the mainline of a tree 2024-05-07 07:53:15 -04:00
cbfb3f2e37 Clean up tests 2024-05-01 09:36:48 -04:00
9540a2c5bb Highlight the current node and make all nodes a bit larger 2024-04-30 23:34:16 -04:00
6165d65977 Make the review tree scrollable 2024-04-30 23:28:12 -04:00
4f8a1636c1 Set up a view model for the game review and highlight current node 2024-04-30 23:27:05 -04:00
20b02fbd90 Update the Nix derivation 2024-04-30 22:26:12 -04:00
278ec27b4e Linting 2024-04-30 22:25:01 -04:00
8b7add37c1 Switch from slab_tree to nary_tree 2024-04-30 22:25:01 -04:00
5441a3c441 Adapt the app to the new slab tree 2024-04-30 22:25:01 -04:00
b1374229f3 Calculate out the depth and width of each node 2024-04-30 22:25:01 -04:00
bc5042c004 Start propogating the slab_tree up to OTG 2024-04-30 22:25:01 -04:00
0534143d6b Finish implementing GameTree.Clone and PartialEq 2024-04-30 22:25:01 -04:00
d7f5269e15 Begin implementing traits for a game record 2024-04-30 22:25:01 -04:00
c913e9da37 Convert the recursive parse tree to a slab GameTree 2024-04-30 22:25:01 -04:00
c50bd652f1 Switch from my custom Tree setup to a slab tree in GameRecord 2024-04-30 22:25:01 -04:00
093e1f7f8a Start writing functions for finding a node within the tree 2024-04-30 22:25:01 -04:00
3c94f906a6 Update Cargo.nix 2024-04-19 12:27:17 -04:00
0aecaee760 Use pre-loaded images 2024-04-05 10:47:16 -04:00
baeb458126 Create a resource manager that preloads the images 2024-04-05 10:06:52 -04:00
da144a58ec Revise the graphics 2024-04-05 09:15:20 -04:00
f09af67193 Add images for fancier stones 2024-04-01 00:14:15 -04:00
32391a46e7 Render the board in the background 2024-03-31 19:59:44 -04:00
0a62c96b0f Clippy 2024-03-31 19:36:44 -04:00
78863ee709 Cleanups 2024-03-31 18:16:41 -04:00
5cdcf0499c Improve ident calculation to help with tree drawing 2024-03-31 16:19:09 -04:00
b982f2c1cc Start doing a bare basic rendering of nodes in a game tree 2024-03-31 14:09:48 -04:00
46b25cc6c5 Set up a BFS iterator over the node tree 2024-03-31 13:35:23 -04:00
9fbc630500 Game tree becomes a tree of UUIDs, not GameNodes
Doing this avoids reference lifetime hell
2024-03-30 11:00:54 -04:00
b481d5d058 Adjust the coordinates calculated to be zero-based 2024-03-29 09:29:32 -04:00
7a06b8cf39 Work out how to calculate the horizontal position of each node 2024-03-29 09:10:38 -04:00
3192c0a142 Apply clippy to otg-gtk 2024-03-26 09:17:26 -04:00
acf7ca0c9a Resolve clippy warnings on otg-core 2024-03-26 09:17:26 -04:00
64138b9e90 Resolve clippy warnings on SGF 2024-03-26 09:17:26 -04:00
e587d269e9 Start on the foundations of a tree-drawing algorithm
I don't actually know what I'm doing. I've done some reading and from
that I'm doing experiments until I can better understand what I've read.
2024-03-26 09:17:26 -04:00
57aadd7597 Create the drawing area for the review tree 2024-03-26 09:17:25 -04:00
b70d927eac Render the board with the completed game state. 2024-03-26 09:17:25 -04:00
3a7f204883 Add more test data and ensure the mainline is returned even with branches 2024-03-26 09:17:25 -04:00
642351f248 Return the mainline of a game that has no branches in it. 2024-03-26 09:17:25 -04:00
d9bb9d92e5 Apply moves to the abstract board
To get here, I had to also build some conversion functions and make a
lot of things within the game record public
2024-03-26 09:17:25 -04:00
30e7bdb817 Render the grid of the goban 2024-03-26 09:17:25 -04:00
556f91b70b Set the size of the drawing area 2024-03-26 09:17:25 -04:00
894575b0fb Start on the new Goban component 2024-03-26 09:17:25 -04:00
d964ab0d2f Minimal linting 2024-03-26 09:17:25 -04:00
74c8eb6861 Document the Goban representation in Core 2024-03-26 09:17:25 -04:00
3aac3b8393 Rename Game to GameRecord for disambiguation. 2024-03-26 09:17:25 -04:00
295f0a0411 Create the game review page and work on navigating to it with a navigation stack 2024-03-26 09:17:25 -04:00
e694ba74ca Update to Rust 1.77 2024-03-26 09:14:28 -04:00
5f9cd2622a Fix Cargo.nix 2024-03-26 09:06:08 -04:00
48271389ad Rename Kifu to On The Grid 2024-03-22 08:19:14 -04:00
49571b0f82 Show real information in the library view 2024-03-21 23:30:24 -04:00
89a289a1ae Make games serializable through Serde 2024-03-21 23:23:47 -04:00
fe082773e3 Set up the library view again 2024-03-21 23:16:10 -04:00
db9efbaedd Move the settings view model back into the core 2024-03-21 23:14:25 -04:00
1d959117aa Write a little app to demonstrate reading an an SGF file 2024-03-21 23:12:30 -04:00
a5990a2a30 Ensure that the territory property is accepted 2024-03-21 22:49:24 -04:00
bd6d5b62e3 Reduce the recursion amount of parser Node to GameNode 2024-03-21 22:49:24 -04:00
82c1765513 Write the more semantic Game interpreter 2024-03-21 22:49:24 -04:00
de54ec676f Make sure the settings get saved 2024-03-21 17:01:40 -04:00
5612c89a61 Thread signals through all of the settings dialog 2024-03-21 08:43:08 -04:00
c3c144e035 Set up the preferences UI with the path to the games library 2024-03-19 10:08:43 -04:00
05a6dcf3af Set up the initial settings screen 2024-03-15 14:07:55 -04:00
b98e0bdcea Strip kifu down even further and revamp the app_window structure 2024-03-15 10:49:25 -04:00
3d4a298dc1 Write some documentation for the LocalObserver 2024-02-28 04:42:57 +00:00
2d7fbb9a4b Add the placeholders for game review and settings 2024-02-28 04:42:57 +00:00
3a5cb17e09 Extract notification receiving into an observer 2024-02-28 04:42:57 +00:00
f6c82cbcb0 Ensure the game_view_model handler aborts when the view_model gets dropped 2024-02-28 04:42:57 +00:00
74b00d94b1 Set up the notifications pattern for view models 2024-02-28 04:42:57 +00:00
ddf83b3018 GameViewModel now subscribes directly to the Core 2024-02-28 04:42:57 +00:00
a1f41a440f Remove typeshare from kifu 2024-02-28 04:42:57 +00:00
e838f601ca Set up a placeholder for the GameViewModel 2024-02-28 04:42:57 +00:00
1b0a90a332 Create the placeholder for the database view model 2024-02-28 04:42:57 +00:00
4dc6e3151b Remove everything related to the kifu typescript code 2024-02-28 04:42:57 +00:00
79cec6e21d Switch to async channels and disable most of the existing runtime api 2024-02-28 04:42:57 +00:00
32cc74edfa Set up gio settings 2024-02-28 04:42:57 +00:00
d3d3260091 Create a flake that can run the Kifu app 2024-02-28 04:42:57 +00:00
a1441f7bb1 Bump versions to 0.6.0 2024-02-19 18:47:48 -05:00
9e7350b087 Add an about page that calls out gtk-rs and the GUI development book 2024-02-19 18:41:38 -05:00
0032f16422 Resolve the duplicate widget insertion warning 2024-02-19 18:04:45 -05:00
a5d51dab70 Hugely improve the historical view formatting 2024-02-19 17:56:48 -05:00
c24a5f515f Set up CSS styles around the date range picker 2024-02-19 17:27:02 -05:00
d70ca08db2 Correctly set the date range picker when initializing the historical view 2024-02-19 16:47:10 -05:00
55b6327d42 Open the welcome screen if the database is not available 2024-02-19 16:27:11 -05:00
1c2f40c868 Apply clippy suggestions 2024-02-19 16:14:08 -05:00
1527942f9c Add support for DurationWorkout and SetRep, finishing the legacy importer 2024-02-19 14:24:52 -05:00
7ba758b325 Build an application that can read the legacy record data structure 2024-02-19 13:27:19 -05:00
aed4735209 Add some documentation around the quit action 2024-02-19 12:05:21 -05:00
c14b20b79e Set up an application quit action group
This ties together a menu item, closing the main window, and Ctrl-Q into
the same action, which is to quit the application.
2024-02-19 11:58:01 -05:00
56ff5527ba Quit the application when the user clicks the header bar X 2024-02-19 09:49:20 -05:00
f7f55d74fd Bump fitnesstrax to v0.5.0 2024-02-18 17:58:52 -05:00
843924afef Wrap the list view within a scrolled window 2024-02-18 22:19:39 +00:00
86d7ca0b01 Change TextEntry to a builder pattern 2024-02-18 22:19:39 +00:00
d137ee2481 Apply clippy 2024-02-18 22:19:39 +00:00
74dbb58ed9 Extract the date range picker and enable the quick picker 2024-02-18 22:19:39 +00:00
9a18496c95 Apply the value of a date field to the historical view
Date fields can now be updated and their values retrieved. So now I have added a button that retrieves the range of the date field and updates the historical view in response to them.
2024-02-18 22:19:39 +00:00
01776a534e Start setting up date range widgets 2024-02-18 22:19:39 +00:00
b33d17c256 Start building an internally editable date field 2024-02-18 22:19:39 +00:00
ab59eedef5 Fitnesstrax v0.4.1 2024-02-12 10:14:55 -05:00
4dd6afeae7 Add the desktop shortcut and the gsettings schema to the fitnesstrax installation 2024-02-12 09:15:47 -05:00
cb2bec4287 First pass at an installer override 2024-02-10 12:28:12 -05:00
5a93c4fdcd Bump the fitnesstrax version to 0.4.0 2024-02-09 22:52:34 -05:00
6e5cbc0930 Ensure there is a button for every workout type 2024-02-09 22:40:29 -05:00
73a5ab89a3 Rename BikeRide to Biking
This just generally feels more comfortable, especially when put in conjuction with the other activity types
2024-02-09 08:24:01 -05:00
291dc32fe5 Show summaries of all time-distance workouts 2024-02-09 08:22:12 -05:00
b5c42e3ac3 Add the activity type selector to the time-distance widget 2024-02-09 08:17:10 -05:00
6394d89331 Create new records with the date of the view model 2024-02-08 22:11:46 -05:00
38b1e62b60 Add the ability to edit the time on a time_distance record 2024-02-08 21:58:22 -05:00
4acf034b8d Clean up warnings and remove printlns 2024-02-08 19:01:56 -05:00
1aff203afc Reload data when the user saves on the DayEdit panel
This required some big overhauls. The view model no longer takes records. It only takes the date that it is responsible for, and it will ask the database for records pertaining to that date. This means that once the view model has saved all of its records, it can simply reload those records from the database. This has the effect that as soon as the user moves from DayEdit back to DayDetail, all of the interesting information has been repopulated.
2024-02-08 19:01:56 -05:00
9fc9d2b758 Create Duration and Distance structures to handle rendering
These structures handle parsing and rendering of a Duration and a Distance, allowing that knowledge to be centralized and reused. Then I'm using those structures in a variety of places in order to ensure that the information gets rendered consistently.
2024-02-08 10:30:14 -05:00
76f4b31466 Show existing time/distance workout rows in day detail and editor 2024-02-08 10:23:47 -05:00
73052a0694 Save new time/distance records
This sets up a bunch of callbacks. We're starting to get into Callback Hell, where there are things that need knowledge that I really don't want them to have.

However, edit fields for TimeDistanceEdit now propogate data back into the view model, which is then able to save the results.
2024-02-08 10:20:57 -05:00
2c42c35dfe Build the facilities to add a new time/distance workout
This adds the code to show the new records in the UI, plus it adds them to the view model. Some of the representation changed in order to facilitate linking UI elements to particular records. There are now some buttons to create workouts of various types, clicking on a button adds a new row to the UI, and it also adds a new record to the view model. Saving the view model writes the records to the database.
2024-02-08 09:44:58 -05:00
afe693fe10 Make emseries::Record copyable 2024-02-08 09:18:55 -05:00
af1422d523 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-02-08 09:18:55 -05:00
792e20d44b Add buttons with icons to represent workouts 2024-02-08 09:13:54 -05:00
8016188b29 Add a test program for gnome icons 2024-02-08 09:13:54 -05:00
74df2880bb Implement the Edit Cancel button 2024-02-08 09:13:54 -05:00
96c4201680 Render time distance details in the day detail view 2024-02-08 09:02:09 -05:00
ecdd38ebbc Show a summary of the day's biking stats when there is one 2024-02-08 08:57:17 -05:00
2fb8728856 Invert the TraxRecord
This simplifies, though not as much as I was hoping, the patterns for accessing data along strict type patterns. I may see better results once I'm getting the Time/Distance views working.
2024-02-08 00:20:40 -05:00
a7d43ef184 Update Cargo.nix 2024-02-08 00:16:27 -05:00
9727d35116 Resolve clippy warnings
Warnings were mounting up. It was time to resolve them before attempting a massive rebase.
2024-02-07 23:36:03 -05:00
1d6155d9e5 Finish the update and delete view model functions 2024-02-07 09:29:08 -05:00
a8bf540517 Remove test that steps and weights are honored correctly
Step and weight records may be presented in any order. Any test that
tries to enforce that one gets presented before teh other can't cannot
succeed. So, I've removed that test and instead put in a warning that
will appear when the view model gets loaded.
2024-02-07 08:28:38 -05:00
3db870d790 Set up time distance operations and tests 2024-02-03 15:28:33 -05:00
24276d172b Introduce the RecordProvider interface
DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App.
2024-02-01 10:12:35 -05:00
96317f5692 Finish removing the previous record grouping
Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens.
2024-02-01 10:08:18 -05:00
c1e797f3ae DayDetailViewModel now ignores records and directly retrieves data from App
This is preparatory work. Having the view model directly retrieve data both adds a degree of symmetry (it both gets data from and sends data to the app) and makes it possible for the view model to refresh itself when needing to revert data or after saving data.
2024-02-01 09:27:40 -05:00
304008c674 The view model can no longer be initialized without an app 2024-01-31 09:51:17 -05:00
772188b470 Set up text entry fields for all of the common metrics 2024-01-31 08:56:54 -05:00
bc31522c95 Add the on_update callback to TextEntry, and test the component 2024-01-31 08:44:46 -05:00
6c68564a77 Create a function which safely initializes GTK once
This is only available in test code, and it allows GUI component tests to run without having to worry about double-initializing GTK
2024-01-31 08:40:55 -05:00
55c1a6372f Rename the formatters 2024-01-31 08:38:17 -05:00
2cbd539bf4 Add a type for managing Time of Day values 2024-01-31 08:27:42 -05:00
7d14308def Create automated testing for weight, duration, and distance formatters 2024-01-31 08:10:46 -05:00
dcd6301bb9 Introduce structures for formatting and parsing Duration and Distance values
These aren't in use yet.
2024-01-30 10:11:30 -05:00
69567db486 Introduce a structure for formatting and parsing Weight values 2024-01-30 10:08:10 -05:00
f8d66bbb69 Update build tools for dashboard and fitnesstrax 2024-01-25 22:55:45 -05:00
dce11dde2b Set up flake-based builds 2024-01-25 22:48:41 -05:00
3f9a7072eb Fitnesstrax, version 0.3.0 2024-01-21 10:14:50 -05:00
7ec48ded5d Make the day summary use the view model 2024-01-20 17:04:20 -05:00
9461c387fe Simplify the weight editor 2024-01-20 16:05:33 -05:00
d4c48c4443 Add a step count editor field 2024-01-20 15:59:03 -05:00
9bedb7a76c Tons of linting and get tests running again 2024-01-20 15:04:46 -05:00
1fe318068b Set up a view model for the day detail view 2024-01-20 11:16:31 -05:00
18e7e4fe2f Start setting up the day detail view model
I've created the view model and added a getter function for the weight.
I'm passing the view model now to the DayDetailView, DayDetail, and
DayEdit.

I'm starting to set up the Save function for the view model, draining
all of the updated records and saving them.

None of the components yet save any updates to the view model, so
updated_records is always going to be empty until I figure that out.
2024-01-18 09:00:08 -05:00
1c2c4982a1 Update the record in the detail view on save 2024-01-18 07:43:18 -05:00
c075b7ed6e Just barely get the data saveable again
Starting to see some pretty serious limitations already.
2024-01-17 22:59:20 -05:00
56d0a53666 Fix how DayEdit deals with the weight field 2024-01-17 22:35:13 -05:00
b00acc64a3 Set up the ActionGroup component 2024-01-17 22:13:55 -05:00
104760c754 Be able to switch into edit mode 2024-01-15 23:27:55 -05:00
1e6555ef61 Create a day detail view
DayDetail, the component, I used to use as a view. Now I'm swapping
things out so that DayDetailView handles the view itself. The DayDetail
component will still show the details of the day, but I'll create a
DayEditComponent which is dedicated to showing the edit interface for
everything in a day.

The swapping will now happen in DayDetailView, not in DayDetail or an
even deeper component.
2024-01-15 15:53:01 -05:00
2e2ff6b47e Create a Singleton component and use it to simplify the weight view 2024-01-15 13:20:23 -05:00
2d22397382 Bump the dashboard version 2024-01-09 08:10:58 -05:00
2c7666304a Fix the year in the current date view 2024-01-09 08:10:02 -05:00
0007522b26 Extract the Weight and Time Distance widgets 2024-01-01 23:58:55 -05:00
b7b9b1b29f Create an EditView "component"
This is a super tiny data structure that covers an edit mode, a view
mode, and an unconfigured mode. It's mostly a container so that views
don't have to preserve everything directly.
2024-01-01 23:49:31 -05:00
2e3d5fc5a4 Clean up the parameters to TextEntry and populate the field 2024-01-01 22:57:59 -05:00
a25b76d230 Create a validated text entry widget
I move the weight edit view into the validated text entry widget, and I
work on some of the unfortunate logic in the weight blur function. I've
left behind a lot of breadcrumbs for things that still need to be done.
2023-12-31 11:18:15 -05:00
9970161c30 Set up a lot of the files necessary for a desktop app 2023-12-29 13:13:22 -05:00
7bd4885b09 Save new information to the day detail view and to the historical view 2023-12-29 09:24:37 -05:00
b5dcee3737 Update the historical view when a change happens in the db 2023-12-28 22:47:47 -05:00
0c3ae062c8 Save real data to the database. Load data on app start. 2023-12-28 22:46:44 -05:00
f422e233a1 Record data to the database
This isn't recording real data. It's basically discarding all
information from the weight edit field. But it is creating a record.
2023-12-28 22:43:56 -05:00
7a6e902fdd Create placeholders in the historical view for days that are unpopulated. 2023-12-28 22:36:44 -05:00
6d9e2ea382 Switch to the updated emseries record type 2023-12-28 22:36:40 -05:00
04a48574d3 Develop a pattern to detect clicking outside of a focused child
Be able to respond to blur events and potentially be able to record weight.
2023-12-28 22:34:09 -05:00
e13e7cf4c3 Create a widget that can show the weight view and edit modes 2023-12-28 22:31:11 -05:00
383f809191 Open and style the day detail view and add it to the navigation stack 2023-12-28 22:31:07 -05:00
d269924827 Refactorings and dead code removal 2023-12-28 22:20:30 -05:00
8049859816 Clean up showing the welcome and historical screens
Swapping is now done in dedicated functions instead of a big pattern
match.

After selecting a database, the app window will apply the configuration
by opening the database, saving the path to configuration, and switching
to the historical view.
2023-12-28 21:45:55 -05:00
ac343a2af6 Switch from channel-based communication to async calls into the core 2023-12-28 19:09:12 -05:00
5cd0e822c6 Update to adwaita 1.4, and add a navigation page stack 2023-12-28 13:21:42 -05:00
fe5e4ed044 Save the views as their original widgets
This allows me to directly reference functions that occur on those
widgets without losing them behind a gtk::Widget upcast or needing to
later downcast them.
2023-12-28 12:59:29 -05:00
e30668ca8e Drop DateTimeTz from fitnesstrax 2023-12-28 12:51:50 -05:00
149587f0bd Bind the ID to the record instead of keeping them separate 2023-12-27 16:13:47 -05:00
d2f4ec97c0 Rename UniqueId to RecordId 2023-12-26 15:39:17 -05:00
c94b7db484 Stop using DateTimeTz 2023-12-26 15:37:53 -05:00
85e2494c3b Add a test application that demonstrates chrono and timezone processing 2023-12-26 13:33:18 -05:00
af8f9b0244 Generate some random data and feed it into hte historical view 2023-12-24 19:13:49 -05:00
1b3ca7439d Add styling to the day summary 2023-12-24 12:00:12 -05:00
3dc8be0d26 Render a weight record 2023-12-22 18:53:29 -05:00
43cd408e2c Start elaborating upon the HistoricalView
I've created the DaySummary structure and set up a list view to go into
the historical view. One hard-coded date is visible as a placeholder to
start filling things into the day summary.
2023-12-22 17:32:45 -05:00
3a728a51b4 Extract the application loop from the main file 2023-12-22 15:17:22 -05:00
f19090311b Extract all of the UI components into dedicated files 2023-12-22 15:17:22 -05:00
dedcc76df0 Mild cleanups 2023-12-22 15:16:03 -05:00
6678ab9852 Documentation 2023-12-22 14:28:23 -05:00
9c200f555c Set up app invocation and response handling 2023-12-22 14:08:16 -05:00
3ca8bf64cc Set up message passing between app window and an app thread 2023-12-19 18:05:22 -05:00
87994012fa Save the database path to settings and attempt to open the database on start 2023-12-19 10:59:33 -05:00
50268ffadc Actually be able to open the database 2023-12-19 10:46:53 -05:00
beedeba8dc Style the welcome screen 2023-12-19 10:10:02 -05:00
db188ea75a Allow the user to create a new file 2023-12-19 00:37:51 -05:00
104ffc5782 Set up callbacks to make the save button sensitive to the file selection 2023-12-19 00:31:36 -05:00
38db3d6780 Elaborate upon and format the welcome dialog 2023-12-18 21:14:08 -05:00
0dd0a5f7cc Set up some of the content of the welcome view 2023-12-18 20:04:55 -05:00
acdf9ec150 Add the window header bar 2023-12-18 19:08:32 -05:00
0ebdcd7c2a Add some commentary 2023-12-18 18:36:22 -05:00
baf652173c Set up the main views for the window, as well as the redraw policy
Whenever we change views, we need to call the redraw function. That
function will handle dropping the old view and populating the new one.
2023-12-18 18:30:41 -05:00
c4befcc6de Add the CSS style context to the main window 2023-12-18 11:59:56 -05:00
a7d6d82ec2 Set up an environment variable to toggle between dev and production schemas 2023-12-07 09:56:10 -05:00
f3a453d151 Set up a development gsettings schema 2023-12-07 09:45:56 -05:00
b9aa434278 Remove types that are not implemented yet
I've gone *years* without these types, mostly because I wasn't doing
these workouts. I can go longer.
2023-12-06 23:55:12 -05:00
83a4839b1d Implement the timestamp function 2023-12-06 23:52:46 -05:00
0e0d67a9ac Split Fitnesstrax into two crates 2023-12-06 23:52:33 -05:00
e5fb605816 Create a test that verifies that a series can be made for a TraxRecord 2023-12-06 23:52:33 -05:00
f9db002464 Make the series open function accept anything that can be a path reference 2023-12-06 23:52:28 -05:00
0ac9bb74a6 Set up the bare minimum of a GUI app, opening only a single window 2023-12-06 23:52:28 -05:00
f034dfcb8b Set up the basic data structures of a new fitnesstrax app. 2023-12-06 23:52:28 -05:00
7abb33c4fe Work out how the session filter and the handlers can function 2023-11-21 09:57:35 -05:00
581979fc54 Make some test endpoints and prototype an authentication filter 2023-11-20 23:30:10 -05:00
bf93625225 Create a placeholder for the Visions server 2023-11-20 00:03:16 -05:00
778da0b651 Start working out designs and build tools for the visions vtt 2023-11-20 00:03:16 -05:00
8b53114d0d Have the file-service depend on the new authdb library 2023-11-19 23:55:43 -05:00
42e931d780 Move the cli app into authdb 2023-11-19 23:54:02 -05:00
532210db03 Extract the authentication DB from the file service 2023-11-19 23:43:33 -05:00
37f6334c9f Update the gtk dependencies for all packages
This breaks the hex-grid application. set_source_pixbuf got removed and
I have not figured out a replacement for drawing a pixbuf to a context.
2023-11-14 10:05:56 -05:00
3310c460ba Cleanups 2023-11-14 08:27:13 -05:00
6d14cdbe2a Build a color test pattern. 2023-11-14 08:04:31 -05:00
c46ab1b389 Tweak the bit-banging code to get the protocol right 2023-11-13 18:21:07 -05:00
168ba6eb40 Try controlling dotstars through SPI and through bit-banging 2023-11-09 22:24:37 -05:00
7e3ee9a5b7 Set up a blink application for the raspberry pi pico 2023-11-05 15:54:33 -05:00
86a6d386d2 Set up raspberry pi cross-compile tools 2023-11-05 15:53:43 -05:00
e461cb9908 Import the new level-one parser
This is the parser that does a raw parse of the SGF file, interpreting components but not enforcing node types.
2023-10-30 01:57:00 +00:00
942e91009e Disable sgf::go and provide a shim for a game 2023-10-30 01:57:00 +00:00
48113d6ccb Bump version to 0.2.0 2023-10-26 00:26:52 -04:00
d878f4e82c Resolve more linting issues 2023-10-26 00:19:13 -04:00
7949033857 Add the handler to delete a file 2023-10-26 00:14:10 -04:00
ce874e1d30 Fix the form to string conversion and set up the Delete form 2023-10-26 00:12:45 -04:00
07b8bb7bfe Style the authentication page for mobile 2023-10-26 00:03:49 -04:00
a403c1b1b3 Hugely refactor the HTML 2023-10-26 00:03:39 -04:00
9a014af75a Remove my custom Image struct 2023-10-25 23:24:41 -04:00
448231739b Remove my custom Unordered List 2023-10-25 23:08:02 -04:00
b0027032a4 Rename the password field to be compatible with 1Password 2023-10-25 23:05:06 -04:00
410 changed files with 80714 additions and 13763 deletions

1
.envrc
View File

@ -1 +1,2 @@
mkdir .direnv
use flake

6
.gitignore vendored
View File

@ -5,7 +5,7 @@ dist
result
*.tgz
*.tar.gz
file-service/*.sqlite
file-service/*.sqlite-shm
file-service/*.sqlite-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
file-service/var

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"rust-analyzer.showUnlinkedFileNotification": false
}

3642
Cargo.lock generated

File diff suppressed because it is too large Load Diff

19325
Cargo.nix Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,38 @@
[workspace]
resolver = "2"
members = [
"authdb",
# "bike-lights/bike",
"bike-lights/core",
"bike-lights/simulator",
"changeset",
"config",
"config-derive",
"coordinates",
"cyber-slides",
"cyberpunk",
"cyberpunk-splash",
"dashboard",
"emseries",
"file-service",
"fitnesstrax/app",
"fitnesstrax/core",
"fluent-ergonomics",
"geo-types",
"gm-control-panel",
"gm-dash/server",
"hex-grid",
"ifc",
"kifu/core",
"kifu/gtk",
"icon-test",
"l10n-db",
"memorycache",
"nom-training",
"otg/core",
"otg/gtk",
"pico-st7789",
"result-extended",
"screenplay",
"sgf",
"timezone-testing",
"tree",
"visions/server",
]

View File

@ -1,30 +0,0 @@
all: test bin
test: kifu-core/test-oneshot sgf/test-oneshot
bin: kifu-gtk
kifu-core/dev:
cd kifu/core && make test
kifu-core/test:
cd kifu/core && make test
kifu-core/test-oneshot:
cd kifu/core && make test-oneshot
kifu-gtk:
cd kifu/gtk && make release
kifu-gtk/dev:
cd kifu/gtk && make dev
kifu-pwa:
cd kifu/pwa && make release
kifu-pwa/dev:
pushd kifu/pwa && make dev
kifu-pwa/server:
pushd kifu/pwa && make server

16
Taskfile.yml Normal file
View File

@ -0,0 +1,16 @@
version: '3'
tasks:
build:
cmds:
- cargo build --release
update:
cmds:
- task build
- crate2nix generate
- nix build
lint:
cmds:
- cargo watch -x clippy

27
authdb/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "authdb"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "authdb"
path = "src/lib.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
[dependencies]
base64ct = { version = "1", features = [ "alloc" ] }
clap = { version = "4", features = [ "derive" ] }
serde = { version = "1.0", features = ["derive"] }
sha2 = { version = "0.10" }
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
[dev-dependencies]
cool_asserts = "*"

View File

@ -1,5 +1,5 @@
use authdb::{AuthDB, Username};
use clap::{Parser, Subcommand};
use file_service::{AuthDB, Username};
use std::path::PathBuf;
#[derive(Subcommand, Debug)]

302
authdb/src/lib.rs Normal file
View File

@ -0,0 +1,302 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::ops::Deref;
use std::path::PathBuf;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("database failed")]
SqlError(sqlx::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.is_empty() {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.is_empty() {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use cool_asserts::assert_matches;
use std::collections::HashSet;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.authenticate(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.authenticate(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.authenticate(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.validate_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -0,0 +1,12 @@
[build]
target = "thumbv6m-none-eabi"
[target.thumbv6m-none-eabi]
rustflags = [
"-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x",
"-C", "inline-threshold=5",
"-C", "no-vectorize-loops",
]
runner = "elf2uf2-rs -d"

View File

@ -0,0 +1,18 @@
[package]
name = "bike"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
az = { version = "1" }
cortex-m-rt = { version = "0.7.3" }
cortex-m = { version = "0.7.7" }
embedded-alloc = { version = "0.5.1" }
embedded-hal = { version = "0.2.7" }
fixed = { version = "1" }
fugit = { version = "0.3.7" }
lights-core = { path = "../core" }
panic-halt = { version = "0.2.0" }
rp-pico = { version = "0.8.0" }

View File

@ -0,0 +1,244 @@
#![no_main]
#![no_std]
extern crate alloc;
use alloc::boxed::Box;
use az::*;
use core::cell::RefCell;
use cortex_m::delay::Delay;
use embedded_alloc::Heap;
use embedded_hal::{blocking::spi::Write, digital::v2::InputPin, digital::v2::OutputPin};
use fixed::types::I16F16;
use fugit::RateExtU32;
use lights_core::{App, BodyPattern, DashboardPattern, Event, Instant, FPS, UI};
use panic_halt as _;
use rp_pico::{
entry,
hal::{
clocks::init_clocks_and_plls,
gpio::{FunctionSio, Pin, PinId, PullDown, PullUp, SioInput, SioOutput},
pac::{CorePeripherals, Peripherals},
spi::{Enabled, Spi, SpiDevice, ValidSpiPinout},
watchdog::Watchdog,
Clock, Sio,
},
Pins,
};
#[global_allocator]
static HEAP: Heap = Heap::empty();
const LIGHT_SCALE: I16F16 = I16F16::lit("256.0");
const DASHBOARD_BRIGHTESS: u8 = 1;
const BODY_BRIGHTNESS: u8 = 8;
struct DebouncedButton<P: PinId> {
debounce: Instant,
pin: Pin<P, FunctionSio<SioInput>, PullUp>,
}
impl<P: PinId> DebouncedButton<P> {
fn new(pin: Pin<P, FunctionSio<SioInput>, PullUp>) -> Self {
Self {
debounce: Instant((0 as u32).into()),
pin,
}
}
fn is_low(&self, time: Instant) -> bool {
if time <= self.debounce {
return false;
}
self.pin.is_low().unwrap_or(false)
}
fn set_debounce(&mut self, time: Instant) {
self.debounce = time + Instant((250 as u32).into());
}
}
struct BikeUI<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> {
spi: RefCell<Spi<Enabled, D, P, 8>>,
left_blinker_button: DebouncedButton<LeftId>,
right_blinker_button: DebouncedButton<RightId>,
previous_animation_button: DebouncedButton<PreviousId>,
next_animation_button: DebouncedButton<NextId>,
brake_sensor: Pin<BrakeId, FunctionSio<SioInput>, PullUp>,
brake_enabled: bool,
}
impl<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> BikeUI<D, P, LeftId, RightId, PreviousId, NextId, BrakeId>
{
fn new(
spi: Spi<Enabled, D, P, 8>,
left_blinker_button: Pin<LeftId, FunctionSio<SioInput>, PullUp>,
right_blinker_button: Pin<RightId, FunctionSio<SioInput>, PullUp>,
previous_animation_button: Pin<PreviousId, FunctionSio<SioInput>, PullUp>,
next_animation_button: Pin<NextId, FunctionSio<SioInput>, PullUp>,
brake_sensor: Pin<BrakeId, FunctionSio<SioInput>, PullUp>,
) -> Self {
Self {
spi: RefCell::new(spi),
left_blinker_button: DebouncedButton::new(left_blinker_button),
right_blinker_button: DebouncedButton::new(right_blinker_button),
previous_animation_button: DebouncedButton::new(previous_animation_button),
next_animation_button: DebouncedButton::new(next_animation_button),
brake_sensor,
brake_enabled: false,
}
}
}
impl<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> UI for BikeUI<D, P, LeftId, RightId, PreviousId, NextId, BrakeId>
{
fn check_event(&mut self, current_time: Instant) -> Option<Event> {
/*
if self.brake_sensor.is_high().unwrap_or(true) && !self.brake_enabled {
self.brake_enabled = true;
Some(Event::Brake)
} else if self.brake_sensor.is_low().unwrap_or(false) && self.brake_enabled {
self.brake_enabled = false;
Some(Event::BrakeRelease)
} else if self.left_blinker_button.is_low(current_time) {
*/
if self.left_blinker_button.is_low(current_time) {
self.left_blinker_button.set_debounce(current_time);
Some(Event::LeftBlinker)
} else if self.right_blinker_button.is_low(current_time) {
self.right_blinker_button.set_debounce(current_time);
Some(Event::RightBlinker)
} else if self.previous_animation_button.is_low(current_time) {
self.previous_animation_button.set_debounce(current_time);
Some(Event::PreviousPattern)
} else if self.next_animation_button.is_low(current_time) {
self.next_animation_button.set_debounce(current_time);
Some(Event::NextPattern)
} else {
None
}
}
fn update_lights(&self, dashboard_lights: DashboardPattern, body_lights: BodyPattern) {
let mut lights: [u8; 260] = [0; 260];
lights[256] = 0xff;
lights[257] = 0xff;
lights[258] = 0xff;
lights[259] = 0xff;
for (idx, rgb) in dashboard_lights.iter().enumerate() {
lights[(idx + 1) * 4 + 0] = 0xe0 + DASHBOARD_BRIGHTESS;
lights[(idx + 1) * 4 + 1] = (I16F16::from(rgb.r) * LIGHT_SCALE).saturating_as();
lights[(idx + 1) * 4 + 2] = (I16F16::from(rgb.b) * LIGHT_SCALE).saturating_as();
lights[(idx + 1) * 4 + 3] = (I16F16::from(rgb.g) * LIGHT_SCALE).saturating_as();
}
for (idx, rgb) in body_lights.iter().enumerate() {
lights[(idx + 4) * 4 + 0] = 0xe0 + BODY_BRIGHTNESS;
lights[(idx + 4) * 4 + 1] = (I16F16::from(rgb.b) * LIGHT_SCALE).saturating_as();
lights[(idx + 4) * 4 + 2] = (I16F16::from(rgb.g) * LIGHT_SCALE).saturating_as();
lights[(idx + 4) * 4 + 3] = (I16F16::from(rgb.r) * LIGHT_SCALE).saturating_as();
}
let mut spi = self.spi.borrow_mut();
spi.write(lights.as_slice());
}
}
#[entry]
fn main() -> ! {
{
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 8096;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
let mut pac = Peripherals::take().unwrap();
let core = CorePeripherals::take().unwrap();
let sio = Sio::new(pac.SIO);
let mut watchdog = Watchdog::new(pac.WATCHDOG);
let pins = Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let clocks = init_clocks_and_plls(
12_000_000u32,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let mut delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
let mut spi_clk = pins.gpio10.into_function();
let mut spi_sdo = pins.gpio11.into_function();
let spi = Spi::<_, _, _, 8>::new(pac.SPI1, (spi_sdo, spi_clk));
let mut spi = spi.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
1_u32.MHz(),
embedded_hal::spi::MODE_1,
);
let left_blinker_button = pins.gpio16.into_pull_up_input();
let right_blinker_button = pins.gpio17.into_pull_up_input();
let previous_animation_button = pins.gpio27.into_pull_up_input();
let next_animation_button = pins.gpio26.into_pull_up_input();
let brake_sensor = pins.gpio18.into_pull_up_input();
let mut led_pin = pins.led.into_push_pull_output();
let ui = BikeUI::new(
spi,
left_blinker_button,
right_blinker_button,
previous_animation_button,
next_animation_button,
brake_sensor,
);
let mut app = App::new(Box::new(ui));
led_pin.set_high();
let mut time = Instant::default();
let delay_ms = 1000 / (FPS as u32);
loop {
app.tick(time);
delay.delay_ms(delay_ms);
time = time + Instant(delay_ms.into());
}
}

View File

@ -0,0 +1,158 @@
$fn = 50;
threshold = 0.1;
half_threshold = threshold / 2;
bevel = 0.5;
wire_radius = 1;
wall_thickness = 2;
cutout_threshold = 1;
battery_length = 71;
battery_width = 18.75;
cell_holder_length = battery_length + wall_thickness * 2;
cell_holder_width = battery_width + wall_thickness * 2;
cell_holder_height = battery_width + wall_thickness;
battery_contact_thickness = .6;
// battery_contact_thickness = 1;
battery_contact_width = 11;
battery_contact_length = 12.8;
battery_contact_spring_height = 10.5;
battery_contact_flange_height = 1.9;
converter_width = 11.25;
converter_length = 22.25;
converter_height = 5;
include <./common.scad>;
// box(20, 10, 10);
// color("blue", 0.5) cube([10, 20, 10], center = true);
module cell_cradle(width, height) {
difference() {
translate([0, 0, -height / 2]) cube([width,
wall_thickness,
height],
center = true);
color("red", 1) translate([0, 0, 0])
rotate([90, 0, 0])
cylinder(h = wall_thickness + cutout_threshold,
r = width / 2,
center = true);
}
}
module cell_box() {
union() {
channel(cell_holder_length, cell_holder_width, cell_holder_height);
translate([0, -battery_length / 6, wall_thickness]) cell_cradle(cell_holder_width, cell_holder_height / 2);
translate([0, battery_length / 6, wall_thickness]) cell_cradle(cell_holder_width, cell_holder_height / 2);
}
}
module contact_box() {
contact_thickness = battery_contact_flange_height * .75;
cutout_width = battery_contact_width * .8;
// box_thickness = contact_thickness_ + wall_thickness * 2;
// box_height = width + wall_thickness;
difference() {
box(wall_thickness * 2 + contact_thickness, cell_holder_width, cell_holder_height);
translate([0, contact_thickness, wall_thickness * 2])
cube([battery_contact_width,
wall_thickness * 2,
battery_contact_length + threshold],
center = true);
color("red", 1) translate([0,
-(wall_thickness + contact_thickness + threshold) / 2,
cell_holder_height / 2])
cube([5, wall_thickness + threshold * 2, cell_holder_height], center = true);
translate([0,
-(wall_thickness + contact_thickness + threshold) / 2 - wire_radius,
0])
rotate([0, 90, 0])
cylinder(h = cell_holder_width, r = wire_radius, center = true);
color("green", 1) translate([-cell_holder_width / 2, 0, cell_holder_height / 2])
rotate([0, 90, 0])
cylinder(h = 5, r = contact_thickness / 2, center = true);
color("green", 1) translate([cell_holder_width / 2, 0, cell_holder_height / 2])
rotate([0, 90, 0])
cylinder(h = 5, r = contact_thickness / 2, center = true);
}
}
module battery_slot() {
difference() {
union() {
translate([0, -cell_holder_length / 2, 0]) contact_box();
translate([0, wall_thickness, 0]) cell_box();
translate([0, cell_holder_length / 2 + wall_thickness * 2, 0])
rotate([0, 0, 180])
contact_box();
}
translate([cell_holder_width / 2, 1, 0]) rotate([90, 0, 0]) cylinder(h = cell_holder_length + wall_thickness * 4 + battery_contact_flange_height * 2, r = wire_radius, center = true);
translate([-cell_holder_width / 2, 1, 0]) rotate([90, 0, 0]) cylinder(h = cell_holder_length + wall_thickness * 4 + battery_contact_flange_height * 2, r = wire_radius, center = true);
}
}
module converter_box() {
box_length = wall_thickness * 2 + converter_height;
box_width = cell_holder_width * 2 - wall_thickness;
difference() {
box(box_length, box_width, cell_holder_height);
translate([cell_holder_width - wire_radius, 0, 0])
rotate([90, 0, 0])
cylinder(h = box_length, r = wire_radius, center = true);
translate([cell_holder_width - wire_radius * 2, 0, 0])
rotate([0, 90, 0])
cylinder(h = wall_thickness + threshold, r = wire_radius, center = true);
translate([-cell_holder_width + wire_radius, 0, 0])
rotate([90, 0, 0])
cylinder(h = box_length, r = wire_radius, center = true);
translate([-cell_holder_width + wire_radius * 2, 0, 0])
rotate([0, 90, 0])
cylinder(h = wall_thickness + threshold, r = wire_radius, center = true);
translate([0, -box_length / 2, 0])
rotate([0, 90, 0])
cylinder(h = cell_holder_width * 2 + wall_thickness, r = wire_radius, center = true);
translate([-cell_holder_width * .75, (-box_length + wall_thickness) / 2, 0])
rotate([90, 0, 0])
cylinder(h = wall_thickness * 2, r = wire_radius, center = true);
translate([cell_holder_width * .75, (-box_length + wall_thickness) / 2, 0])
rotate([90, 0, 0])
cylinder(h = wall_thickness * 2, r = wire_radius, center = true);
color("red", 1) translate([-box_width / 4, -(converter_height + wall_thickness) / 2, cell_holder_height / 2])
cube([5, wall_thickness + threshold * 2, cell_holder_height], center = true);
color("red", 1) translate([box_width / 4, -(converter_height + wall_thickness) / 2, cell_holder_height / 2])
cube([5, wall_thickness + threshold * 2, cell_holder_height], center = true);
}
}
module battery_case() {
union() {
translate([-cell_holder_width / 2, 0, 0]) battery_slot();
translate([cell_holder_width / 2 - wall_thickness, 0, 0]) battery_slot();
translate([-wall_thickness / 2,
cell_holder_length / 2 + wall_thickness * 2 + battery_contact_flange_height + wall_thickness * 2 + wall_thickness / 2,
0])
converter_box();
}
}
battery_case();

View File

@ -0,0 +1,174 @@
width = 65;
length = 75;
height = 16;
wall_thickness = 2;
guide_thickness = 1;
power_width = 21;
output_width = 37.5;
half_wall_thickness = wall_thickness / 2;
standoff_thickness = 10;
hole_diameter = 3;
// The radius of a nut in mm. However, based on my measurements, I'm not actually sure I have this right. The short height of a nut is 7.86mm. Derive from there.
nut_radius = 8.5 * cos(30) / 2;
nut_height = 2.69; // mm
screw_radius = 2;
handlebar_radius = 15;
clasp_thickness = 4;
clasp_width = 35;
circular_face_count = 48;
module hexagon(r, h) {
pi = 3.1415926;
polyhedron(
points=[
[r, 0, 0],
[r * cos(60), r * sin(60), 0],
[r * cos(120), r * sin(120), 0],
[r * cos(180), r * sin(180), 0],
[r * cos(240), r * sin(240), 0],
[r * cos(300), r * sin(300), 0],
[r, 0, h],
[r * cos(60), r * sin(60), h],
[r * cos(120), r * sin(120), h],
[r * cos(180), r * sin(180), h],
[r * cos(240), r * sin(240), h],
[r * cos(300), r * sin(300), h],
],
faces=[
[0, 1, 2, 3, 4, 5],
[11, 10, 9, 8, 7, 6],
[6, 7, 1, 0],
[7, 8, 2, 1],
[8, 9, 3, 2],
[9, 10, 4, 3],
[10, 11, 5, 4],
[11, 6, 0, 5],
]
);
}
// Nut holders are blocks that have a hole drilled through them and a hexagonal-shaped cavity. The idea is to
module nut_holder() {
difference() {
translate([-4.5, -4.5, -2]) cube([9, 9, 4]);
union() {
translate([0, 0, -1]) hexagon(nut_radius, 2);
cylinder(h = 6, r = screw_radius, center = true, $fn = 24);
}
}
}
module screw_hole() {
union() {
translate([0, 0, 4]) cylinder(h = 2.1, r = screw_radius * 2, center = true, $fn = 24);
cylinder(h = 6, r = screw_radius, center = true, $fn = 24);
}
}
module base() {
cube([width, length, wall_thickness]);
}
module face() {
union() {
cube([width, length, wall_thickness / 2]);
translate([wall_thickness, wall_thickness, wall_thickness / 2]) cube([width-wall_thickness*2, length-wall_thickness*2, wall_thickness / 2]);
translate([4.5 + wall_thickness, 4.5 + wall_thickness, 4]) nut_holder();
translate([width - 4.5 - wall_thickness, 4.5 + wall_thickness, 4]) nut_holder();
translate([width - 4.5 - wall_thickness, length - 4.5 - wall_thickness, 4]) nut_holder();
translate([4.5 + wall_thickness, length - 4.5 - wall_thickness, 4]) nut_holder();
}
}
module wall(length) {
cube([length, height, wall_thickness]);
}
module power_wall() {
difference() {
wall(65);
translate([9, 2, -.5]) cube([power_width, height, wall_thickness + 1]);
}
}
module output_wall() {
difference() {
wall(65);
translate([9, 2, -.5]) cube([output_width, height, wall_thickness + 1]);
}
}
// Use hexagons as cutouts into which I can install a hex nut. This isn't quite right yet, but close.
// hexagon(nut_radius, 1);
// cube([standoff_thickness, standoff_thickness, 2]);
/*
difference() {
union() {
base();
rotate([90, 0, 90]) wall(75);
// translate([width - wall_thickness, 0, 0]) rotate([90, 0, 90]) wall(length);
// rotate([90, 0, 0]) power_wall();
// translate([0, length, 0]) rotate([90, 0, 0]) output_wall();
// translate([wall_thickness,
// wall_thickness,
// wall_thickness]) standoff();
// translate([width - wall_thickness - standoff_thickness,
// wall_thickness,
// wall_thickness]) standoff();
// translate([wall_thickness,
// length - wall_thickness - standoff_thickness,
// wall_thickness]) standoff();
// translate([width - wall_thickness - standoff_thickness,
// length - wall_thickness - standoff_thickness,
// wall_thickness]) standoff();
}
// translate([-half_wall_thickness, -wall_thickness - half_wall_thickness, height - half_wall_thickness]) cube([wall_thickness, length + wall_thickness * 2, wall_thickness]);
// translate([width - half_wall_thickness, -wall_thickness - half_wall_thickness, height - half_wall_thickness]) cube([wall_thickness, length + wall_thickness * 2, wall_thickness]);
// translate([-half_wall_thickness, -half_wall_thickness, height - half_wall_thickness]) rotate([0, 0, 270]) cube([wall_thickness, width + wall_thickness * 2, wall_thickness]);
// translate([-half_wall_thickness, length + half_wall_thickness, height - half_wall_thickness]) rotate([0, 0, 270]) cube([wall_thickness, width + wall_thickness * 2, wall_thickness]);
}
*/
module box() {
difference() {
union() {
cube([width, length, wall_thickness * 2]);
translate([0, 0, wall_thickness]) rotate([90, 0, 90]) wall(length);
translate([width - wall_thickness, 0, wall_thickness]) rotate([90, 0, 90]) wall(length);
translate([0, wall_thickness, wall_thickness]) rotate([90, 0, 0]) wall(width);
translate([0, length, wall_thickness]) rotate([90, 0, 0]) wall(width);
}
translate([4.5 + wall_thickness, 4.5 + wall_thickness, 4]) rotate([180, 0, 0]) screw_hole();
translate([width - 4.5 - wall_thickness, 4.5 + wall_thickness, 4]) rotate([180, 0, 0]) screw_hole();
translate([width - 4.5 - wall_thickness, length - 4.5 - wall_thickness, 4]) rotate([180, 0, 0]) screw_hole();
translate([4.5 + wall_thickness, length - 4.5 - wall_thickness, 4]) rotate([180, 0, 0]) screw_hole();
}
}
module top_clasp() {
difference() {
union() {
cylinder(h = clasp_width, r = handlebar_radius + clasp_thickness, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2 + 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2 - 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([-handlebar_radius-5, -10, -clasp_width / 2 + 6]) cube([6, 20, clasp_width - 12]);
}
translate([-0.5, 0, 0]) cylinder(h = clasp_width+2, r = handlebar_radius + 1, center = true, $fn = circular_face_count);
translate([-0.5, -handlebar_radius - 10, -clasp_width / 2 - 1]) cube([handlebar_radius + 10, handlebar_radius * 2 + 20, clasp_width + 2]);
}
}
module body() {
union() {
box();
translate([width / 2, length / 2, -5 - handlebar_radius]) rotate([0, 90, 90]) top_clasp();
}
}
body();
translate([width + 10, 0, 0]) face();

View File

@ -0,0 +1,21 @@
handlebar_radius = 15;
clasp_thickness = 4;
circular_face_count = 48;
clasp_width = 35;
module top_clasp() {
difference() {
union() {
cylinder(h = clasp_width, r = handlebar_radius + clasp_thickness, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2 + 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2 - 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([-handlebar_radius-5, -10, -clasp_width / 2 + 6]) cube([6, 20, clasp_width - 12]);
}
translate([-0.5, 0, 0]) cylinder(h = clasp_width+2, r = handlebar_radius + 1, center = true, $fn = circular_face_count);
translate([-0.5, -handlebar_radius - 10, -clasp_width / 2 - 1]) cube([handlebar_radius + 10, handlebar_radius * 2 + 20, clasp_width + 2]);
}
}
top_clasp();

View File

@ -0,0 +1,92 @@
module hexagon(r, h) {
cylinder(r = r, h = h, center = 2, $fn = 6);
}
module pill(length, bevel) {
hull() {
translate([0, 0, (-length / 2) + bevel]) sphere(r = bevel);
translate([0, 0, (length / 2) - bevel]) sphere(r = bevel);
}
}
module rounded_cube(dimensions, bevel = 0) {
x = dimensions[0];
y = dimensions[1];
z = dimensions[2];
if (bevel > 0) {
hull() {
translate([-x / 2 + bevel, -y / 2 + bevel, -z / 2 + bevel]) sphere(r = bevel);
translate([ x / 2 - bevel, -y / 2 + bevel, -z / 2 + bevel]) sphere(r = bevel);
translate([ x / 2 - bevel, y / 2 - bevel, -z / 2 + bevel]) sphere(r = bevel);
translate([-x / 2 + bevel, y / 2 - bevel, -z / 2 + bevel]) sphere(r = bevel);
translate([-x / 2 + bevel, -y / 2 + bevel, z / 2 - bevel]) sphere(r = bevel);
translate([ x / 2 - bevel, -y / 2 + bevel, z / 2 - bevel]) sphere(r = bevel);
translate([ x / 2 - bevel, y / 2 - bevel, z / 2 - bevel]) sphere(r = bevel);
translate([-x / 2 + bevel, y / 2 - bevel, z / 2 - bevel]) sphere(r = bevel);
}
} else {
cube(dimensions, center = true);
}
}
module box_face(dimensions, bevel = 0) {
x = dimensions[0];
y = dimensions[1];
z = dimensions[2];
if (bevel > 0) {
translate([0, 0, z / 2])
hull() {
pill(z, bevel);
translate([x, 0, 0])
pill(z, bevel);
translate([x, y, 0])
pill(z, bevel);
translate([0, y, 0])
pill(z, bevel);
}
} else {
cube(dimensions);
}
}
module channel(length, width, height, bevel) {
union() {
box_face([length, width, wall_thickness], bevel);
translate([0, wall_thickness - bevel, bevel])
rotate([90, 0, 0])
box_face([length, height, wall_thickness], bevel);
translate([0, width + bevel, bevel])
rotate([90, 0, 0])
box_face([length, height, wall_thickness], bevel);
}
}
module box(length, width, height, bevel = 0) {
union() {
channel(length, width, height, bevel);
translate([-bevel, 0, bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
box_face([width, height, wall_thickness], bevel);
translate([length - wall_thickness + bevel, 0, bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
box_face([width, height, wall_thickness], bevel);
}
}
module box_side_slider(length, width, height) {
difference() {
box_face([width - wall_thickness * 2 + 4, height, wall_thickness], bevel);
translate([-1, -1, 1]) cube([4-threshold, height+2, 4-threshold]);
color("red") translate([width - wall_thickness * 2 + 1, -1, 1]) cube([4-threshold, height+2, 4-threshold]);
}
}

View File

@ -0,0 +1,210 @@
$fn = 50;
threshold = 0.1;
board_length = 92;
board_width = 72;
board_height = 21.5;
wall_thickness = 4;
bevel = 0.5;
hinge_radius = 2.5;
case_width = board_width + wall_thickness * 2;
case_length = board_length + wall_thickness * 2;
case_height = board_height + wall_thickness;
handlebar_radius = 15;
clasp_thickness = 4;
circular_face_count = 48;
clasp_width = 35;
include <./common.scad>;
module top_clasp() {
difference() {
union() {
cylinder(h = clasp_width, r = handlebar_radius + clasp_thickness, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, -clasp_width / 2 + 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([0, 0, clasp_width / 2 - 4]) cylinder(h = 1, r = handlebar_radius + clasp_thickness + 1, center = true, $fn = circular_face_count);
translate([-handlebar_radius-5, -10, -clasp_width / 2 + 6]) cube([6, 20, clasp_width - 12]);
}
translate([-0.5, 0, 0]) cylinder(h = clasp_width+2, r = handlebar_radius + 1, center = true, $fn = circular_face_count);
translate([-0.5, -handlebar_radius - 10, -clasp_width / 2 - 1]) cube([handlebar_radius + 10, handlebar_radius * 2 + 20, clasp_width + 2]);
}
}
module hinge(length) {
difference() {
union() {
cube([hinge_radius * 2, length, hinge_radius], center = true);
translate([0, 0, -1.5]) rotate([90, 0, 0]) cylinder(h = length, r = hinge_radius, center = true);
}
translate([0, threshold / 2, -1.5]) rotate([90, 0, 0]) cylinder(h = length + threshold * 2, r = 1, center = true);
}
}
module base_case(length, width, height, bevel = 0) {
difference() {
union() {
channel(length + wall_thickness / 2, width, height, bevel);
translate([-bevel, 0, bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
box_face([width, height, wall_thickness], bevel);
// These are the sleds at the bottom of the case that should hold the lower of the two boards down
color("blue") translate([0, wall_thickness - 2, wall_thickness + 4]) cube([length - 8, 4, wall_thickness / 2]);
color("blue") translate([wall_thickness - 2, wall_thickness - 4, wall_thickness + 4]) cube([4, width, wall_thickness / 2]);
color("blue") translate([length - 25, width - wall_thickness * 3 / 2, wall_thickness + 6]) cube([16, wall_thickness, wall_thickness / 2]);
}
// This makes an indent at the bottom to accomodate solder joins
translate([wall_thickness + 2, wall_thickness + 2, wall_thickness / 2]) cube([length, width - wall_thickness * 2 - 4, wall_thickness / 2 + threshold]);
// This creates a cutout that lets the power plug slide in better.
translate([wall_thickness, width - wall_thickness, wall_thickness]) cube([length, 2, 6]);
// These two put in the slots that should allow the fourth wall to be slotted into place.
color("red") translate([length - 1, wall_thickness - 2, 4]) cube([2, 2, height]);
color("red") translate([length - 1, width - wall_thickness, 4]) cube([2, 2, height]);
}
}
module main_case() {
hinge_length = board_length / 4;
hinge_y_offset = board_width + wall_thickness + hinge_radius;
hinge_z_offset = board_height;
difference() {
union() {
base_case(case_length,
case_width,
case_height,
bevel);
translate([-bevel, 0, bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
box_face([case_width, case_height, wall_thickness], bevel);
translate([0, -hinge_radius - bevel + threshold, hinge_z_offset + bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
hinge(case_length / 4);
translate([case_length - hinge_length, -hinge_radius - bevel + threshold, hinge_z_offset + bevel])
rotate([90, 0, 0])
rotate([0, 90, 0])
hinge(case_length / 4);
translate([43, case_width, wall_thickness + 8])
rotate([90, 0, 0])
rotate([0, 180, 0])
linear_extrude(1)
text("lights", size = 3);
translate([67, case_width, wall_thickness + 8])
rotate([90, 0, 0])
rotate([0, 180, 0])
linear_extrude(1)
text("left", size = 3);
translate([55, case_width, wall_thickness + 8])
rotate([90, 0, 0])
rotate([0, 180, 0])
linear_extrude(1)
text("right", size = 3);
// translate([case_length / 2, case_width / 2, -20]) rotate([0, 90, 0]) top_clasp();
}
translate([case_length / 2, case_width / 2, -threshold]) hexagon(4.5, 6);
# translate([8.5 + wall_thickness, case_width - wall_thickness - threshold, wall_thickness])
# cube([60, wall_thickness * 2, 7]);
}
}
module lamp() {
union() {
translate([0, 0, -0.5]) cube([12.9 + threshold, 8, 4], center = true);
translate([0, 0, .88]) cube([5 + threshold, 5 + threshold, 1.56], center = true);
/*
translate([0, 0, -1.56]) cube([12.9, 7.6, wall_thickness], center = true);
*/
}
}
module button() {
union() {
cube([3.5 + threshold, 6.1 + threshold, 4 + threshold], center = true);
translate([0, 0, -0.5]) cube([1.2, 7, 3 + threshold], center = true);
}
}
module lid() {
lid_width = case_width + hinge_radius * 2 + wall_thickness;
hinge_length = case_length / 4;
union() {
difference() {
rounded_cube([case_length,
lid_width,
wall_thickness],
bevel);
translate([0, lid_width / 5, 0.4]) lamp();
translate([-15, lid_width / 5, 0.4]) lamp();
translate([15, lid_width / 5, 0.4]) lamp();
translate([-30, lid_width / 5, 0]) button();
translate([30, lid_width / 5, 0]) button();
translate([0, lid_width / 5, -2]) cube([20, 7, 3], center = true);
color("black") translate([-2, lid_width / 5 - 5, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h=5, r = 1, center = true, $fn = circular_face_count);
color("black") translate([-17, lid_width / 5 - 5, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h=5, r = 1, center = true, $fn = circular_face_count);
color("black") translate([13, lid_width / 5 - 5, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h=5, r = 1, center = true, $fn = circular_face_count);
color("black") translate([-30, lid_width / 5 - 5, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h=5, r = 1, center = true, $fn = circular_face_count);
color("black") translate([30, lid_width / 5 - 5, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h=5, r = 1, center = true, $fn = circular_face_count);
color("black") translate([0, 10, -2]) rotate([0, 90, 0]) cylinder(h = 62, r = 1, center = true, $fn = circular_face_count);
color("red") translate([-33, 21, -2]) rotate([0, 90, 0]) cylinder(h = 5, r = 1, center = true, $fn = circular_face_count);
color("red") translate([-35, 13, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h = 18, r = 1, center = true, $fn = circular_face_count);
color("red") translate([33, 21, -2]) rotate([0, 90, 0]) cylinder(h = 5, r = 1, center = true, $fn = circular_face_count);
color("red") translate([35, 13, -2]) rotate([0, 0, 90]) rotate([0, 90, 0]) cylinder(h = 18, r = 1, center = true, $fn = circular_face_count);
color("red") translate([0, 5, -2]) rotate([0, 90, 0]) cylinder(h = 70, r = 1, center = true, $fn = circular_face_count);
}
translate([case_length / 2 - hinge_length / 2, lid_width / 2 - wall_thickness / 2 - 0.5, -wall_thickness / 2]) rotate([0, 0, 90]) hinge(hinge_length);
translate([-case_length / 2 + hinge_length / 2, lid_width / 2 - wall_thickness / 2 - 0.5, -wall_thickness / 2]) rotate([0, 0, 90]) hinge(hinge_length);
translate([0, -lid_width / 2 + bevel, -3]) rounded_cube([20, wall_thickness / 2, 10], bevel);
color("blue") translate([-9, -lid_width / 2 + 1.5, -6]) rotate([90, 0, 0]) rotate([0, 90, 0]) linear_extrude(18) circle(1, $fn = 3);
color("blue") translate([-9, -lid_width / 2 + 1.5, -7]) rotate([90, 0, 0]) rotate([0, 90, 0]) linear_extrude(18) circle(1, $fn = 3);
}
}
module box_side() {
box_side_slider(case_length, case_width, case_height);
}
module case_base() {
difference() {
rounded_cube([case_length, case_width, wall_thickness + 2], bevel = 0.5);
translate([wall_thickness, 0, 2]) rounded_cube([case_length + threshold, board_width + threshold, 2 + threshold]);
// These give a screw-hole in the center which will allow the clamp to be attached
translate([0, 0, -1]) hexagon(4.5, 2);
translate([0, 0, -wall_thickness / 2]) cylinder(r = 2, h = wall_thickness + threshold, center = true);
// and now a bit of an indentation to help the clip remain in place
translate([0, 0, -4.5]) cube([clasp_width + threshold, clasp_width + threshold, wall_thickness], center = true);
// here are some grooves along the edges that can be used to piece parts together
translate([wall_thickness / 2, case_width / 2 - wall_thickness / 2, wall_thickness / 2])
cube([board_length + wall_thickness, wall_thickness / 2, wall_thickness / 2 + threshold], center = true);
translate([wall_thickness / 2, -case_width / 2 + wall_thickness / 2, wall_thickness / 2])
cube([board_length + wall_thickness, wall_thickness / 2, wall_thickness / 2 + threshold], center = true);
}
}

View File

@ -0,0 +1,11 @@
include <./control_panel.scad>
/*
difference() {
color("blue") rounded_cube([5, 5, 5], bevel = 0.5);
translate([0, 0, 1]) rounded_cube([4, 4, 4]);
};
*/
case_base();

View File

@ -0,0 +1,6 @@
include <./control_panel.scad>
lid();
// lamp();

View File

@ -0,0 +1,4 @@
include <./control_panel.scad>
box_side();

View File

@ -0,0 +1,10 @@
[package]
name = "lights-core"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
az = { version = "1" }
fixed = { version = "1" }

472
bike-lights/core/src/lib.rs Normal file
View File

@ -0,0 +1,472 @@
#![no_std]
extern crate alloc;
use alloc::boxed::Box;
use az::*;
use core::{
clone::Clone,
cmp::PartialEq,
default::Default,
ops::{Add, Sub},
option::Option,
};
use fixed::types::{I48F16, I8F8, U128F0, U16F0};
mod patterns;
pub use patterns::*;
mod types;
pub use types::{BodyPattern, DashboardPattern, RGB};
fn calculate_frames(starting_time: U128F0, now: U128F0) -> U16F0 {
let frames_128 = (now - starting_time) / U128F0::from(FPS);
(frames_128 % U128F0::from(U16F0::MAX)).cast()
}
fn calculate_slope(start: I8F8, end: I8F8, frames: U16F0) -> I8F8 {
let slope_i16f16 = (I48F16::from(end) - I48F16::from(start)) / I48F16::from(frames);
slope_i16f16.saturating_as()
}
fn linear_ease(value: I8F8, frames: U16F0, slope: I8F8) -> I8F8 {
let value_i16f16 = I48F16::from(value) + I48F16::from(frames) * I48F16::from(slope);
value_i16f16.saturating_as()
}
#[derive(Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub struct Instant(pub U128F0);
impl Default for Instant {
fn default() -> Self {
Self(U128F0::from(0_u8))
}
}
impl Add for Instant {
type Output = Self;
fn add(self, r: Self) -> Self::Output {
Self(self.0 + r.0)
}
}
impl Sub for Instant {
type Output = Self;
fn sub(self, r: Self) -> Self::Output {
Self(self.0 - r.0)
}
}
pub const FPS: u8 = 30;
pub trait UI {
fn check_event(&mut self, current_time: Instant) -> Option<Event>;
fn update_lights(&self, dashboard_lights: DashboardPattern, body_lights: BodyPattern);
}
pub trait Animation {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern);
}
/*
pub struct DefaultAnimation {}
impl Animation for DefaultAnimation {
fn tick(&mut self, _: Instant) -> (DashboardPattern, BodyPattern) {
(WATER_DASHBOARD, WATER_BODY)
}
}
*/
pub struct Fade {
starting_dashboard: DashboardPattern,
starting_lights: BodyPattern,
start_time: Instant,
dashboard_slope: [RGB<I8F8>; 3],
body_slope: [RGB<I8F8>; 60],
frames: U16F0,
}
impl Fade {
fn new(
dashboard: DashboardPattern,
lights: BodyPattern,
ending_dashboard: DashboardPattern,
ending_lights: BodyPattern,
frames: U16F0,
time: Instant,
) -> Self {
let mut dashboard_slope = [Default::default(); 3];
let mut body_slope = [Default::default(); 60];
for i in 0..3 {
let slope = RGB {
r: calculate_slope(dashboard[i].r, ending_dashboard[i].r, frames),
g: calculate_slope(dashboard[i].g, ending_dashboard[i].g, frames),
b: calculate_slope(dashboard[i].b, ending_dashboard[i].b, frames),
};
dashboard_slope[i] = slope;
}
for i in 0..60 {
let slope = RGB {
r: calculate_slope(lights[i].r, ending_lights[i].r, frames),
g: calculate_slope(lights[i].g, ending_lights[i].g, frames),
b: calculate_slope(lights[i].b, ending_lights[i].b, frames),
};
body_slope[i] = slope;
}
Self {
starting_dashboard: dashboard,
starting_lights: lights,
start_time: time,
dashboard_slope,
body_slope,
frames,
}
}
}
impl Animation for Fade {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern) {
let mut frames = calculate_frames(self.start_time.0, time.0);
if frames > self.frames {
frames = self.frames
}
let mut dashboard_pattern: DashboardPattern = OFF_DASHBOARD;
let mut body_pattern: BodyPattern = OFF_BODY;
for i in 0..3 {
dashboard_pattern[i].r = linear_ease(
self.starting_dashboard[i].r,
frames,
self.dashboard_slope[i].r,
);
dashboard_pattern[i].g = linear_ease(
self.starting_dashboard[i].g,
frames,
self.dashboard_slope[i].g,
);
dashboard_pattern[i].b = linear_ease(
self.starting_dashboard[i].b,
frames,
self.dashboard_slope[i].b,
);
}
for i in 0..60 {
body_pattern[i].r =
linear_ease(self.starting_lights[i].r, frames, self.body_slope[i].r);
body_pattern[i].g =
linear_ease(self.starting_lights[i].g, frames, self.body_slope[i].g);
body_pattern[i].b =
linear_ease(self.starting_lights[i].b, frames, self.body_slope[i].b);
}
(dashboard_pattern, body_pattern)
}
}
#[derive(Debug)]
pub enum FadeDirection {
Transition,
FadeIn,
FadeOut,
}
pub enum BlinkerDirection {
Left,
Right,
}
pub struct Blinker {
transition: Fade,
fade_in: Fade,
fade_out: Fade,
direction: FadeDirection,
start_time: Instant,
frames: U16F0,
}
impl Blinker {
fn new(
starting_dashboard: DashboardPattern,
starting_body: BodyPattern,
direction: BlinkerDirection,
time: Instant,
) -> Self {
let mut ending_dashboard = OFF_DASHBOARD;
match direction {
BlinkerDirection::Left => {
ending_dashboard[0].r = LEFT_BLINKER_DASHBOARD[0].r;
ending_dashboard[0].g = LEFT_BLINKER_DASHBOARD[0].g;
ending_dashboard[0].b = LEFT_BLINKER_DASHBOARD[0].b;
}
BlinkerDirection::Right => {
ending_dashboard[2].r = RIGHT_BLINKER_DASHBOARD[2].r;
ending_dashboard[2].g = RIGHT_BLINKER_DASHBOARD[2].g;
ending_dashboard[2].b = RIGHT_BLINKER_DASHBOARD[2].b;
}
}
let mut ending_body = OFF_BODY;
match direction {
BlinkerDirection::Left => {
for i in 0..30 {
ending_body[i].r = LEFT_BLINKER_BODY[i].r;
ending_body[i].g = LEFT_BLINKER_BODY[i].g;
ending_body[i].b = LEFT_BLINKER_BODY[i].b;
}
}
BlinkerDirection::Right => {
for i in 30..60 {
ending_body[i].r = RIGHT_BLINKER_BODY[i].r;
ending_body[i].g = RIGHT_BLINKER_BODY[i].g;
ending_body[i].b = RIGHT_BLINKER_BODY[i].b;
}
}
}
Blinker {
transition: Fade::new(
starting_dashboard,
starting_body,
ending_dashboard,
ending_body,
BLINKER_FRAMES,
time,
),
fade_in: Fade::new(
OFF_DASHBOARD,
OFF_BODY,
ending_dashboard,
ending_body,
BLINKER_FRAMES,
time,
),
fade_out: Fade::new(
ending_dashboard,
ending_body,
OFF_DASHBOARD,
OFF_BODY,
BLINKER_FRAMES,
time,
),
direction: FadeDirection::Transition,
start_time: time,
frames: BLINKER_FRAMES,
}
}
}
impl Animation for Blinker {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern) {
let frames = calculate_frames(self.start_time.0, time.0);
if frames > self.frames {
match self.direction {
FadeDirection::Transition => {
self.direction = FadeDirection::FadeOut;
self.fade_out.start_time = time;
}
FadeDirection::FadeIn => {
self.direction = FadeDirection::FadeOut;
self.fade_out.start_time = time;
}
FadeDirection::FadeOut => {
self.direction = FadeDirection::FadeIn;
self.fade_in.start_time = time;
}
}
self.start_time = time;
}
match self.direction {
FadeDirection::Transition => self.transition.tick(time),
FadeDirection::FadeIn => self.fade_in.tick(time),
FadeDirection::FadeOut => self.fade_out.tick(time),
}
}
}
#[derive(Clone, Debug)]
pub enum Event {
Brake,
BrakeRelease,
LeftBlinker,
NextPattern,
PreviousPattern,
RightBlinker,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pattern {
Water,
GayPride,
TransPride,
}
impl Pattern {
fn previous(&self) -> Pattern {
match self {
Pattern::Water => Pattern::TransPride,
Pattern::GayPride => Pattern::Water,
Pattern::TransPride => Pattern::GayPride,
}
}
fn next(&self) -> Pattern {
match self {
Pattern::Water => Pattern::GayPride,
Pattern::GayPride => Pattern::TransPride,
Pattern::TransPride => Pattern::Water,
}
}
fn dashboard(&self) -> DashboardPattern {
match self {
Pattern::Water => WATER_DASHBOARD,
Pattern::GayPride => PRIDE_DASHBOARD,
Pattern::TransPride => TRANS_PRIDE_DASHBOARD,
}
}
fn body(&self) -> BodyPattern {
match self {
Pattern::Water => WATER_BODY,
Pattern::GayPride => PRIDE_BODY,
Pattern::TransPride => TRANS_PRIDE_BODY,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum State {
Pattern(Pattern),
Brake,
LeftBlinker,
RightBlinker,
BrakeLeftBlinker,
BrakeRightBlinker,
}
pub struct App {
ui: Box<dyn UI>,
state: State,
home_pattern: Pattern,
current_animation: Box<dyn Animation>,
dashboard_lights: DashboardPattern,
lights: BodyPattern,
}
impl App {
pub fn new(ui: Box<dyn UI>) -> Self {
let pattern = Pattern::Water;
Self {
ui,
state: State::Pattern(pattern),
home_pattern: pattern,
current_animation: Box::new(Fade::new(
OFF_DASHBOARD,
OFF_BODY,
pattern.dashboard(),
pattern.body(),
DEFAULT_FRAMES,
Instant(0_u32.into()),
)),
dashboard_lights: OFF_DASHBOARD,
lights: OFF_BODY,
}
}
fn update_animation(&mut self, time: Instant) {
match self.state {
State::Pattern(ref pattern) => {
self.current_animation = Box::new(Fade::new(
self.dashboard_lights,
self.lights,
pattern.dashboard(),
pattern.body(),
DEFAULT_FRAMES,
time,
))
}
State::Brake => {
self.current_animation = Box::new(Fade::new(
self.dashboard_lights,
self.lights,
BRAKES_DASHBOARD,
BRAKES_BODY,
BRAKES_FRAMES,
time,
));
}
State::LeftBlinker => {
self.current_animation = Box::new(Blinker::new(
self.dashboard_lights,
self.lights,
BlinkerDirection::Left,
time,
));
}
State::RightBlinker => {
self.current_animation = Box::new(Blinker::new(
self.dashboard_lights,
self.lights,
BlinkerDirection::Right,
time,
));
}
State::BrakeLeftBlinker => (),
State::BrakeRightBlinker => (),
}
}
fn update_state(&mut self, event: Event) {
match event {
Event::Brake => {
if self.state == State::Brake {
self.state = State::Pattern(self.home_pattern);
} else {
self.state = State::Brake;
}
}
Event::BrakeRelease => self.state = State::Pattern(self.home_pattern),
Event::LeftBlinker => match self.state {
State::Brake => self.state = State::BrakeLeftBlinker,
State::BrakeLeftBlinker => self.state = State::Brake,
State::LeftBlinker => self.state = State::Pattern(self.home_pattern),
_ => self.state = State::LeftBlinker,
},
Event::NextPattern => if let State::Pattern(ref pattern) = self.state {
self.home_pattern = pattern.next();
self.state = State::Pattern(self.home_pattern);
},
Event::PreviousPattern => if let State::Pattern(ref pattern) = self.state {
self.home_pattern = pattern.previous();
self.state = State::Pattern(self.home_pattern);
},
Event::RightBlinker => match self.state {
State::Brake => self.state = State::BrakeRightBlinker,
State::BrakeRightBlinker => self.state = State::Brake,
State::RightBlinker => self.state = State::Pattern(self.home_pattern),
_ => self.state = State::RightBlinker,
},
}
}
pub fn tick(&mut self, time: Instant) {
if let Some(event) = self.ui.check_event(time) {
self.update_state(event);
self.update_animation(time);
};
let (dashboard, lights) = self.current_animation.tick(time);
self.dashboard_lights = dashboard;
self.lights = lights;
self.ui.update_lights(dashboard, lights);
}
}

View File

@ -0,0 +1,400 @@
use crate::{BodyPattern, DashboardPattern, RGB};
use fixed::types::{I8F8, U16F0};
pub const RGB_OFF: RGB<I8F8> = RGB {
r: I8F8::lit("0"),
g: I8F8::lit("0"),
b: I8F8::lit("0"),
};
pub const RGB_WHITE: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("1"),
b: I8F8::lit("1"),
};
pub const BRAKES_RED: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("0"),
b: I8F8::lit("0"),
};
pub const BLINKER_AMBER: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("0.15"),
b: I8F8::lit("0"),
};
pub const PRIDE_RED: RGB<I8F8> = RGB {
r: I8F8::lit("0.95"),
g: I8F8::lit("0.00"),
b: I8F8::lit("0.00"),
};
pub const PRIDE_ORANGE: RGB<I8F8> = RGB {
r: I8F8::lit("1.0"),
g: I8F8::lit("0.25"),
b: I8F8::lit("0"),
};
pub const PRIDE_YELLOW: RGB<I8F8> = RGB {
r: I8F8::lit("1.0"),
g: I8F8::lit("0.85"),
b: I8F8::lit("0"),
};
pub const PRIDE_GREEN: RGB<I8F8> = RGB {
r: I8F8::lit("0"),
g: I8F8::lit("0.95"),
b: I8F8::lit("0.05"),
};
pub const PRIDE_INDIGO: RGB<I8F8> = RGB {
r: I8F8::lit("0.04"),
g: I8F8::lit("0.15"),
b: I8F8::lit("0.55"),
};
pub const PRIDE_VIOLET: RGB<I8F8> = RGB {
r: I8F8::lit("0.75"),
g: I8F8::lit("0.0"),
b: I8F8::lit("0.80"),
};
pub const TRANS_BLUE: RGB<I8F8> = RGB {
r: I8F8::lit("0.06"),
g: I8F8::lit("0.41"),
b: I8F8::lit("0.98"),
};
pub const TRANS_PINK: RGB<I8F8> = RGB {
r: I8F8::lit("0.96"),
g: I8F8::lit("0.16"),
b: I8F8::lit("0.32"),
};
pub const WATER_1: RGB<I8F8> = RGB {
r: I8F8::lit("0.0"),
g: I8F8::lit("0.0"),
b: I8F8::lit("0.75"),
};
pub const WATER_2: RGB<I8F8> = RGB {
r: I8F8::lit("0.8"),
g: I8F8::lit("0.8"),
b: I8F8::lit("0.8"),
};
pub const WATER_3: RGB<I8F8> = RGB {
r: I8F8::lit("0.00"),
g: I8F8::lit("0.75"),
b: I8F8::lit("0.75"),
};
pub const OFF_DASHBOARD: DashboardPattern = [RGB_OFF; 3];
pub const OFF_BODY: BodyPattern = [RGB_OFF; 60];
pub const DEFAULT_FRAMES: U16F0 = U16F0::lit("30");
pub const WATER_DASHBOARD: DashboardPattern = [WATER_1, WATER_2, WATER_3];
pub const WATER_BODY: BodyPattern = [
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_3,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_2,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
WATER_1,
];
pub const PRIDE_DASHBOARD: DashboardPattern = [PRIDE_RED, PRIDE_GREEN, PRIDE_INDIGO];
pub const PRIDE_BODY: BodyPattern = [
// Left Side
// Red
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
// Orange
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
// Yellow
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
// Green
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
// Indigo
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
// Violet
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
// Right Side
// Violet
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
// Indigo
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
// Green
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
// Yellow
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
// Orange
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
// Red
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
];
pub const TRANS_PRIDE_DASHBOARD: DashboardPattern = [TRANS_BLUE, RGB_WHITE, TRANS_PINK];
pub const TRANS_PRIDE_BODY: BodyPattern = [
// Left Side
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_PINK, TRANS_PINK,
TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, RGB_WHITE, RGB_WHITE, RGB_WHITE, RGB_WHITE,
RGB_WHITE, RGB_WHITE, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK,
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE,
// Right side
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_PINK, TRANS_PINK,
TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, RGB_WHITE, RGB_WHITE, RGB_WHITE, RGB_WHITE,
RGB_WHITE, RGB_WHITE, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK,
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE,
];
pub const BRAKES_FRAMES: U16F0 = U16F0::lit("15");
pub const BRAKES_DASHBOARD: DashboardPattern = [BRAKES_RED; 3];
pub const BRAKES_BODY: BodyPattern = [BRAKES_RED; 60];
pub const BLINKER_FRAMES: U16F0 = U16F0::lit("10");
pub const LEFT_BLINKER_DASHBOARD: DashboardPattern = [BLINKER_AMBER, RGB_OFF, RGB_OFF];
pub const LEFT_BLINKER_BODY: BodyPattern = [
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
];
pub const RIGHT_BLINKER_DASHBOARD: DashboardPattern = [RGB_OFF, RGB_OFF, BLINKER_AMBER];
pub const RIGHT_BLINKER_BODY: BodyPattern = [
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
];

View File

@ -0,0 +1,17 @@
use core::default::Default;
use fixed::types::I8F8;
#[derive(Clone, Copy, Default, Debug)]
pub struct RGB<T> {
pub r: T,
pub g: T,
pub b: T,
}
const DASHBOARD_LIGHT_COUNT: usize = 3;
pub type DashboardPattern = [RGB<I8F8>; DASHBOARD_LIGHT_COUNT];
const BODY_LIGHT_COUNT: usize = 60;
pub type BodyPattern = [RGB<I8F8>; BODY_LIGHT_COUNT];

View File

@ -0,0 +1,17 @@
[package]
name = "simulator"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
async-std = "1.13.0"
cairo-rs = { version = "0.18" }
fixed = { version = "1" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_8" ] }
lights-core = { path = "../core" }
pango = { version = "*" }

View File

@ -0,0 +1,315 @@
use adw::prelude::*;
use async_std::channel::Sender;
use fixed::types::{I8F8, U128F0};
use glib::Object;
use gtk::subclass::prelude::*;
use lights_core::{
App, BodyPattern, DashboardPattern, Event, Instant, FPS, OFF_BODY, OFF_DASHBOARD, RGB, UI,
};
use std::{
cell::RefCell,
env,
rc::Rc,
sync::mpsc::{Receiver, TryRecvError},
};
const WIDTH: i32 = 640;
const HEIGHT: i32 = 480;
pub struct Update {
dashboard: DashboardPattern,
lights: BodyPattern,
}
pub struct DashboardLightsPrivate {
lights: Rc<RefCell<DashboardPattern>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DashboardLightsPrivate {
const NAME: &'static str = "DashboardLights";
type Type = DashboardLights;
type ParentType = gtk::DrawingArea;
fn new() -> Self {
Self {
lights: Rc::new(RefCell::new(OFF_DASHBOARD)),
}
}
}
impl ObjectImpl for DashboardLightsPrivate {}
impl WidgetImpl for DashboardLightsPrivate {}
impl DrawingAreaImpl for DashboardLightsPrivate {}
glib::wrapper! {
pub struct DashboardLights(ObjectSubclass<DashboardLightsPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl Default for DashboardLights {
fn default() -> Self {
Self::new()
}
}
impl DashboardLights {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(WIDTH);
s.set_height_request(100);
s.set_draw_func({
let s = s.clone();
move |_, context, width, _| {
let start = width as f64 / 2. - 150.;
let lights = s.imp().lights.borrow();
for i in 0..3 {
context.set_source_rgb(
lights[i].r.into(),
lights[i].g.into(),
lights[i].b.into(),
);
context.rectangle(start + 100. * i as f64, 10., 80., 80.);
let _ = context.fill();
}
}
});
s
}
pub fn set_lights(&self, lights: DashboardPattern) {
*self.imp().lights.borrow_mut() = lights;
self.queue_draw();
}
}
pub struct BikeLightsPrivate {
lights: Rc<RefCell<BodyPattern>>,
}
#[glib::object_subclass]
impl ObjectSubclass for BikeLightsPrivate {
const NAME: &'static str = "BikeLights";
type Type = BikeLights;
type ParentType = gtk::DrawingArea;
fn new() -> Self {
Self {
lights: Rc::new(RefCell::new(OFF_BODY)),
}
}
}
impl ObjectImpl for BikeLightsPrivate {}
impl WidgetImpl for BikeLightsPrivate {}
impl DrawingAreaImpl for BikeLightsPrivate {}
glib::wrapper! {
pub struct BikeLights(ObjectSubclass<BikeLightsPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl Default for BikeLights {
fn default() -> Self {
Self::new()
}
}
impl BikeLights {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(WIDTH);
s.set_height_request(640);
let center = WIDTH as f64 / 2.;
s.set_draw_func({
let s = s.clone();
move |_, context, _, _| {
let lights = s.imp().lights.borrow();
for i in 0..30 {
context.set_source_rgb(
lights[i].r.into(),
lights[i].g.into(),
lights[i].b.into(),
);
context.rectangle(center - 45., 5. + 20. * i as f64, 15., 15.);
let _ = context.fill();
}
for i in 0..30 {
context.set_source_rgb(
lights[i + 30].r.into(),
lights[i + 30].g.into(),
lights[i + 30].b.into(),
);
context.rectangle(center + 15., 5. + 20. * (30. - (i + 1) as f64), 15., 15.);
let _ = context.fill();
}
}
});
s
}
pub fn set_lights(&self, lights: [RGB<I8F8>; 60]) {
*self.imp().lights.borrow_mut() = lights;
self.queue_draw();
}
}
struct GTKUI {
tx: Sender<Update>,
rx: Receiver<Event>,
}
impl UI for GTKUI {
fn check_event(&mut self, _: Instant) -> Option<Event> {
match self.rx.try_recv() {
Ok(event) => Some(event),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => None,
}
}
fn update_lights(&self, dashboard_lights: DashboardPattern, lights: BodyPattern) {
let tx = self.tx.clone();
glib::spawn_future(async move {
let _ = tx
.send(Update {
dashboard: dashboard_lights,
lights,
})
.await;
});
}
}
fn main() {
let adw_app = adw::Application::builder()
.application_id("com.luminescent-dreams.bike-light-simulator")
.build();
adw_app.connect_activate(move |adw_app| {
let (update_tx, update_rx) = async_std::channel::unbounded();
let (event_tx, event_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut bike_app = App::new(Box::new(GTKUI {
tx: update_tx,
rx: event_rx,
}));
loop {
bike_app.tick(Instant(U128F0::from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
)));
std::thread::sleep(std::time::Duration::from_millis(1000 / (FPS as u64)));
}
});
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.default_width(WIDTH)
.default_height(HEIGHT)
.build();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let dashboard_lights = DashboardLights::new();
let bike_lights = BikeLights::new();
let left_button = gtk::Button::builder().label("L").build();
let brake_button = gtk::Button::builder().label("Brakes").build();
let right_button = gtk::Button::builder().label("R").build();
left_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::LeftBlinker);
}
});
brake_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::Brake);
}
});
right_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::RightBlinker);
}
});
controls.append(&left_button);
controls.append(&brake_button);
controls.append(&right_button);
layout.append(&controls);
let pattern_controls = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let previous_pattern = gtk::Button::builder().label("Previous").build();
let next_pattern = gtk::Button::builder().label("Next").build();
previous_pattern.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::PreviousPattern);
}
});
next_pattern.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::NextPattern);
}
});
pattern_controls.append(&previous_pattern);
pattern_controls.append(&next_pattern);
layout.append(&pattern_controls);
layout.append(&dashboard_lights);
layout.append(&bike_lights);
glib::spawn_future_local({
let dashboard_lights = dashboard_lights.clone();
let bike_lights = bike_lights.clone();
async move {
while let Ok(Update { dashboard, lights }) = update_rx.recv().await {
dashboard_lights.set_lights(dashboard);
bike_lights.set_lights(lights);
}
}
});
/*
update_rx.attach(None, {
let dashboard_lights = dashboard_lights.clone();
let bike_lights = bike_lights.clone();
move |Update { dashboard, lights }| {
dashboard_lights.set_lights(dashboard);
bike_lights.set_lights(lights);
glib::ControlFlow::Continue
}
});
*/
window.set_content(Some(&layout));
window.present();
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RUST_ALL_TARGETS=(
"changeset"
"config"
"config-derive"
"coordinates"
"cyberpunk-splash"
"dashboard"
"emseries"
"file-service"
"fluent-ergonomics"
"geo-types"
"gm-control-panel"
"hex-grid"
"ifc"
"kifu-core"
"kifu-gtk"
"memorycache"
"nom-training"
"result-extended"
"screenplay"
"sgf"
"tree"
)
build_rust_targets() {
local CMD=$1
local TARGETS=${@/$CMD}
for target in $TARGETS; do
MODULE=$target CMD=$CMD ./builders/rust.sh
done
}
build_dist() {
local TARGETS=${@/$CMD}
for target in $TARGETS; do
if [ -f $target/dist.sh ]; then
build_rust_targets release ${TARGETS[*]}
cd $target && ./dist.sh
fi
done
}
export CARGO=`which cargo`
if [ -z "${TARGET-}" ]; then
TARGET="all"
fi
if [ -z "${CMD-}" ]; then
CMD="test release"
fi
if [ "${CMD}" == "clean" ]; then
cargo clean
exit 0
fi
for cmd in $CMD; do
if [ "${CMD}" == "dist" ]; then
build_dist $TARGET
elif [ "${TARGET}" == "all" ]; then
build_rust_targets $cmd ${RUST_ALL_TARGETS[*]}
else
build_rust_targets $cmd $TARGET
fi
done

View File

@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ ! -z "$MODULE" ]; then
MODULE="-p $MODULE"
fi
if [ -z "${PARAMS-}" ]; then
PARAMS=""
fi
case $CMD in
build)
$CARGO build $MODULE $PARAMS
;;
lint)
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
;;
test)
$CARGO test $MODULE $PARAMS
;;
run)
$CARGO run $MODULE $PARAMS
;;
release)
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
$CARGO build --release $MODULE $PARAMS
$CARGO test --release $MODULE $PARAMS
;;
clean)
$CARGO clean $MODULE
;;
"")
echo "No command specified. Use build | lint | test | run | release | clean"
;;
*)
echo "$CMD is unknown. Use build | lint | test | run | release | clean"
;;
esac

View File

@ -35,11 +35,17 @@ macro_rules! define_config {
$($name($struct)),+
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Config {
values: std::collections::HashMap<ConfigName, ConfigOption>,
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl Config {
pub fn new() -> Self {
Self {

View File

@ -145,7 +145,7 @@ mod tests {
let coord2 = &lst1[idx];
assert!(coord2.is_adjacent(&coord1));
assert!(coord1.is_adjacent(&coord2));
assert!(coord1.is_adjacent(coord2));
}
#[test]
@ -166,10 +166,10 @@ mod tests {
let hexaddr = AxialAddr::new(q, r);
let en_distancaj_hexaddr: Vec<AxialAddr> = hexaddr.addresses(distance).collect();
let expected_cnt = ((0..distance+1).map(|v| v * 6).fold(1, |acc, val| acc + val)) as usize;
let expected_cnt = (0..distance+1).map(|v| v * 6).fold(1, |acc, val| acc + val);
assert_eq!(en_distancaj_hexaddr.len(), expected_cnt);
for c in en_distancaj_hexaddr {
assert!(c.distance(&hexaddr) <= distance as usize);
assert!(c.distance(&hexaddr) <= distance);
}
}
}

519
crate-hashes.json Normal file
View File

@ -0,0 +1,519 @@
{
"registry+https://github.com/rust-lang/crates.io-index#addr2line@0.24.2": "1hd1i57zxgz08j6h5qrhsnm2fi0bcqvsh389fw400xm3arz2ggnz",
"registry+https://github.com/rust-lang/crates.io-index#adler2@2.0.0": "09r6drylvgy8vv8k20lnbvwq8gp09h7smfn6h1rxsy15pgh629si",
"registry+https://github.com/rust-lang/crates.io-index#adler32@1.2.0": "0d7jq7jsjyhsgbhnfq5fvrlh9j0i9g1fqrl2735ibv5f75yjgqda",
"registry+https://github.com/rust-lang/crates.io-index#adler@1.0.2": "1zim79cvzd5yrkzl3nyfx0avijwgk9fqv3yrscdy1cc79ih02qpj",
"registry+https://github.com/rust-lang/crates.io-index#aho-corasick@1.1.3": "05mrpkvdgp5d20y2p989f187ry9diliijgwrs254fs9s1m1x6q4f",
"registry+https://github.com/rust-lang/crates.io-index#allocator-api2@0.2.21": "08zrzs022xwndihvzdn78yqarv2b9696y67i6h78nla3ww87jgb8",
"registry+https://github.com/rust-lang/crates.io-index#android-tzdata@0.1.1": "1w7ynjxrfs97xg3qlcdns4kgfpwcdv824g611fq32cag4cdr96g9",
"registry+https://github.com/rust-lang/crates.io-index#android_system_properties@0.1.5": "04b3wrz12837j7mdczqd95b732gw5q7q66cv4yn4646lvccp57l1",
"registry+https://github.com/rust-lang/crates.io-index#annotate-snippets@0.9.2": "07p8r6jzb7nqydq0kr5pllckqcdxlyld2g275v425axnzffpxbyc",
"registry+https://github.com/rust-lang/crates.io-index#anstream@0.6.18": "16sjk4x3ns2c3ya1x28a44kh6p47c7vhk27251i015hik1lm7k4a",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-parse@0.2.6": "1acqayy22fwzsrvr6n0lz6a4zvjjcvgr5sm941m7m0b2fr81cb9v",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-query@1.1.2": "036nm3lkyk43xbps1yql3583fp4hg3b1600is7mcyxs1gzrpm53r",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-wincon@3.0.7": "0kmf0fq4c8yribdpdpylzz1zccpy84hizmcsac3wrac1f7kk8dfa",
"registry+https://github.com/rust-lang/crates.io-index#anstyle@1.0.10": "1yai2vppmd7zlvlrp9grwll60knrmscalf8l2qpfz8b7y5lkpk2m",
"registry+https://github.com/rust-lang/crates.io-index#anyhow@1.0.95": "010vd1ki8w84dzgx6c81sc8qm9n02fxic1gkpv52zp4nwrn0kb1l",
"registry+https://github.com/rust-lang/crates.io-index#async-channel@1.9.0": "0dbdlkzlncbibd3ij6y6jmvjd0cmdn48ydcfdpfhw09njd93r5c1",
"registry+https://github.com/rust-lang/crates.io-index#async-channel@2.3.1": "0skvwxj6ysfc6d7bhczz9a2550260g62bm5gl0nmjxxyn007id49",
"registry+https://github.com/rust-lang/crates.io-index#async-executor@1.13.1": "1v6w1dbvsmw6cs4dk4lxj5dvrikc6xi479wikwaab2qy3h09mjih",
"registry+https://github.com/rust-lang/crates.io-index#async-global-executor@2.4.1": "1762s45cc134d38rrv0hyp41hv4iv6nmx59vswid2p0il8rvdc85",
"registry+https://github.com/rust-lang/crates.io-index#async-io@2.4.0": "0n8h0vy53n4vdkq529scqnkzm9vcl3r73za9nj81s2nfrhiv78j3",
"registry+https://github.com/rust-lang/crates.io-index#async-lock@3.4.0": "060vh45i809wcqyxzs5g69nqiqah7ydz0hpkcjys9258vqn4fvpz",
"registry+https://github.com/rust-lang/crates.io-index#async-std@1.13.0": "059nbiyijwbndyrz0050skvlvzhds0dmnl0biwmxwbw055glfd66",
"registry+https://github.com/rust-lang/crates.io-index#async-task@4.7.1": "1pp3avr4ri2nbh7s6y9ws0397nkx1zymmcr14sq761ljarh3axcb",
"registry+https://github.com/rust-lang/crates.io-index#async-trait@0.1.85": "0mm0gwad44zs7mna4a0m1z4dhzpmydfj73w4wm23c8xpnhrli4rz",
"registry+https://github.com/rust-lang/crates.io-index#atoi@2.0.0": "0a05h42fggmy7h0ajjv6m7z72l924i7igbx13hk9d8pyign9k3gj",
"registry+https://github.com/rust-lang/crates.io-index#atomic-waker@1.1.2": "1h5av1lw56m0jf0fd3bchxq8a30xv0b4wv8s4zkp4s0i7mfvs18m",
"registry+https://github.com/rust-lang/crates.io-index#autocfg@0.1.8": "0y4vw4l4izdxq1v0rrhvmlbqvalrqrmk60v1z0dqlgnlbzkl7phd",
"registry+https://github.com/rust-lang/crates.io-index#autocfg@1.4.0": "09lz3by90d2hphbq56znag9v87gfpd9gb8nr82hll8z6x2nhprdc",
"registry+https://github.com/rust-lang/crates.io-index#az@1.2.1": "0ww9k1w3al7x5qmb7f13v3s9c2pg1pdxbs8xshqy6zyrchj4qzkv",
"registry+https://github.com/rust-lang/crates.io-index#backtrace@0.3.74": "06pfif7nwx66qf2zaanc2fcq7m64i91ki9imw9xd3bnz5hrwp0ld",
"registry+https://github.com/rust-lang/crates.io-index#base64@0.21.7": "0rw52yvsk75kar9wgqfwgb414kvil1gn7mqkrhn9zf1537mpsacx",
"registry+https://github.com/rust-lang/crates.io-index#base64@0.22.1": "1imqzgh7bxcikp5vx3shqvw9j09g9ly0xr0jma0q66i52r7jbcvj",
"registry+https://github.com/rust-lang/crates.io-index#base64@0.9.3": "0hs62r35bgxslawyrn1vp9rmvrkkm76fqv0vqcwd048vs876r7a8",
"registry+https://github.com/rust-lang/crates.io-index#base64ct@1.6.0": "0nvdba4jb8aikv60az40x2w1y96sjdq8z3yp09rwzmkhiwv1lg4c",
"registry+https://github.com/rust-lang/crates.io-index#bindgen@0.69.5": "1240snlcfj663k04bjsg629g4wx6f83flgbjh5rzpgyagk3864r7",
"registry+https://github.com/rust-lang/crates.io-index#bit-set@0.8.0": "18riaa10s6n59n39vix0cr7l2dgwdhcpbcm97x1xbyfp1q47x008",
"registry+https://github.com/rust-lang/crates.io-index#bit-vec@0.8.0": "1xxa1s2cj291r7k1whbxq840jxvmdsq9xgh7bvrxl46m80fllxjy",
"registry+https://github.com/rust-lang/crates.io-index#bit_field@0.10.2": "0qav5rpm4hqc33vmf4vc4r0mh51yjx5vmd9zhih26n9yjs3730nw",
"registry+https://github.com/rust-lang/crates.io-index#bitflags@1.3.2": "12ki6w8gn1ldq7yz9y680llwk5gmrhrzszaa17g1sbrw2r2qvwxy",
"registry+https://github.com/rust-lang/crates.io-index#bitflags@2.8.0": "0dixc6168i98652jxf0z9nbyn0zcis3g6hi6qdr7z5dbhcygas4g",
"registry+https://github.com/rust-lang/crates.io-index#block-buffer@0.10.4": "0w9sa2ypmrsqqvc20nhwr75wbb5cjr4kkyhpjm1z1lv2kdicfy1h",
"registry+https://github.com/rust-lang/crates.io-index#blocking@1.6.1": "1si99l8zp7c4zq87y35ayjgc5c9b60jb8h0k14zfcs679z2l2gvh",
"registry+https://github.com/rust-lang/crates.io-index#build_html@2.5.0": "0p4k25yk3v0wf720wl5zcghvc9ik6l7lsh3fz86cq3g7x4nbhpi2",
"registry+https://github.com/rust-lang/crates.io-index#bumpalo@3.16.0": "0b015qb4knwanbdlp1x48pkb4pm57b8gidbhhhxr900q2wb6fabr",
"registry+https://github.com/rust-lang/crates.io-index#bytemuck@1.21.0": "18wj81x9xhqcd6985r8qxmbik6szjfjfj62q3xklw8h2p3x7srgg",
"registry+https://github.com/rust-lang/crates.io-index#byteorder@1.5.0": "0jzncxyf404mwqdbspihyzpkndfgda450l0893pz5xj685cg5l0z",
"registry+https://github.com/rust-lang/crates.io-index#bytes@1.9.0": "16ykzx24v1x4f42v2lxyvlczqhdfji3v7r4ghwckpwijzvb1hn9j",
"registry+https://github.com/rust-lang/crates.io-index#cairo-rs@0.18.5": "1qjfkcq3mrh3p01nnn71dy3kn99g21xx3j8xcdvzn8ll2pq6x8lc",
"registry+https://github.com/rust-lang/crates.io-index#cairo-sys-rs@0.18.2": "0lfsxl7ylw3phbnwmz3k58j1gnqi6kc2hdc7g3bb7f4hwnl9yp38",
"registry+https://github.com/rust-lang/crates.io-index#cc@1.2.10": "0aaj2ivamhfzhgb9maasnfkh03s2mzhzpzwrkghgzbkfnv5qy80k",
"registry+https://github.com/rust-lang/crates.io-index#cexpr@0.6.0": "0rl77bwhs5p979ih4r0202cn5jrfsrbgrksp40lkfz5vk1x3ib3g",
"registry+https://github.com/rust-lang/crates.io-index#cfg-expr@0.15.8": "00lgf717pmf5qd2qsxxzs815v6baqg38d6m5i6wlh235p14asryh",
"registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.0": "1za0vb97n4brpzpv8lsbnzmq5r8f2b0cpqqr0sy8h5bn751xxwds",
"registry+https://github.com/rust-lang/crates.io-index#chrono-tz-build@0.2.1": "03rmzd69cn7fp0fgkjr5042b3g54s2l941afjm3001ls7kqkjgj3",
"registry+https://github.com/rust-lang/crates.io-index#chrono-tz@0.8.6": "0vlksnmpb6rd4h55245agnfhphnpslwnq9al3aw3is43dd3f16nm",
"registry+https://github.com/rust-lang/crates.io-index#chrono@0.4.39": "09g8nf409lb184kl9j4s85k0kn8wzgjkp5ls9zid50b886fwqdky",
"registry+https://github.com/rust-lang/crates.io-index#clang-sys@1.8.1": "1x1r9yqss76z8xwpdanw313ss6fniwc1r7dzb5ycjn0ph53kj0hb",
"registry+https://github.com/rust-lang/crates.io-index#clap@4.5.30": "0vcyrn4ymq2gd56sl3xnfki8q8llg64sj3rj3qx33mgsf66v3dwj",
"registry+https://github.com/rust-lang/crates.io-index#clap_builder@4.5.30": "0369xis2ar46icsaxqyy37976mlb62alzyx4j53k99vq2w3v4pd3",
"registry+https://github.com/rust-lang/crates.io-index#clap_derive@4.5.28": "1vgigkhljp3r8r5lwdrn1ij93nafmjwh8cx77nppb9plqsaysk5z",
"registry+https://github.com/rust-lang/crates.io-index#clap_lex@0.7.4": "19nwfls5db269js5n822vkc8dw0wjq2h1wf0hgr06ld2g52d2spl",
"registry+https://github.com/rust-lang/crates.io-index#cloudabi@0.0.3": "0kxcg83jlihy0phnd2g8c2c303px3l2p3pkjz357ll6llnd5pz6x",
"registry+https://github.com/rust-lang/crates.io-index#color_quant@1.1.0": "12q1n427h2bbmmm1mnglr57jaz2dj9apk0plcxw7nwqiai7qjyrx",
"registry+https://github.com/rust-lang/crates.io-index#colorchoice@1.0.3": "1439m3r3jy3xqck8aa13q658visn71ki76qa93cy55wkmalwlqsv",
"registry+https://github.com/rust-lang/crates.io-index#concurrent-queue@2.5.0": "0wrr3mzq2ijdkxwndhf79k952cp4zkz35ray8hvsxl96xrx1k82c",
"registry+https://github.com/rust-lang/crates.io-index#const-oid@0.9.6": "1y0jnqaq7p2wvspnx7qj76m7hjcqpz73qzvr9l2p9n2s51vr6if2",
"registry+https://github.com/rust-lang/crates.io-index#convert_case@0.6.0": "1jn1pq6fp3rri88zyw6jlhwwgf6qiyc08d6gjv0qypgkl862n67c",
"registry+https://github.com/rust-lang/crates.io-index#cookie-factory@0.3.3": "18mka6fk3843qq3jw1fdfvzyv05kx7kcmirfbs2vg2kbw9qzm1cq",
"registry+https://github.com/rust-lang/crates.io-index#cookie@0.17.0": "096c52jg9iq4lfcps2psncswv33fc30mmnaa2sbzzcfcw71kgyvy",
"registry+https://github.com/rust-lang/crates.io-index#cool_asserts@2.0.3": "1v18dg7ifx41k2f82j3gsnpm1fg9wk5s4zv7sf42c7pnad72b7zf",
"registry+https://github.com/rust-lang/crates.io-index#core-foundation-sys@0.8.7": "12w8j73lazxmr1z0h98hf3z623kl8ms7g07jch7n4p8f9nwlhdkp",
"registry+https://github.com/rust-lang/crates.io-index#core-foundation@0.9.4": "13zvbbj07yk3b61b8fhwfzhy35535a583irf23vlcg59j7h9bqci",
"registry+https://github.com/rust-lang/crates.io-index#cpufeatures@0.2.16": "1hy466fkhxjbb16i7na95wz8yr14d0kd578pwzj5lbkz14jh5f0n",
"registry+https://github.com/rust-lang/crates.io-index#crc-catalog@2.4.0": "1xg7sz82w3nxp1jfn425fvn1clvbzb3zgblmxsyqpys0dckp9lqr",
"registry+https://github.com/rust-lang/crates.io-index#crc32fast@1.4.2": "1czp7vif73b8xslr3c9yxysmh9ws2r8824qda7j47ffs9pcnjxx9",
"registry+https://github.com/rust-lang/crates.io-index#crc@3.2.1": "0dnn23x68qakzc429s1y9k9y3g8fn5v9jwi63jcz151sngby9rk9",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-deque@0.8.6": "0l9f1saqp1gn5qy0rxvkmz4m6n7fc0b3dbm6q1r5pmgpnyvi3lcx",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-epoch@0.9.18": "03j2np8llwf376m3fxqx859mgp9f83hj1w34153c7a9c7i5ar0jv",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-queue@0.3.12": "059igaxckccj6ndmg45d5yf7cm4ps46c18m21afq3pwiiz1bnn0g",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-utils@0.8.21": "0a3aa2bmc8q35fb67432w16wvi54sfmb69rk9h5bhd18vw0c99fh",
"registry+https://github.com/rust-lang/crates.io-index#crunchy@0.2.2": "1dx9mypwd5mpfbbajm78xcrg5lirqk7934ik980mmaffg3hdm0bs",
"registry+https://github.com/rust-lang/crates.io-index#crypto-common@0.1.6": "1cvby95a6xg7kxdz5ln3rl9xh66nz66w46mm3g56ri1z5x815yqv",
"registry+https://github.com/rust-lang/crates.io-index#data-encoding@2.7.0": "0vxdv88fnvnxw29gk84gj5662xlv0p5hmlxpwp7d60cckp8fwq0f",
"registry+https://github.com/rust-lang/crates.io-index#deflate@0.8.6": "0x6iqlayg129w63999kz97m279m0jj4x4sm6gkqlvmp73y70yxvk",
"registry+https://github.com/rust-lang/crates.io-index#der@0.7.9": "1h4vzjfa1lczxdf8avfj9qlwh1qianqlxdy1g5rn762qnvkzhnzm",
"registry+https://github.com/rust-lang/crates.io-index#deranged@0.3.11": "1d1ibqqnr5qdrpw8rclwrf1myn3wf0dygl04idf4j2s49ah6yaxl",
"registry+https://github.com/rust-lang/crates.io-index#digest@0.10.7": "14p2n6ih29x81akj097lvz7wi9b6b9hvls0lwrv7b6xwyy0s5ncy",
"registry+https://github.com/rust-lang/crates.io-index#dimensioned@0.7.0": "09ky8s3higkf677lmyqg30hmj66gpg7hx907s6hfvbk2a9av05r5",
"registry+https://github.com/rust-lang/crates.io-index#dimensioned@0.8.0": "15s3j4ry943xqlac63bp81sgdk9s3yilysabzww35j9ibmnaic50",
"registry+https://github.com/rust-lang/crates.io-index#displaydoc@0.2.5": "1q0alair462j21iiqwrr21iabkfnb13d6x5w95lkdg21q2xrqdlp",
"registry+https://github.com/rust-lang/crates.io-index#dotenvy@0.15.7": "16s3n973n5aqym02692i1npb079n5mb0fwql42ikmwn8wnrrbbqs",
"registry+https://github.com/rust-lang/crates.io-index#either@1.13.0": "1w2c1mybrd7vljyxk77y9f4w9dyjrmp3yp82mk7bcm8848fazcb0",
"registry+https://github.com/rust-lang/crates.io-index#encoding_rs@0.8.35": "1wv64xdrr9v37rqqdjsyb8l8wzlcbab80ryxhrszvnj59wy0y0vm",
"registry+https://github.com/rust-lang/crates.io-index#env_logger@0.10.2": "1005v71kay9kbz1d5907l0y7vh9qn2fqsp2yfgb8bjvin6m0bm2c",
"registry+https://github.com/rust-lang/crates.io-index#equivalent@1.0.1": "1malmx5f4lkfvqasz319lq6gb3ddg19yzf9s8cykfsgzdmyq0hsl",
"registry+https://github.com/rust-lang/crates.io-index#errno@0.3.10": "0pgblicz1kjz9wa9m0sghkhh2zw1fhq1mxzj7ndjm746kg5m5n1k",
"registry+https://github.com/rust-lang/crates.io-index#etcetera@0.8.0": "0hxrsn75dirbjhwgkdkh0pnpqrnq17ypyhjpjaypgax1hd91nv8k",
"registry+https://github.com/rust-lang/crates.io-index#event-listener-strategy@0.5.3": "1ch5gf6knllyq12jkb5zdfag573dh44307q4pwwi2g37sc6lwgiw",
"registry+https://github.com/rust-lang/crates.io-index#event-listener@2.5.3": "1q4w3pndc518crld6zsqvvpy9lkzwahp2zgza9kbzmmqh9gif1h2",
"registry+https://github.com/rust-lang/crates.io-index#event-listener@5.4.0": "1bii2gn3vaa33s0gr2zph7cagiq0ppcfxcxabs24ri9z9kgar4il",
"registry+https://github.com/rust-lang/crates.io-index#exr@1.73.0": "1q47yq78q9k210r6jy1wwrilxwwxqavik9l3l426rd17k7srfcgq",
"registry+https://github.com/rust-lang/crates.io-index#fastrand@2.3.0": "1ghiahsw1jd68df895cy5h3gzwk30hndidn3b682zmshpgmrx41p",
"registry+https://github.com/rust-lang/crates.io-index#fdeflate@0.3.7": "130ga18vyxbb5idbgi07njymdaavvk6j08yh1dfarm294ssm6s0y",
"registry+https://github.com/rust-lang/crates.io-index#field-offset@0.3.6": "0zq5sssaa2ckmcmxxbly8qgz3sxpb8g1lwv90sdh1z74qif2gqiq",
"registry+https://github.com/rust-lang/crates.io-index#fixed@1.28.0": "0nn85j5x8yzx10q49jdzia4yp6pnasnxpnwh0p9aqr7qkfwf1il5",
"registry+https://github.com/rust-lang/crates.io-index#flate2@1.0.35": "0z6h0wa095wncpfngx75wyhyjnqwld7wax401gsvnzjhzgdbydn9",
"registry+https://github.com/rust-lang/crates.io-index#fluent-bundle@0.15.3": "14zl0cjn361is69pb1zry4k2zzh5nzsfv0iz05wccl00x0ga5q3z",
"registry+https://github.com/rust-lang/crates.io-index#fluent-langneg@0.13.0": "152yxplc11vmxkslvmaqak9x86xnavnhdqyhrh38ym37jscd0jic",
"registry+https://github.com/rust-lang/crates.io-index#fluent-syntax@0.11.1": "0gd3cdvsx9ymbb8hijcsc9wyf8h1pbcbpsafg4ldba56ji30qlra",
"registry+https://github.com/rust-lang/crates.io-index#fluent@0.16.1": "0njmdpwz52yjzyp55iik9k6vrixqiy7190d98pk0rgdy0x3n6x5v",
"registry+https://github.com/rust-lang/crates.io-index#flume@0.11.1": "15ch0slxa8sqsi6c73a0ky6vdnh48q8cxjf7rksa3243m394s3ns",
"registry+https://github.com/rust-lang/crates.io-index#fnv@1.0.7": "1hc2mcqha06aibcaza94vbi81j6pr9a1bbxrxjfhc91zin8yr7iz",
"registry+https://github.com/rust-lang/crates.io-index#foldhash@0.1.4": "0vsxw2iwpgs7yy6l7pndm7b8nllaq5vdxwnmjn1qpm5kyzhzvlm0",
"registry+https://github.com/rust-lang/crates.io-index#foreign-types-shared@0.1.1": "0jxgzd04ra4imjv8jgkmdq59kj8fsz6w4zxsbmlai34h26225c00",
"registry+https://github.com/rust-lang/crates.io-index#foreign-types@0.3.2": "1cgk0vyd7r45cj769jym4a6s7vwshvd0z4bqrb92q1fwibmkkwzn",
"registry+https://github.com/rust-lang/crates.io-index#form_urlencoded@1.2.1": "0milh8x7nl4f450s3ddhg57a3flcv6yq8hlkyk6fyr3mcb128dp1",
"registry+https://github.com/rust-lang/crates.io-index#fuchsia-cprng@0.1.1": "1fnkqrbz7ixxzsb04bsz9p0zzazanma8znfdqjvh39n14vapfvx0",
"registry+https://github.com/rust-lang/crates.io-index#futures-channel@0.3.31": "040vpqpqlbk099razq8lyn74m0f161zd0rp36hciqrwcg2zibzrd",
"registry+https://github.com/rust-lang/crates.io-index#futures-core@0.3.31": "0gk6yrxgi5ihfanm2y431jadrll00n5ifhnpx090c2f2q1cr1wh5",
"registry+https://github.com/rust-lang/crates.io-index#futures-executor@0.3.31": "17vcci6mdfzx4gbk0wx64chr2f13wwwpvyf3xd5fb1gmjzcx2a0y",
"registry+https://github.com/rust-lang/crates.io-index#futures-intrusive@0.5.0": "0vwm08d1pli6bdaj0i7xhk3476qlx4pll6i0w03gzdnh7lh0r4qx",
"registry+https://github.com/rust-lang/crates.io-index#futures-io@0.3.31": "1ikmw1yfbgvsychmsihdkwa8a1knank2d9a8dk01mbjar9w1np4y",
"registry+https://github.com/rust-lang/crates.io-index#futures-lite@2.6.0": "0cmmgszlmkwsac9pyw5rfjakmshgx4wmzmlyn6mmjs0jav4axvgm",
"registry+https://github.com/rust-lang/crates.io-index#futures-macro@0.3.31": "0l1n7kqzwwmgiznn0ywdc5i24z72zvh9q1dwps54mimppi7f6bhn",
"registry+https://github.com/rust-lang/crates.io-index#futures-sink@0.3.31": "1xyly6naq6aqm52d5rh236snm08kw8zadydwqz8bip70s6vzlxg5",
"registry+https://github.com/rust-lang/crates.io-index#futures-task@0.3.31": "124rv4n90f5xwfsm9qw6y99755y021cmi5dhzh253s920z77s3zr",
"registry+https://github.com/rust-lang/crates.io-index#futures-util@0.3.31": "10aa1ar8bgkgbr4wzxlidkqkcxf77gffyj8j7768h831pcaq784z",
"registry+https://github.com/rust-lang/crates.io-index#futures@0.3.31": "0xh8ddbkm9jy8kc5gbvjp9a4b6rqqxvc8471yb2qaz5wm2qhgg35",
"registry+https://github.com/rust-lang/crates.io-index#gdk-pixbuf-sys@0.18.0": "1xya543c4ffd2n7aiwwrdxsyc9casdbasafi6ixcknafckm3k61z",
"registry+https://github.com/rust-lang/crates.io-index#gdk-pixbuf@0.18.5": "1v7svvl0g7zybndmis5inaqqgi1mvcc6s1n8rkb31f5zn3qzbqah",
"registry+https://github.com/rust-lang/crates.io-index#gdk4-sys@0.7.2": "1w7yvir565sjrrw828lss07749hfpfsr19jdjzwivkx36brl7ayv",
"registry+https://github.com/rust-lang/crates.io-index#gdk4@0.7.3": "1xiacc63p73apr033gjrb9dsk0y4yxnsljwfxbwfry41snd03nvy",
"registry+https://github.com/rust-lang/crates.io-index#generic-array@0.11.2": "0a7w8w0rg47nmcinnfzv443lcyb8mplwc251p1jyr5xj2yh6wzv6",
"registry+https://github.com/rust-lang/crates.io-index#generic-array@0.14.7": "16lyyrzrljfq424c3n8kfwkqihlimmsg5nhshbbp48np3yjrqr45",
"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.2.15": "1mzlnrb3dgyd1fb84gvw10pyr8wdqdl4ry4sr64i1s8an66pqmn4",
"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.3.1": "1y154yzby383p63ndw6zpfm0fz3vf6c0zdwc7df6vkl150wrr923",
"registry+https://github.com/rust-lang/crates.io-index#gif@0.11.4": "01hbw3isapzpzff8l6aw55jnaqx2bcscrbwyf3rglkbbfp397p9y",
"registry+https://github.com/rust-lang/crates.io-index#gif@0.13.1": "1whrkvdg26gp1r7f95c6800y6ijqw5y0z8rgj6xihpi136dxdciz",
"registry+https://github.com/rust-lang/crates.io-index#gimli@0.31.1": "0gvqc0ramx8szv76jhfd4dms0zyamvlg4whhiz11j34hh3dqxqh7",
"registry+https://github.com/rust-lang/crates.io-index#gio-sys@0.18.1": "1lip8z35iy9d184x2qwjxlbxi64q9cpayy7v1p5y9xdsa3w6smip",
"registry+https://github.com/rust-lang/crates.io-index#gio@0.18.4": "0wsc6mnx057s4ailacg99dwgna38dbqli5x7a6y9rdw75x9qzz6l",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.16.3": "1z73bl10zmxwrv16v4f5wcky1f3z5a2v0hknca54al4k2p5ka695",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.17.10": "05p7ab2vn8962cbchi7a6hndhvw64nqk4w5kpg5z53iizsgdfrbs",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.18.0": "0p5c2ayiam5bkp9wvq9f9ihwp06nqs5j801npjlwnhrl8rpwac9l",
"registry+https://github.com/rust-lang/crates.io-index#glib-macros@0.18.5": "1p5cla53fcp195zp0hkqpmnn7iwmkdswhy7xh34002bw8y7j5c0b",
"registry+https://github.com/rust-lang/crates.io-index#glib-sys@0.18.1": "164qhsfmlzd5mhyxs8123jzbdfldwxbikfpq5cysj3lddbmy4g06",
"registry+https://github.com/rust-lang/crates.io-index#glib@0.18.5": "1r8fw0627nmn19bgk3xpmcfngx3wkn7mcpq5a8ma3risx3valg93",
"registry+https://github.com/rust-lang/crates.io-index#glob@0.3.2": "1cm2w34b5w45fxr522h5b0fv1bxchfswcj560m3pnjbia7asvld8",
"registry+https://github.com/rust-lang/crates.io-index#gloo-timers@0.3.0": "1519157n7xppkk6pdw5w52vy1llzn5iljkqd7q1h5609jv7l7cdv",
"registry+https://github.com/rust-lang/crates.io-index#gobject-sys@0.18.0": "0i6fhp3m6vs3wkzyc22rk2cqj68qvgddxmpaai34l72da5xi4l08",
"registry+https://github.com/rust-lang/crates.io-index#graphene-rs@0.18.1": "00f4q1ra4haap5i7lazwhkdgnb49fs8adk2nm6ki6mjhl76jh8iv",
"registry+https://github.com/rust-lang/crates.io-index#graphene-sys@0.18.1": "0n8zlg7z26lwpnvlqp1hjlgrs671skqwagdpm7r8i1zwx3748hfc",
"registry+https://github.com/rust-lang/crates.io-index#grid@0.9.0": "0iswdcxggyxp9m1rz0m7bfg4xacinvn78zp2fgfp0l0079x10d06",
"registry+https://github.com/rust-lang/crates.io-index#gsk4-sys@0.7.3": "0mbdlm9qi1hql48rr29vsj9vlqwc7gxg67wg1q19z67azwz9xg8j",
"registry+https://github.com/rust-lang/crates.io-index#gsk4@0.7.3": "0zhzs2dkgiinhgc11akpn2harq3x5n1iq21dnc4h689g3lsqx58d",
"registry+https://github.com/rust-lang/crates.io-index#gtk4-macros@0.7.2": "0bw3cchiycf7dw1bw4p8946gv38azxy05a5w0ndgcmxnz6fc8znm",
"registry+https://github.com/rust-lang/crates.io-index#gtk4-sys@0.7.3": "1f2ylskyqkjdik9fij2m46pra4jagnif5xyalbxfk3334fmc9n2l",
"registry+https://github.com/rust-lang/crates.io-index#gtk4@0.7.3": "0hh8nzglmz94v1m1h6vy8z12m6fr7ia467ry0md5fa4p7sm53sss",
"registry+https://github.com/rust-lang/crates.io-index#h2@0.3.26": "1s7msnfv7xprzs6xzfj5sg6p8bjcdpcqcmjjbkd345cyi1x55zl1",
"registry+https://github.com/rust-lang/crates.io-index#half@2.4.1": "123q4zzw1x4309961i69igzd1wb7pj04aaii3kwasrz3599qrl3d",
"registry+https://github.com/rust-lang/crates.io-index#hashbrown@0.15.2": "12dj0yfn59p3kh3679ac0w1fagvzf4z2zp87a13gbbqbzw0185dz",
"registry+https://github.com/rust-lang/crates.io-index#hashlink@0.10.0": "1h8lzvnl9qxi3zyagivzz2p1hp6shgddfmccyf6jv7s1cdicz0kk",
"registry+https://github.com/rust-lang/crates.io-index#headers-core@0.2.0": "0ab469xfpd411mc3dhmjhmzrhqikzyj8a17jn5bkj9zfpy0n9xp7",
"registry+https://github.com/rust-lang/crates.io-index#headers@0.3.9": "0w62gnwh2p1lml0zqdkrx9dp438881nhz32zrzdy61qa0a9kns06",
"registry+https://github.com/rust-lang/crates.io-index#heck@0.4.1": "1a7mqsnycv5z4z5vnv1k34548jzmc0ajic7c1j8jsaspnhw5ql4m",
"registry+https://github.com/rust-lang/crates.io-index#heck@0.5.0": "1sjmpsdl8czyh9ywl3qcsfsq9a307dg4ni2vnlwgnzzqhc4y0113",
"registry+https://github.com/rust-lang/crates.io-index#hermit-abi@0.3.9": "092hxjbjnq5fmz66grd9plxd0sh6ssg5fhgwwwqbrzgzkjwdycfj",
"registry+https://github.com/rust-lang/crates.io-index#hermit-abi@0.4.0": "1k1zwllx6nfq417hy38x4akw1ivlv68ymvnzyxs76ffgsqcskxpv",
"registry+https://github.com/rust-lang/crates.io-index#hex-string@0.1.0": "02sgrgrbp693jv0v5iga7z47y6aj93cq0ia39finby9x17fw53l4",
"registry+https://github.com/rust-lang/crates.io-index#hex@0.4.3": "0w1a4davm1lgzpamwnba907aysmlrnygbqmfis2mqjx5m552a93z",
"registry+https://github.com/rust-lang/crates.io-index#hkdf@0.12.4": "1xxxzcarz151p1b858yn5skmhyrvn8fs4ivx5km3i1kjmnr8wpvv",
"registry+https://github.com/rust-lang/crates.io-index#hmac@0.12.1": "0pmbr069sfg76z7wsssfk5ddcqd9ncp79fyz6zcm6yn115yc6jbc",
"registry+https://github.com/rust-lang/crates.io-index#home@0.5.11": "1kxb4k87a9sayr8jipr7nq9wpgmjk4hk4047hmf9kc24692k75aq",
"registry+https://github.com/rust-lang/crates.io-index#http-body@0.4.6": "1lmyjfk6bqk6k9gkn1dxq770sb78pqbqshga241hr5p995bb5skw",
"registry+https://github.com/rust-lang/crates.io-index#http@0.2.12": "1w81s4bcbmcj9bjp7mllm8jlz6b31wzvirz8bgpzbqkpwmbvn730",
"registry+https://github.com/rust-lang/crates.io-index#http@1.2.0": "1skglzdf98j5nzxlii540n11is0w4l80mi5sm3xrj716asps4v7i",
"registry+https://github.com/rust-lang/crates.io-index#httparse@1.9.5": "0ip9v8m9lvgvq1lznl31wvn0ch1v254na7lhid9p29yx9rbx6wbx",
"registry+https://github.com/rust-lang/crates.io-index#httpdate@1.0.3": "1aa9rd2sac0zhjqh24c9xvir96g188zldkx0hr6dnnlx5904cfyz",
"registry+https://github.com/rust-lang/crates.io-index#humantime@2.1.0": "1r55pfkkf5v0ji1x6izrjwdq9v6sc7bv99xj6srywcar37xmnfls",
"registry+https://github.com/rust-lang/crates.io-index#hyper-tls@0.5.0": "01crgy13102iagakf6q4mb75dprzr7ps1gj0l5hxm1cvm7gks66n",
"registry+https://github.com/rust-lang/crates.io-index#hyper@0.10.16": "0wwjh9p3mzvg3fss2lqz5r7ddcgl1fh9w6my2j69d6k0lbcm41ha",
"registry+https://github.com/rust-lang/crates.io-index#hyper@0.14.32": "1rvcb0smz8q1i0y6p7rwxr02x5sclfg2hhxf3g0774zczn0cgps1",
"registry+https://github.com/rust-lang/crates.io-index#iana-time-zone-haiku@0.1.2": "17r6jmj31chn7xs9698r122mapq85mfnv98bb4pg6spm0si2f67k",
"registry+https://github.com/rust-lang/crates.io-index#iana-time-zone@0.1.61": "085jjsls330yj1fnwykfzmb2f10zp6l7w4fhq81ng81574ghhpi3",
"registry+https://github.com/rust-lang/crates.io-index#icu_collections@1.5.0": "09j5kskirl59mvqc8kabhy7005yyy7dp88jw9f6f3gkf419a8byv",
"registry+https://github.com/rust-lang/crates.io-index#icu_locid@1.5.0": "0dznvd1c5b02iilqm044q4hvar0sqibq1z46prqwjzwif61vpb0k",
"registry+https://github.com/rust-lang/crates.io-index#icu_locid_transform@1.5.0": "0kmmi1kmj9yph6mdgkc7v3wz6995v7ly3n80vbg0zr78bp1iml81",
"registry+https://github.com/rust-lang/crates.io-index#icu_locid_transform_data@1.5.0": "0vkgjixm0wzp2n3v5mw4j89ly05bg3lx96jpdggbwlpqi0rzzj7x",
"registry+https://github.com/rust-lang/crates.io-index#icu_normalizer@1.5.0": "0kx8qryp8ma8fw1vijbgbnf7zz9f2j4d14rw36fmjs7cl86kxkhr",
"registry+https://github.com/rust-lang/crates.io-index#icu_normalizer_data@1.5.0": "05lmk0zf0q7nzjnj5kbmsigj3qgr0rwicnn5pqi9n7krmbvzpjpq",
"registry+https://github.com/rust-lang/crates.io-index#icu_properties@1.5.1": "1xgf584rx10xc1p7zjr78k0n4zn3g23rrg6v2ln31ingcq3h5mlk",
"registry+https://github.com/rust-lang/crates.io-index#icu_properties_data@1.5.0": "0scms7pd5a7yxx9hfl167f5qdf44as6r3bd8myhlngnxqgxyza37",
"registry+https://github.com/rust-lang/crates.io-index#icu_provider@1.5.0": "1nb8vvgw8dv2inqklvk05fs0qxzkw8xrg2n9vgid6y7gm3423m3f",
"registry+https://github.com/rust-lang/crates.io-index#icu_provider_macros@1.5.0": "1mjs0w7fcm2lcqmbakhninzrjwqs485lkps4hz0cv3k36y9rxj0y",
"registry+https://github.com/rust-lang/crates.io-index#idna@0.1.5": "0kl4gs5kaydn4v07c6ka33spm9qdh2np0x7iw7g5zd8z1c7rxw1q",
"registry+https://github.com/rust-lang/crates.io-index#idna@1.0.3": "0zlajvm2k3wy0ay8plr07w22hxkkmrxkffa6ah57ac6nci984vv8",
"registry+https://github.com/rust-lang/crates.io-index#idna_adapter@1.2.0": "0wggnkiivaj5lw0g0384ql2d7zk4ppkn3b1ry4n0ncjpr7qivjns",
"registry+https://github.com/rust-lang/crates.io-index#image@0.23.14": "18gn2f7xp30pf9aqka877knlq308khxqiwjvsccvzaa4f9zcpzr4",
"registry+https://github.com/rust-lang/crates.io-index#image@0.24.9": "17gnr6ifnpzvhjf6dwbl9hki8x6bji5mwcqp0048x1jm5yfi742n",
"registry+https://github.com/rust-lang/crates.io-index#indent_write@2.2.0": "1hqjp80argdskrhd66g9sh542yxy8qi77j6rc69qd0l7l52rdzhc",
"registry+https://github.com/rust-lang/crates.io-index#indexmap@2.7.0": "07s7jmdymvd0rm4yswp0j3napx57hkjm9gs9n55lvs2g78vj5y32",
"registry+https://github.com/rust-lang/crates.io-index#intl-memoizer@0.5.2": "1nkvql7c7b76axv4g68di1p2m9bnxq1cbn6mlqcawf72zhhf08py",
"registry+https://github.com/rust-lang/crates.io-index#intl_pluralrules@7.0.2": "0wprd3h6h8nfj62d8xk71h178q7zfn3srxm787w4sawsqavsg3h7",
"registry+https://github.com/rust-lang/crates.io-index#ipnet@2.10.1": "025p9wm94q1w2l13hbbr4cbmfygly3a2ag8g5s618l2jhq4l3hnx",
"registry+https://github.com/rust-lang/crates.io-index#iron@0.6.1": "1s4mf8395f693nhwsr0znw3j5frzn56gzllypyl50il85p50ily6",
"registry+https://github.com/rust-lang/crates.io-index#is-terminal@0.4.13": "0jwgjjz33kkmnwai3nsdk1pz9vb6gkqvw1d1vq7bs3q48kinh7r6",
"registry+https://github.com/rust-lang/crates.io-index#is_terminal_polyfill@1.70.1": "1kwfgglh91z33kl0w5i338mfpa3zs0hidq5j4ny4rmjwrikchhvr",
"registry+https://github.com/rust-lang/crates.io-index#itertools@0.12.1": "0s95jbb3ndj1lvfxyq5wanc0fm0r6hg6q4ngb92qlfdxvci10ads",
"registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.14": "0x26kr9m062mafaxgcf2p6h2x7cmixm0zw95aipzn2hr3d5jlnnp",
"registry+https://github.com/rust-lang/crates.io-index#jpeg-decoder@0.1.22": "1wnh0bmmswpgwhgmlizz545x8334nlbmkq8imy9k224ri3am7792",
"registry+https://github.com/rust-lang/crates.io-index#jpeg-decoder@0.3.1": "1c1k53svpdyfhibkmm0ir5w0v3qmcmca8xr8vnnmizwf6pdagm7m",
"registry+https://github.com/rust-lang/crates.io-index#js-sys@0.3.77": "13x2qcky5l22z4xgivi59xhjjx4kxir1zg7gcj0f1ijzd4yg7yhw",
"registry+https://github.com/rust-lang/crates.io-index#kv-log-macro@1.0.7": "0zwp4bxkkp87rl7xy2dain77z977rvcry1gmr5bssdbn541v7s0d",
"registry+https://github.com/rust-lang/crates.io-index#language-tags@0.2.2": "16hrjdpa827carq5x4b8zhas24d8kg4s16m6nmmn1kb7cr5qh7d9",
"registry+https://github.com/rust-lang/crates.io-index#lazy_static@1.5.0": "1zk6dqqni0193xg6iijh7i3i44sryglwgvx20spdvwk3r6sbrlmv",
"registry+https://github.com/rust-lang/crates.io-index#lazycell@1.3.0": "0m8gw7dn30i0zjjpjdyf6pc16c34nl71lpv461mix50x3p70h3c3",
"registry+https://github.com/rust-lang/crates.io-index#lebe@0.5.2": "1j2l6chx19qpa5gqcw434j83gyskq3g2cnffrbl3842ymlmpq203",
"registry+https://github.com/rust-lang/crates.io-index#libadwaita-sys@0.5.3": "16n6xsy6jhbj0jbpz8yvql6c9b89a99v9vhdz5s37mg1inisl42y",
"registry+https://github.com/rust-lang/crates.io-index#libadwaita@0.5.3": "174pzn9dwsk8ikvrhx13vkh0zrpvb3rhg9yd2q5d2zjh0q6fgrrg",
"registry+https://github.com/rust-lang/crates.io-index#libc@0.2.169": "02m253hs8gw0m1n8iyrsc4n15yzbqwhddi7w1l0ds7i92kdsiaxm",
"registry+https://github.com/rust-lang/crates.io-index#libloading@0.8.6": "0d2ccr88f8kv3x7va2ccjxalcjnhrci4j2kwxp7lfmbkpjs4wbzw",
"registry+https://github.com/rust-lang/crates.io-index#libm@0.2.11": "1yjgk18rk71rjbqcw9l1zaqna89p9s603k7n327nqs8dn88vwmc3",
"registry+https://github.com/rust-lang/crates.io-index#libspa-sys@0.8.0": "07yh4i5grzbxkchg6dnxlwbdw2wm5jnd7ffbhl77jr0388b9f3dz",
"registry+https://github.com/rust-lang/crates.io-index#libspa@0.8.0": "044qs48yl0llp2dmrgwxj9y1pgfy09i6fhq661zqqb9a3fwa9wv5",
"registry+https://github.com/rust-lang/crates.io-index#libsqlite3-sys@0.30.1": "0jcikvgbj84xc7ikdmpc8m4y5lyqgrb9aqblphwk67kv95xgp69f",
"registry+https://github.com/rust-lang/crates.io-index#libyml@0.0.5": "106963pwg1gc3165bdlk8bbspmk919gk10vshhqglks3z8m700ik",
"registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.4.15": "1aq7r2g7786hyxhv40spzf2nhag5xbw2axxc1k8z5k1dsgdm4v6j",
"registry+https://github.com/rust-lang/crates.io-index#litemap@0.7.4": "012ili3vppd4952sh6y3qwcd0jkd0bq2qpr9h7cppc8sj11k7saf",
"registry+https://github.com/rust-lang/crates.io-index#lock_api@0.4.12": "05qvxa6g27yyva25a5ghsg85apdxkvr77yhkyhapj6r8vnf8pbq7",
"registry+https://github.com/rust-lang/crates.io-index#log@0.3.9": "0jq23hhn5h35k7pa8r7wqnsywji6x3wn1q5q7lif5q536if8v7p1",
"registry+https://github.com/rust-lang/crates.io-index#log@0.4.25": "17ydv5zhfv1zzygy458bmg3f3jx1vfziv9d74817w76yhfqgbjq4",
"registry+https://github.com/rust-lang/crates.io-index#logger@0.4.0": "14xlxvkspcfnspjil0xi63qj5cybxn1hjmr5gq8m4v1g9k5p54bc",
"registry+https://github.com/rust-lang/crates.io-index#matches@0.1.10": "1994402fq4viys7pjhzisj4wcw894l53g798kkm2y74laxk0jci5",
"registry+https://github.com/rust-lang/crates.io-index#md-5@0.10.6": "1kvq5rnpm4fzwmyv5nmnxygdhhb2369888a06gdc9pxyrzh7x7nq",
"registry+https://github.com/rust-lang/crates.io-index#memchr@2.7.4": "18z32bhxrax0fnjikv475z7ii718hq457qwmaryixfxsl2qrmjkq",
"registry+https://github.com/rust-lang/crates.io-index#memoffset@0.9.1": "12i17wh9a9plx869g7j4whf62xw68k5zd4k0k5nh6ys5mszid028",
"registry+https://github.com/rust-lang/crates.io-index#mime@0.2.6": "1q1s1ax1gaz8ld3513nvhidfwnik5asbs1ma3hp6inp5dn56nqms",
"registry+https://github.com/rust-lang/crates.io-index#mime@0.3.17": "16hkibgvb9klh0w0jk5crr5xv90l3wlf77ggymzjmvl1818vnxv8",
"registry+https://github.com/rust-lang/crates.io-index#mime_guess@1.8.8": "18qcd5aa3363mb742y7lf39j7ha88pkzbv9ff2qidlsdxsjjjs91",
"registry+https://github.com/rust-lang/crates.io-index#mime_guess@2.0.5": "03jmg3yx6j39mg0kayf7w4a886dl3j15y8zs119zw01ccy74zi7p",
"registry+https://github.com/rust-lang/crates.io-index#minimal-lexical@0.2.1": "16ppc5g84aijpri4jzv14rvcnslvlpphbszc7zzp6vfkddf4qdb8",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.3.7": "0dblrhgbm0wa8jjl8cjp81akaj36yna92df4z1h9b26n3spal7br",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.4.4": "0jsfv00hl5rmx1nijn59sr9jmjd4rjnjhh4kdjy8d187iklih9d9",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.8.3": "093r1kd1r9dyf05cbvsibgmh96pxp3qhzfvpd6f15bpggamjqh5q",
"registry+https://github.com/rust-lang/crates.io-index#mio@1.0.3": "1gah0h4ia3avxbwym0b6bi6lr6rpysmj9zvw6zis5yq0z0xq91i8",
"registry+https://github.com/rust-lang/crates.io-index#modifier@0.1.0": "0n3fmgli1nsskl0whrfzm1gk0rmwwl6pw1q4nb9sqqmn5h8wkxa1",
"registry+https://github.com/rust-lang/crates.io-index#multer@2.1.0": "1hjiphaypj3phqaj5igrzcia9xfmf4rr4ddigbh8zzb96k1bvb01",
"registry+https://github.com/rust-lang/crates.io-index#nary_tree@0.4.3": "1iqray1a716995l9mmvz5sfqrwg9a235bvrkpcn8bcqwjnwfv1pv",
"registry+https://github.com/rust-lang/crates.io-index#native-tls@0.2.12": "0rkl65z70n7sy4d5w0qa99klg1hr43wx6kcprk4d2n9xr2r4wqd8",
"registry+https://github.com/rust-lang/crates.io-index#nix@0.27.1": "0ly0kkmij5f0sqz35lx9czlbk6zpihb7yh1bsy4irzwfd2f4xc1f",
"registry+https://github.com/rust-lang/crates.io-index#no-std-compat@0.4.1": "132vrf710zsdp40yp1z3kgc2ss8pi0z4gmihsz3y7hl4dpd56f5r",
"registry+https://github.com/rust-lang/crates.io-index#nom@7.1.3": "0jha9901wxam390jcf5pfa0qqfrgh8li787jx2ip0yk5b8y9hwyj",
"registry+https://github.com/rust-lang/crates.io-index#num-bigint-dig@0.8.4": "0lb12df24wgxxbspz4gw1sf1kdqwvpdcpwq4fdlwg4gj41c1k16w",
"registry+https://github.com/rust-lang/crates.io-index#num-conv@0.1.0": "1ndiyg82q73783jq18isi71a7mjh56wxrk52rlvyx0mi5z9ibmai",
"registry+https://github.com/rust-lang/crates.io-index#num-integer@0.1.46": "13w5g54a9184cqlbsq80rnxw4jj4s0d8wv75jsq5r2lms8gncsbr",
"registry+https://github.com/rust-lang/crates.io-index#num-iter@0.1.45": "1gzm7vc5g9qsjjl3bqk9rz1h6raxhygbrcpbfl04swlh0i506a8l",
"registry+https://github.com/rust-lang/crates.io-index#num-rational@0.3.2": "01sgiwny9iflyxh2xz02sak71v2isc3x608hfdpwwzxi3j5l5b0j",
"registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19": "0h984rhdkkqd4ny9cif7y2azl3xdfb7768hb9irhpsch4q3gq787",
"registry+https://github.com/rust-lang/crates.io-index#num_cpus@1.16.0": "0hra6ihpnh06dvfvz9ipscys0xfqa9ca9hzp384d5m02ssvgqqa1",
"registry+https://github.com/rust-lang/crates.io-index#object@0.36.7": "11vv97djn9nc5n6w1gc6bd96d2qk2c8cg1kw5km9bsi3v4a8x532",
"registry+https://github.com/rust-lang/crates.io-index#once_cell@1.20.2": "0xb7rw1aqr7pa4z3b00y7786gyf8awx2gca3md73afy76dzgwq8j",
"registry+https://github.com/rust-lang/crates.io-index#openssl-macros@0.1.1": "173xxvfc63rr5ybwqwylsir0vq6xsj4kxiv4hmg4c3vscdmncj59",
"registry+https://github.com/rust-lang/crates.io-index#openssl-probe@0.1.5": "1kq18qm48rvkwgcggfkqq6pm948190czqc94d6bm2sir5hq1l0gz",
"registry+https://github.com/rust-lang/crates.io-index#openssl-sys@0.9.104": "0hf712xcxmycnlc09r8d446b3mwqchsbfrjv374fp7grrc3g7as5",
"registry+https://github.com/rust-lang/crates.io-index#openssl@0.10.68": "1xbiz2bmba2fibg70s462yk2fndp3f9vz11c7iw0ilh2y54bqx31",
"registry+https://github.com/rust-lang/crates.io-index#pango-sys@0.18.0": "1iaxalcaaj59cl9n10svh4g50v8jrc1a36kd7n9yahx8j7ikfrs3",
"registry+https://github.com/rust-lang/crates.io-index#pango@0.18.3": "1r5ygq7036sv7w32kp8yxr6vgggd54iaavh3yckanmq4xg0px8kw",
"registry+https://github.com/rust-lang/crates.io-index#parking@2.2.1": "1fnfgmzkfpjd69v4j9x737b1k8pnn054bvzcn5dm3pkgq595d3gk",
"registry+https://github.com/rust-lang/crates.io-index#parking_lot@0.12.3": "09ws9g6245iiq8z975h8ycf818a66q3c6zv4b5h8skpm7hc1igzi",
"registry+https://github.com/rust-lang/crates.io-index#parking_lot_core@0.9.10": "1y3cf9ld9ijf7i4igwzffcn0xl16dxyn4c5bwgjck1dkgabiyh0y",
"registry+https://github.com/rust-lang/crates.io-index#parse-zoneinfo@0.3.1": "093cs8slbd6kyfi6h12isz0mnaayf5ha8szri1xrbqj4inqhaahz",
"registry+https://github.com/rust-lang/crates.io-index#pem-rfc7468@0.7.0": "04l4852scl4zdva31c1z6jafbak0ni5pi0j38ml108zwzjdrrcw8",
"registry+https://github.com/rust-lang/crates.io-index#percent-encoding@1.0.1": "0cgq08v1fvr6bs5fvy390cz830lq4fak8havdasdacxcw790s09i",
"registry+https://github.com/rust-lang/crates.io-index#percent-encoding@2.3.1": "0gi8wgx0dcy8rnv1kywdv98lwcx67hz0a0zwpib5v2i08r88y573",
"registry+https://github.com/rust-lang/crates.io-index#phf@0.11.3": "0y6hxp1d48rx2434wgi5g8j1pr8s5jja29ha2b65435fh057imhz",
"registry+https://github.com/rust-lang/crates.io-index#phf@0.7.24": "066xwv4dr6056a9adlkarwp4n94kbpwngbmd47ngm3cfbyw49nmk",
"registry+https://github.com/rust-lang/crates.io-index#phf_codegen@0.11.3": "0si1n6zr93kzjs3wah04ikw8z6npsr39jw4dam8yi9czg2609y5f",
"registry+https://github.com/rust-lang/crates.io-index#phf_codegen@0.7.24": "0zjiblicfm0nrmr2xxrs6pnf6zz2394wgch6dcbd8jijkq98agmh",
"registry+https://github.com/rust-lang/crates.io-index#phf_generator@0.11.3": "0gc4np7s91ynrgw73s2i7iakhb4lzdv1gcyx7yhlc0n214a2701w",
"registry+https://github.com/rust-lang/crates.io-index#phf_generator@0.7.24": "0qi62gxk3x3whrmw5c4i71406icqk11qmpgln438p6qm7k4lqdh9",
"registry+https://github.com/rust-lang/crates.io-index#phf_shared@0.11.3": "1rallyvh28jqd9i916gk5gk2igdmzlgvv5q0l3xbf3m6y8pbrsk7",
"registry+https://github.com/rust-lang/crates.io-index#phf_shared@0.7.24": "18371fla0vsj7d6d5rlfb747xbr2in11ar9vgv5qna72bnhp2kr3",
"registry+https://github.com/rust-lang/crates.io-index#pin-project-internal@1.1.8": "1yzfhf6l27nhzv7r5hfrwj2g0x7xmfhgil19fj9am4srqp06csnm",
"registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.16": "16wzc7z7dfkf9bmjin22f5282783f6mdksnr0nv0j5ym5f9gyg1v",
"registry+https://github.com/rust-lang/crates.io-index#pin-project@1.1.8": "05jr3xfy1spgmz3q19l4mmvv46vgvkvsgphamifx7x45swxcabhy",
"registry+https://github.com/rust-lang/crates.io-index#pin-utils@0.1.0": "117ir7vslsl2z1a7qzhws4pd01cg2d3338c47swjyvqv2n60v1wb",
"registry+https://github.com/rust-lang/crates.io-index#piper@0.2.4": "0rn0mjjm0cwagdkay77wgmz3sqf8fqmv9d9czm79mvr2yj8c9j4n",
"registry+https://github.com/rust-lang/crates.io-index#pipewire-sys@0.8.0": "04hiy3rl8v3j2dfzp04gr7r8l5azzqqsvqdzwa7sipdij27ii7l4",
"registry+https://github.com/rust-lang/crates.io-index#pipewire@0.8.0": "1nldg1hz4v0qr26lzdxqpvrac4zbc3pb6436sl392425bjx4brh8",
"registry+https://github.com/rust-lang/crates.io-index#pkcs1@0.7.5": "0zz4mil3nchnxljdfs2k5ab1cjqn7kq5lqp62n9qfix01zqvkzy8",
"registry+https://github.com/rust-lang/crates.io-index#pkcs8@0.10.2": "1dx7w21gvn07azszgqd3ryjhyphsrjrmq5mmz1fbxkj5g0vv4l7r",
"registry+https://github.com/rust-lang/crates.io-index#pkg-config@0.3.31": "1wk6yp2phl91795ia0lwkr3wl4a9xkrympvhqq8cxk4d75hwhglm",
"registry+https://github.com/rust-lang/crates.io-index#plugin@0.2.6": "1q7nghkpvxxr168y2jnzh3w7qc9vfrby9n7ygy3xpj0bj71hsshs",
"registry+https://github.com/rust-lang/crates.io-index#png@0.16.8": "1ipl44q3vy4kvx6j296vk7d4v8gvcg203lrkvvixwixq1j98fciw",
"registry+https://github.com/rust-lang/crates.io-index#png@0.17.16": "09kmkms9fmkbkarw0lnf0scqvjwwg3r7riddag0i3q39r0pil5c2",
"registry+https://github.com/rust-lang/crates.io-index#polling@3.7.4": "0bs4nhwfwsvlzlhah2gbhj3aa9ynvchv2g350wapswh26a65c156",
"registry+https://github.com/rust-lang/crates.io-index#powerfmt@0.2.0": "14ckj2xdpkhv3h6l5sdmb9f1d57z8hbfpdldjc2vl5givq2y77j3",
"registry+https://github.com/rust-lang/crates.io-index#ppv-lite86@0.2.20": "017ax9ssdnpww7nrl1hvqh2lzncpv04nnsibmnw9nxjnaqlpp5bp",
"registry+https://github.com/rust-lang/crates.io-index#pretty_env_logger@0.5.0": "076w9dnvcpx6d3mdbkqad8nwnsynb7c8haxmscyrz7g3vga28mw6",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-crate@1.3.1": "069r1k56bvgk0f58dm5swlssfcp79im230affwk6d9ck20g04k3z",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-crate@2.0.0": "1s23imns07vmacn2xjd5hv2h6rr94iqq3fd2frwa6i4h2nk6d0vy",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-error-attr@1.0.4": "0sgq6m5jfmasmwwy8x4mjygx5l7kp8s4j60bv25ckv2j1qc41gm1",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-error@1.0.4": "1373bhxaf0pagd8zkyd03kkx6bchzf6g0dkwrwzsnal9z47lj9fs",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.93": "169dw9wch753if1mgyi2nfl1il77gslvh6y2q46qplprwml6m530",
"registry+https://github.com/rust-lang/crates.io-index#proptest@1.6.0": "0l4y4bb8hffv7cys7d59qwqdmvmqjfzz0x9vblc08209clqfkjhl",
"registry+https://github.com/rust-lang/crates.io-index#qoi@0.4.1": "00c0wkb112annn2wl72ixyd78mf56p4lxkhlmsggx65l3v3n8vbz",
"registry+https://github.com/rust-lang/crates.io-index#quick-error@1.2.3": "1q6za3v78hsspisc197bg3g7rpc989qycy8ypr8ap8igv10ikl51",
"registry+https://github.com/rust-lang/crates.io-index#quote@1.0.38": "1k0s75w61k6ch0rs263r4j69b7vj1wadqgb9dia4ylc9mymcqk8f",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.3.23": "0v679h38pjjqj5h4md7v2slsvj6686qgcn7p9fbw3h43iwnk1b34",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.4.6": "14qjfv3gggzhnma20k0sc1jf8y6pplsaq7n1j9ls5c8kf2wl0a2m",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.6.5": "1jl4449jcl4wgmzld6ffwqj5gwxrp8zvx8w573g1z368qg6xlwbd",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.8.5": "013l6931nn7gkc23jz5mm3qdhf93jjf0fg64nz2lp4i51qd8vbrl",
"registry+https://github.com/rust-lang/crates.io-index#rand_chacha@0.1.1": "1vxwyzs4fy1ffjc8l00fsyygpiss135irjf7nyxgq2v0lqf3lvam",
"registry+https://github.com/rust-lang/crates.io-index#rand_chacha@0.3.1": "123x2adin558xbhvqb8w4f6syjsdkmqff8cxwhmjacpsl1ihmhg6",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.3.1": "0jzdgszfa4bliigiy4hi66k7fs3gfwi2qxn8vik84ph77fwdwvvs",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.4.2": "1p09ynysrq1vcdlmcqnapq4qakl2yd1ng3kxh3qscpx09k2a6cww",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.6.4": "0b4j2v4cb5krak1pv6kakv4sz6xcwbrmy2zckc32hsigbrwy82zc",
"registry+https://github.com/rust-lang/crates.io-index#rand_hc@0.1.0": "1i0vl8q5ddvvy0x8hf1zxny393miyzxkwqnw31ifg6p0gdy6fh3v",
"registry+https://github.com/rust-lang/crates.io-index#rand_isaac@0.1.1": "027flpjr4znx2csxk7gxb7vrf9c7y5mydmvg5az2afgisp4rgnfy",
"registry+https://github.com/rust-lang/crates.io-index#rand_jitter@0.1.4": "16z387y46bfz3csc42zxbjq89vcr1axqacncvv8qhyy93p4xarhi",
"registry+https://github.com/rust-lang/crates.io-index#rand_os@0.1.3": "0wahppm0s64gkr2vmhcgwc0lij37in1lgfxg5rbgqlz0l5vgcxbv",
"registry+https://github.com/rust-lang/crates.io-index#rand_pcg@0.1.2": "0i0bdla18a8x4jn1w0fxsbs3jg7ajllz6azmch1zw33r06dv1ydb",
"registry+https://github.com/rust-lang/crates.io-index#rand_xorshift@0.1.1": "0p2x8nr00hricpi2m6ca5vysiha7ybnghz79yqhhx6sl4gkfkxyb",
"registry+https://github.com/rust-lang/crates.io-index#rand_xorshift@0.3.0": "13vcag7gmqspzyabfl1gr9ykvxd2142q2agrj8dkyjmfqmgg4nyj",
"registry+https://github.com/rust-lang/crates.io-index#rayon-core@1.12.1": "1qpwim68ai5h0j7axa8ai8z0payaawv3id0lrgkqmapx7lx8fr8l",
"registry+https://github.com/rust-lang/crates.io-index#rayon@1.10.0": "1ylgnzwgllajalr4v00y4kj22klq2jbwllm70aha232iah0sc65l",
"registry+https://github.com/rust-lang/crates.io-index#rdrand@0.4.0": "1cjq0kwx1bk7jx3kzyciiish5gqsj7620dm43dc52sr8fzmm9037",
"registry+https://github.com/rust-lang/crates.io-index#redox_syscall@0.5.8": "0d48ylyd6gsamynyp257p6n2zl4dw2fhnn5z9y3nhgpri6rn5a03",
"registry+https://github.com/rust-lang/crates.io-index#regex-automata@0.4.9": "02092l8zfh3vkmk47yjc8d631zhhcd49ck2zr133prvd3z38v7l0",
"registry+https://github.com/rust-lang/crates.io-index#regex-syntax@0.8.5": "0p41p3hj9ww7blnbwbj9h7rwxzxg0c1hvrdycgys8rxyhqqw859b",
"registry+https://github.com/rust-lang/crates.io-index#regex@1.11.1": "148i41mzbx8bmq32hsj1q4karkzzx5m60qza6gdw4pdc9qdyyi5m",
"registry+https://github.com/rust-lang/crates.io-index#remove_dir_all@0.5.3": "1rzqbsgkmr053bxxl04vmvsd1njyz0nxvly97aip6aa2cmb15k9s",
"registry+https://github.com/rust-lang/crates.io-index#reqwest@0.11.27": "0qjary4hpplpgdi62d2m0xvbn6lnzckwffm0rgkm2x51023m6ryx",
"registry+https://github.com/rust-lang/crates.io-index#rsa@0.9.7": "06amqm85raq26v6zg00fbf93lbj3kx559n2lpxc3wrvbbiy5vis7",
"registry+https://github.com/rust-lang/crates.io-index#rustc-demangle@0.1.24": "07zysaafgrkzy2rjgwqdj2a8qdpsm6zv6f5pgpk9x0lm40z9b6vi",
"registry+https://github.com/rust-lang/crates.io-index#rustc-hash@1.1.0": "1qkc5khrmv5pqi5l5ca9p5nl5hs742cagrndhbrlk3dhlrx3zm08",
"registry+https://github.com/rust-lang/crates.io-index#rustc_version@0.4.1": "14lvdsmr5si5qbqzrajgb6vfn69k0sfygrvfvr2mps26xwi3mjyg",
"registry+https://github.com/rust-lang/crates.io-index#rustix@0.38.43": "1xjfhdnmqsbwnfmm77vyh7ldhqx0g9waqm4982404d7jdgp93257",
"registry+https://github.com/rust-lang/crates.io-index#rustls-pemfile@1.0.4": "1324n5bcns0rnw6vywr5agff3rwfvzphi7rmbyzwnv6glkhclx0w",
"registry+https://github.com/rust-lang/crates.io-index#rustversion@1.0.19": "1m39qd65jcd1xgqzdm3017ppimiggh2446xngwp1ngr8hjbmpi7p",
"registry+https://github.com/rust-lang/crates.io-index#rusty-fork@0.3.0": "0kxwq5c480gg6q0j3bg4zzyfh2kwmc3v2ba94jw8ncjc8mpcqgfb",
"registry+https://github.com/rust-lang/crates.io-index#ryu@1.0.18": "17xx2s8j1lln7iackzd9p0sv546vjq71i779gphjq923vjh5pjzk",
"registry+https://github.com/rust-lang/crates.io-index#safemem@0.3.3": "0wp0d2b2284lw11xhybhaszsczpbq1jbdklkxgifldcknmy3nw7g",
"registry+https://github.com/rust-lang/crates.io-index#schannel@0.1.27": "0gbbhy28v72kd5iina0z2vcdl3vz63mk5idvkzn5r52z6jmfna8z",
"registry+https://github.com/rust-lang/crates.io-index#scoped-tls@1.0.1": "15524h04mafihcvfpgxd8f4bgc3k95aclz8grjkg9a0rxcvn9kz1",
"registry+https://github.com/rust-lang/crates.io-index#scoped_threadpool@0.1.9": "1a26d3lk40s9mrf4imhbik7caahmw2jryhhb6vqv6fplbbgzal8x",
"registry+https://github.com/rust-lang/crates.io-index#scopeguard@1.2.0": "0jcz9sd47zlsgcnm1hdw0664krxwb5gczlif4qngj2aif8vky54l",
"registry+https://github.com/rust-lang/crates.io-index#security-framework-sys@2.14.0": "0chwn01qrnvs59i5220bymd38iddy4krbnmfnhf4k451aqfj7ns9",
"registry+https://github.com/rust-lang/crates.io-index#security-framework@2.11.1": "00ldclwx78dm61v7wkach9lcx76awlrv0fdgjdwch4dmy12j4yw9",
"registry+https://github.com/rust-lang/crates.io-index#self_cell@0.10.3": "0pci3zh23b7dg6jmlxbn8k4plb7hcg5jprd1qiz0rp04p1ilskp1",
"registry+https://github.com/rust-lang/crates.io-index#self_cell@1.1.0": "1gmxk5bvnnimcif7v1jk8ai2azfvh9djki545nd86vsnphjgrzf2",
"registry+https://github.com/rust-lang/crates.io-index#semver@1.0.24": "1fmvjjkd3f64y5fqr1nakkq371mnwzv09fbz5mbmdxril63ypdiw",
"registry+https://github.com/rust-lang/crates.io-index#serde@0.9.15": "1bsla8l5xr9pp5sirkal6mngxcq6q961km88jvf339j5ff8j7dil",
"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.218": "0q6z4bnrwagnms0bds4886711l6mc68s979i49zd3xnvkg8wkpz8",
"registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.218": "0azqd74xbpb1v5vf6w1fdbgmwp39ljjfj25cib5rgrzlj7hh75gh",
"registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.139": "19kj3irpa22a7djz1jaf4wambzh7psiqa6zyafqnb76crhx6ry24",
"registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.8": "1q89g70azwi4ybilz5jb8prfpa575165lmrffd49vmcf76qpqq47",
"registry+https://github.com/rust-lang/crates.io-index#serde_urlencoded@0.7.1": "1zgklbdaysj3230xivihs30qi5vkhigg323a9m62k8jwf4a1qjfk",
"registry+https://github.com/rust-lang/crates.io-index#serde_yml@0.0.12": "1p8xwz4znd6fj962y22fdvvv16gb8c0hx4iv5hjplngiidcdvqjr",
"registry+https://github.com/rust-lang/crates.io-index#sha1@0.10.6": "1fnnxlfg08xhkmwf2ahv634as30l1i3xhlhkvxflmasi5nd85gz3",
"registry+https://github.com/rust-lang/crates.io-index#sha2@0.10.8": "1j1x78zk9il95w9iv46dh9wm73r6xrgj32y6lzzw7bxws9dbfgbr",
"registry+https://github.com/rust-lang/crates.io-index#shlex@1.3.0": "0r1y6bv26c1scpxvhg2cabimrmwgbp4p3wy6syj9n0c4s3q2znhg",
"registry+https://github.com/rust-lang/crates.io-index#signal-hook-registry@1.4.2": "1cb5akgq8ajnd5spyn587srvs4n26ryq0p78nswffwhv46sf1sd9",
"registry+https://github.com/rust-lang/crates.io-index#signature@2.2.0": "1pi9hd5vqfr3q3k49k37z06p7gs5si0in32qia4mmr1dancr6m3p",
"registry+https://github.com/rust-lang/crates.io-index#simd-adler32@0.3.7": "1zkq40c3iajcnr5936gjp9jjh1lpzhy44p3dq3fiw75iwr1w2vfn",
"registry+https://github.com/rust-lang/crates.io-index#siphasher@0.2.3": "1b53m53l24lyhr505lwqzrpjyq5qfnic71mynrcfvm43rybf938b",
"registry+https://github.com/rust-lang/crates.io-index#siphasher@1.0.1": "17f35782ma3fn6sh21c027kjmd227xyrx06ffi8gw4xzv9yry6an",
"registry+https://github.com/rust-lang/crates.io-index#slab@0.4.9": "0rxvsgir0qw5lkycrqgb1cxsvxzjv9bmx73bk5y42svnzfba94lg",
"registry+https://github.com/rust-lang/crates.io-index#smallvec@1.13.2": "0rsw5samawl3wsw6glrsb127rx6sh89a8wyikicw6dkdcjd1lpiw",
"registry+https://github.com/rust-lang/crates.io-index#snowflake@1.3.0": "1wadr7bxdxbmkbqkqsvzan6q1h3mxqpxningi3ss3v9jaav7n817",
"registry+https://github.com/rust-lang/crates.io-index#socket2@0.5.8": "1s7vjmb5gzp3iaqi94rh9r63k9cj00kjgbfn7gn60kmnk6fjcw69",
"registry+https://github.com/rust-lang/crates.io-index#spin@0.9.8": "0rvam5r0p3a6qhc18scqpvpgb3ckzyqxpgdfyjnghh8ja7byi039",
"registry+https://github.com/rust-lang/crates.io-index#spki@0.7.3": "17fj8k5fmx4w9mp27l970clrh5qa7r5sjdvbsln987xhb34dc7nr",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-core@0.8.3": "1q31dawr61wc6q2f12my4fw082mbv8sxwz1082msjsk76rlpn03a",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-macros-core@0.8.3": "1bg7sn6l8dc4pzrqx2dwc3sp7dbn97msfqahpycnl55bqnn917sf",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-macros@0.8.3": "047k67sylscv0gdhwwqrn0s33jy1mvq8rmqq6s8fygv4g2ny44ii",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-mysql@0.8.3": "0czjzzjm2y6lkhxvvzrzwgp0pmlhymcnym20hn9n9kh01s7jfq25",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-postgres@0.8.3": "04wnjl51kfx0qbfsfmhqdshpmw32vzz2p8dksmj6gvb3ydbqmff5",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-sqlite@0.8.3": "0h05ca26g428h4337k4nm0ww75bcdkiqzp883m7fc92v78fsfp7q",
"registry+https://github.com/rust-lang/crates.io-index#sqlx@0.8.3": "0pvlpq0plgyxf5kikcv786pf0pjv8dx5shlvz72l510d7hxyf424",
"registry+https://github.com/rust-lang/crates.io-index#stable_deref_trait@1.2.0": "1lxjr8q2n534b2lhkxd6l6wcddzjvnksi58zv11f9y0jjmr15wd8",
"registry+https://github.com/rust-lang/crates.io-index#stringprep@0.1.5": "1cb3jis4h2b767csk272zw92lc6jzfzvh8d6m1cd86yqjb9z6kbv",
"registry+https://github.com/rust-lang/crates.io-index#strsim@0.11.1": "0kzvqlw8hxqb7y598w1s0hxlnmi84sg5vsipp3yg5na5d1rvba3x",
"registry+https://github.com/rust-lang/crates.io-index#subtle@2.6.1": "14ijxaymghbl1p0wql9cib5zlwiina7kall6w7g89csprkgbvhhk",
"registry+https://github.com/rust-lang/crates.io-index#syn@1.0.109": "0ds2if4600bd59wsv7jjgfkayfzy3hnazs394kz6zdkmna8l3dkj",
"registry+https://github.com/rust-lang/crates.io-index#syn@2.0.96": "102wk3cgawimi3i0q3r3xw3i858zkyingg6y7gsxfy733amsvl6m",
"registry+https://github.com/rust-lang/crates.io-index#sync_wrapper@0.1.2": "0q01lyj0gr9a93n10nxsn8lwbzq97jqd6b768x17c8f7v7gccir0",
"registry+https://github.com/rust-lang/crates.io-index#synstructure@0.13.1": "0wc9f002ia2zqcbj0q2id5x6n7g1zjqba7qkg2mr0qvvmdk7dby8",
"registry+https://github.com/rust-lang/crates.io-index#system-configuration-sys@0.5.0": "1jckxvdr37bay3i9v52izgy52dg690x5xfg3hd394sv2xf4b2px7",
"registry+https://github.com/rust-lang/crates.io-index#system-configuration@0.5.1": "1rz0r30xn7fiyqay2dvzfy56cvaa3km74hnbz2d72p97bkf3lfms",
"registry+https://github.com/rust-lang/crates.io-index#system-deps@6.2.2": "0j93ryw031n3h8b0nfpj5xwh3ify636xmv8kxianvlyyipmkbrd3",
"registry+https://github.com/rust-lang/crates.io-index#target-lexicon@0.12.16": "1cg3bnx1gdkdr5hac1hzxy64fhw4g7dqkd0n3dxy5lfngpr1mi31",
"registry+https://github.com/rust-lang/crates.io-index#tempdir@0.3.7": "1n5n86zxpgd85y0mswrp5cfdisizq2rv3la906g6ipyc03xvbwhm",
"registry+https://github.com/rust-lang/crates.io-index#tempfile@3.17.1": "0c52ggq5vy5mzgk5ly36cgzs1cig3cv6r1jarijmzxgkn6na1r92",
"registry+https://github.com/rust-lang/crates.io-index#termcolor@1.4.1": "0mappjh3fj3p2nmrg4y7qv94rchwi9mzmgmfflr8p2awdj7lyy86",
"registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@1.0.69": "1h84fmn2nai41cxbhk6pqf46bxqq1b344v8yz089w1chzi76rvjg",
"registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@2.0.11": "1hkkn7p2y4cxbffcrprybkj0qy1rl1r6waxmxqvr764axaxc3br6",
"registry+https://github.com/rust-lang/crates.io-index#thiserror@1.0.69": "0lizjay08agcr5hs9yfzzj6axs53a2rgx070a1dsi3jpkcrzbamn",
"registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.11": "1z0649rpa8c2smzx129bz4qvxmdihj30r2km6vfpcv9yny2g4lnl",
"registry+https://github.com/rust-lang/crates.io-index#tiff@0.6.1": "0ds48vs919ccxa3fv1www7788pzkvpg434ilqkq7sjb5dmqg8lws",
"registry+https://github.com/rust-lang/crates.io-index#tiff@0.9.1": "0ghyxlz566dzc3scvgmzys11dhq2ri77kb8sznjakijlxby104xs",
"registry+https://github.com/rust-lang/crates.io-index#time-core@0.1.2": "1wx3qizcihw6z151hywfzzyd1y5dl804ydyxci6qm07vbakpr4pg",
"registry+https://github.com/rust-lang/crates.io-index#time-macros@0.2.19": "1pl558z26pp342l5y91n6dxb60xwhar975wk6jc4npiygq0ycd18",
"registry+https://github.com/rust-lang/crates.io-index#time@0.1.45": "0nl0pzv9yf56djy8y5dx25nka5pr2q1ivlandb3d24pksgx7ly8v",
"registry+https://github.com/rust-lang/crates.io-index#time@0.3.37": "08bvydyc14plkwhchzia5bcdbmm0mk5fzilsdpjx06w6hf48drrm",
"registry+https://github.com/rust-lang/crates.io-index#tinystr@0.7.6": "0bxqaw7z8r2kzngxlzlgvld1r6jbnwyylyvyjbv1q71rvgaga5wi",
"registry+https://github.com/rust-lang/crates.io-index#tinyvec@1.8.1": "1s41rv7n39sjsxz3kd3d4adw45ndkxz1d18rfbz2wd7s9n8bhb82",
"registry+https://github.com/rust-lang/crates.io-index#tinyvec_macros@0.1.1": "081gag86208sc3y6sdkshgw3vysm5d34p431dzw0bshz66ncng0z",
"registry+https://github.com/rust-lang/crates.io-index#tokio-macros@2.5.0": "1f6az2xbvqp7am417b78d1za8axbvjvxnmkakz9vr8s52czx81kf",
"registry+https://github.com/rust-lang/crates.io-index#tokio-native-tls@0.3.1": "1wkfg6zn85zckmv4im7mv20ca6b1vmlib5xwz9p7g19wjfmpdbmv",
"registry+https://github.com/rust-lang/crates.io-index#tokio-stream@0.1.17": "0ix0770hfp4x5rh5bl7vsnr3d4iz4ms43i522xw70xaap9xqv9gc",
"registry+https://github.com/rust-lang/crates.io-index#tokio-tungstenite@0.21.0": "0f5wj0crsx74rlll97lhw0wk6y12nhdnqvmnjx002hjn08fmcfy8",
"registry+https://github.com/rust-lang/crates.io-index#tokio-util@0.7.13": "0y0h10a52c7hrldmr3410bp7j3fadq0jn9nf7awddgd2an6smz6p",
"registry+https://github.com/rust-lang/crates.io-index#tokio@1.43.0": "17pdm49ihlhfw3rpxix3kdh2ppl1yv7nwp1kxazi5r1xz97zlq9x",
"registry+https://github.com/rust-lang/crates.io-index#toml@0.8.20": "0j012b37iz1mihksr6a928s6dzszxvblzg3l5wxp7azzsv6sb1yd",
"registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.8": "0hgv7v9g35d7y9r2afic58jvlwnf73vgd1mz2k8gihlgrf73bmqd",
"registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.19.15": "08bl7rp5g6jwmfpad9s8jpw8wjrciadpnbaswgywpr9hv9qbfnqv",
"registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.20.2": "0f7k5svmxw98fhi28jpcyv7ldr2s3c867pjbji65bdxjpd44svir",
"registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.22.24": "0x0lgp70x5cl9nla03xqs5vwwwlrwmd0djkdrp3h3lpdymgpkd0p",
"registry+https://github.com/rust-lang/crates.io-index#tower-service@0.3.3": "1hzfkvkci33ra94xjx64vv3pp0sq346w06fpkcdwjcid7zhvdycd",
"registry+https://github.com/rust-lang/crates.io-index#tracing-attributes@0.1.28": "0v92l9cxs42rdm4m5hsa8z7ln1xsiw1zc2iil8c6k7lzq0jf2nir",
"registry+https://github.com/rust-lang/crates.io-index#tracing-core@0.1.33": "170gc7cxyjx824r9kr17zc9gvzx89ypqfdzq259pr56gg5bwjwp6",
"registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.41": "1l5xrzyjfyayrwhvhldfnwdyligi1mpqm8mzbi2m1d6y6p2hlkkq",
"registry+https://github.com/rust-lang/crates.io-index#traitobject@0.1.0": "0yb0n8822mr59j200fyr2fxgzzgqljyxflx9y8bdy3rlaqngilgg",
"registry+https://github.com/rust-lang/crates.io-index#try-lock@0.2.5": "0jqijrrvm1pyq34zn1jmy2vihd4jcrjlvsh4alkjahhssjnsn8g4",
"registry+https://github.com/rust-lang/crates.io-index#tungstenite@0.21.0": "1qaphb5kgwgid19p64grhv2b9kxy7f1059yy92l9kwrlx90sdwcy",
"registry+https://github.com/rust-lang/crates.io-index#type-map@0.5.0": "17qaga12nkankr7hi2mv43f4lnc78hg480kz6j9zmy4g0h28ddny",
"registry+https://github.com/rust-lang/crates.io-index#typeable@0.1.2": "11w8dywgnm32hb291izjvh4zjd037ccnkk77ahk63l913zwzc40l",
"registry+https://github.com/rust-lang/crates.io-index#typemap@0.3.3": "1xm1gbvz9qisj1l6d36hrl9pw8imr8ngs6qyanjnsad3h0yfcfv5",
"registry+https://github.com/rust-lang/crates.io-index#typenum@1.17.0": "09dqxv69m9lj9zvv6xw5vxaqx15ps0vxyy5myg33i0kbqvq0pzs2",
"registry+https://github.com/rust-lang/crates.io-index#typeshare-annotation@1.0.4": "0kx38ah6638pkqq5cac7nmvbg6x43v7fj5jgibla4lj8fv1dc5d6",
"registry+https://github.com/rust-lang/crates.io-index#typeshare@1.0.4": "1svc92lg35r12mqdpbs4wbkw7g72v2302niyw5v1w290250hzghr",
"registry+https://github.com/rust-lang/crates.io-index#unarray@0.1.4": "154smf048k84prsdgh09nkm2n0w0336v84jd4zikyn6v6jrqbspa",
"registry+https://github.com/rust-lang/crates.io-index#unic-langid-impl@0.9.5": "1rckyn5wqd5h8jxhbzlbbagr459zkzg822r4k5n30jaryv0j4m0a",
"registry+https://github.com/rust-lang/crates.io-index#unic-langid@0.9.5": "0i2s024frmpfa68lzy8y8vnb1rz3m9v0ga13f7h2afx7f8g9vp93",
"registry+https://github.com/rust-lang/crates.io-index#unicase@1.4.2": "0cwazh4qsmm9msckjk86zc1z35xg7hjxjykrgjalzdv367w6aivz",
"registry+https://github.com/rust-lang/crates.io-index#unicase@2.8.1": "0fd5ddbhpva7wrln2iah054ar2pc1drqjcll0f493vj3fv8l9f3m",
"registry+https://github.com/rust-lang/crates.io-index#unicode-bidi@0.3.18": "1xcxwbsqa24b8vfchhzyyzgj0l6bn51ib5v8j6krha0m77dva72w",
"registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.14": "10ywa1pg0glgkr4l3dppjxizr9r2b7im0ycbfa0137l69z5fdfdd",
"registry+https://github.com/rust-lang/crates.io-index#unicode-normalization@0.1.24": "0mnrk809z3ix1wspcqy97ld5wxdb31f3xz6nsvg5qcv289ycjcsh",
"registry+https://github.com/rust-lang/crates.io-index#unicode-properties@0.1.3": "1l3mbgzwz8g14xcs09p4ww3hjkjcf0i1ih13nsg72bhj8n5jl3z7",
"registry+https://github.com/rust-lang/crates.io-index#unicode-segmentation@1.12.0": "14qla2jfx74yyb9ds3d2mpwpa4l4lzb9z57c6d2ba511458z5k7n",
"registry+https://github.com/rust-lang/crates.io-index#unicode-width@0.1.14": "1bzn2zv0gp8xxbxbhifw778a7fc93pa6a1kj24jgg9msj07f7mkx",
"registry+https://github.com/rust-lang/crates.io-index#unsafe-any@0.4.2": "0zwwphsqkw5qaiqmjwngnfpv9ym85qcsyj7adip9qplzjzbn00zk",
"registry+https://github.com/rust-lang/crates.io-index#url@1.7.2": "0nim1c90mxpi9wgdw2xh8dqd72vlklwlzam436akcrhjac6pqknx",
"registry+https://github.com/rust-lang/crates.io-index#url@2.5.4": "0q6sgznyy2n4l5lm16zahkisvc9nip9aa5q1pps7656xra3bdy1j",
"registry+https://github.com/rust-lang/crates.io-index#utf-8@0.7.6": "1a9ns3fvgird0snjkd3wbdhwd3zdpc2h5gpyybrfr6ra5pkqxk09",
"registry+https://github.com/rust-lang/crates.io-index#utf16_iter@1.0.5": "0ik2krdr73hfgsdzw0218fn35fa09dg2hvbi1xp3bmdfrp9js8y8",
"registry+https://github.com/rust-lang/crates.io-index#utf8_iter@1.0.4": "1gmna9flnj8dbyd8ba17zigrp9c4c3zclngf5lnb5yvz1ri41hdn",
"registry+https://github.com/rust-lang/crates.io-index#utf8parse@0.2.2": "088807qwjq46azicqwbhlmzwrbkz7l4hpw43sdkdyyk524vdxaq6",
"registry+https://github.com/rust-lang/crates.io-index#uuid@0.4.0": "0cdj2v6v2yy3zyisij69waksd17cyir1n58kwyk1n622105wbzkw",
"registry+https://github.com/rust-lang/crates.io-index#uuid@0.8.2": "1dy4ldcp7rnzjy56dxh7d2sgrcvn4q77y0a8r0a48946h66zjp5w",
"registry+https://github.com/rust-lang/crates.io-index#uuid@1.12.0": "1i2i7ar5651d58ip1l8cghg3y60pn0rqmssvw6lm8d4s3xc1hh3l",
"registry+https://github.com/rust-lang/crates.io-index#value-bag@1.10.0": "1lnsixdpi1ldms1adxyafyx7lyrqxhhskgwrjckmml6majmc9x1y",
"registry+https://github.com/rust-lang/crates.io-index#vcpkg@0.2.15": "09i4nf5y8lig6xgj3f7fyrvzd3nlaw4znrihw8psidvv5yk4xkdc",
"registry+https://github.com/rust-lang/crates.io-index#version-compare@0.2.0": "12y9262fhjm1wp0aj3mwhads7kv0jz8h168nn5fb8b43nwf9abl5",
"registry+https://github.com/rust-lang/crates.io-index#version_check@0.1.5": "1pf91pvj8n6akh7w6j5ypka6aqz08b3qpzgs0ak2kjf4frkiljwi",
"registry+https://github.com/rust-lang/crates.io-index#version_check@0.9.5": "0nhhi4i5x89gm911azqbn7avs9mdacw2i3vcz3cnmz3mv4rqz4hb",
"registry+https://github.com/rust-lang/crates.io-index#wait-timeout@0.2.0": "1xpkk0j5l9pfmjfh1pi0i89invlavfrd9av5xp0zhxgb29dhy84z",
"registry+https://github.com/rust-lang/crates.io-index#want@0.3.1": "03hbfrnvqqdchb5kgxyavb9jabwza0dmh2vw5kg0dq8rxl57d9xz",
"registry+https://github.com/rust-lang/crates.io-index#warp@0.3.7": "07137zd13lchy5hxpspd0hs6sl19b0fv2zc1chf02nwnzw1d4y23",
"registry+https://github.com/rust-lang/crates.io-index#wasi@0.10.0+wasi-snapshot-preview1": "07y3l8mzfzzz4cj09c8y90yak4hpsi9g7pllyzpr6xvwrabka50s",
"registry+https://github.com/rust-lang/crates.io-index#wasi@0.11.0+wasi-snapshot-preview1": "08z4hxwkpdpalxjps1ai9y7ihin26y9f476i53dv98v45gkqg3cw",
"registry+https://github.com/rust-lang/crates.io-index#wasi@0.13.3+wasi-0.2.2": "1lnapbvdcvi3kc749wzqvwrpd483win2kicn1faa4dja38p6v096",
"registry+https://github.com/rust-lang/crates.io-index#wasite@0.1.0": "0nw5h9nmcl4fyf4j5d4mfdjfgvwi1cakpi349wc4zrr59wxxinmq",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-backend@0.2.100": "1ihbf1hq3y81c4md9lyh6lcwbx6a5j0fw4fygd423g62lm8hc2ig",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-futures@0.4.50": "0q8ymi6i9r3vxly551dhxcyai7nc491mspj0j1wbafxwq074fpam",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-macro-support@0.2.100": "1plm8dh20jg2id0320pbmrlsv6cazfv6b6907z19ys4z1jj7xs4a",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-macro@0.2.100": "01xls2dvzh38yj17jgrbiib1d3nyad7k2yw9s0mpklwys333zrkz",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-shared@0.2.100": "0gffxvqgbh9r9xl36gprkfnh3w9gl8wgia6xrin7v11sjcxxf18s",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen@0.2.100": "1x8ymcm6yi3i1rwj78myl1agqv2m86i648myy3lc97s9swlqkp0y",
"registry+https://github.com/rust-lang/crates.io-index#web-sys@0.3.77": "1lnmc1ffbq34qw91nndklqqm75rasaffj2g4f8h1yvqqz4pdvdik",
"registry+https://github.com/rust-lang/crates.io-index#weezl@0.1.8": "10lhndjgs6y5djpg3b420xngcr6jkmv70q8rb1qcicbily35pa2k",
"registry+https://github.com/rust-lang/crates.io-index#whoami@1.5.2": "0vdvm6sga4v9515l6glqqfnmzp246nq66dd09cw5ri4fyn3mnb9p",
"registry+https://github.com/rust-lang/crates.io-index#winapi-i686-pc-windows-gnu@0.4.0": "1dmpa6mvcvzz16zg6d5vrfy4bxgg541wxrcip7cnshi06v38ffxc",
"registry+https://github.com/rust-lang/crates.io-index#winapi-util@0.1.9": "1fqhkcl9scd230cnfj8apfficpf5c9vhwnk4yy9xfc1sw69iq8ng",
"registry+https://github.com/rust-lang/crates.io-index#winapi-x86_64-pc-windows-gnu@0.4.0": "0gqq64czqb64kskjryj8isp62m2sgvx25yyj3kpc2myh85w24bki",
"registry+https://github.com/rust-lang/crates.io-index#winapi@0.3.9": "06gl025x418lchw1wxj64ycr7gha83m44cjr5sarhynd9xkrm0sw",
"registry+https://github.com/rust-lang/crates.io-index#windows-core@0.52.0": "1nc3qv7sy24x0nlnb32f7alzpd6f72l4p24vl65vydbyil669ark",
"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.48.0": "1aan23v5gs7gya1lc46hqn9mdh8yph3fhxmhxlw36pn6pqc28zb7",
"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.52.0": "0gd3v4ji88490zgb6b5mq5zgbvwv7zx1ibn8v3x83rwcdbryaar8",
"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.59.0": "0fw5672ziw8b3zpmnbp9pdv1famk74f1l9fcbc3zsrzdg56vqf0y",
"registry+https://github.com/rust-lang/crates.io-index#windows-targets@0.48.5": "034ljxqshifs1lan89xwpcy1hp0lhdh4b5n0d2z4fwjx2piacbws",
"registry+https://github.com/rust-lang/crates.io-index#windows-targets@0.52.6": "0wwrx625nwlfp7k93r2rra568gad1mwd888h1jwnl0vfg5r4ywlv",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_gnullvm@0.48.5": "1n05v7qblg1ci3i567inc7xrkmywczxrs1z3lj3rkkxw18py6f1b",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_gnullvm@0.52.6": "1lrcq38cr2arvmz19v32qaggvj8bh1640mdm9c2fr877h0hn591j",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_msvc@0.48.5": "1g5l4ry968p73g6bg6jgyvy9lb8fyhcs54067yzxpcpkf44k2dfw",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_msvc@0.52.6": "0sfl0nysnz32yyfh773hpi49b1q700ah6y7sacmjbqjjn5xjmv09",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_gnu@0.48.5": "0gklnglwd9ilqx7ac3cn8hbhkraqisd0n83jxzf9837nvvkiand7",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_gnu@0.52.6": "02zspglbykh1jh9pi7gn8g1f97jh1rrccni9ivmrfbl0mgamm6wf",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_gnullvm@0.52.6": "0rpdx1537mw6slcpqa0rm3qixmsb79nbhqy5fsm3q2q9ik9m5vhf",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_msvc@0.48.5": "01m4rik437dl9rdf0ndnm2syh10hizvq0dajdkv2fjqcywrw4mcg",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_msvc@0.52.6": "0rkcqmp4zzmfvrrrx01260q3xkpzi6fzi2x2pgdcdry50ny4h294",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnu@0.48.5": "13kiqqcvz2vnyxzydjh73hwgigsdr2z1xpzx313kxll34nyhmm2k",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnu@0.52.6": "0y0sifqcb56a56mvn7xjgs8g43p33mfqkd8wj1yhrgxzma05qyhl",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnullvm@0.48.5": "1k24810wfbgz8k48c2yknqjmiigmql6kk3knmddkv8k8g1v54yqb",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnullvm@0.52.6": "03gda7zjx1qh8k9nnlgb7m3w3s1xkysg55hkd1wjch8pqhyv5m94",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_msvc@0.48.5": "0f4mdp895kkjh9zv8dxvn4pc10xr7839lf5pa9l0193i2pkgr57d",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_msvc@0.52.6": "1v7rb5cibyzx8vak29pdrk8nx9hycsjs4w0jgms08qk49jl6v7sq",
"registry+https://github.com/rust-lang/crates.io-index#winnow@0.5.40": "0xk8maai7gyxda673mmw3pj1hdizy5fpi7287vaywykkk19sk4zm",
"registry+https://github.com/rust-lang/crates.io-index#winnow@0.7.3": "1c9bmhpdwbdmll6b4l6skabz0296dchnmnxw84hh2y3ggyllwzqf",
"registry+https://github.com/rust-lang/crates.io-index#winreg@0.50.0": "1cddmp929k882mdh6i9f2as848f13qqna6czwsqzkh1pqnr5fkjj",
"registry+https://github.com/rust-lang/crates.io-index#wit-bindgen-rt@0.33.0": "0g4lwfp9x6a2i1hgjn8k14nr4fsnpd5izxhc75zpi2s5cvcg6s1j",
"registry+https://github.com/rust-lang/crates.io-index#write16@1.0.0": "0dnryvrrbrnl7vvf5vb1zkmwldhjkf2n5znliviam7bm4900z2fi",
"registry+https://github.com/rust-lang/crates.io-index#writeable@0.5.5": "0lawr6y0bwqfyayf3z8zmqlhpnzhdx0ahs54isacbhyjwa7g778y",
"registry+https://github.com/rust-lang/crates.io-index#xml-rs@0.8.25": "1i73ajf6scni5bi1a51r19xykgrambdx5fkks0fyg5jqqbml1ff5",
"registry+https://github.com/rust-lang/crates.io-index#yansi-term@0.1.2": "1w8vjlvxba6yvidqdvxddx3crl6z66h39qxj8xi6aqayw2nk0p7y",
"registry+https://github.com/rust-lang/crates.io-index#yoke-derive@0.7.5": "0m4i4a7gy826bfvnqa9wy6sp90qf0as3wps3wb0smjaamn68g013",
"registry+https://github.com/rust-lang/crates.io-index#yoke@0.7.5": "0h3znzrdmll0a7sglzf9ji0p5iqml11wrj1dypaf6ad6kbpnl3hj",
"registry+https://github.com/rust-lang/crates.io-index#zerocopy-derive@0.7.35": "0gnf2ap2y92nwdalzz3x7142f2b83sni66l39vxp2ijd6j080kzs",
"registry+https://github.com/rust-lang/crates.io-index#zerocopy@0.7.35": "1w36q7b9il2flg0qskapgi9ymgg7p985vniqd09vi0mwib8lz6qv",
"registry+https://github.com/rust-lang/crates.io-index#zerofrom-derive@0.1.5": "022q55phhb44qbrcfbc48k0b741fl8gnazw3hpmmndbx5ycfspjr",
"registry+https://github.com/rust-lang/crates.io-index#zerofrom@0.1.5": "0bnd8vjcllzrvr3wvn8x14k2hkrpyy1fm3crkn2y3plmr44fxwyg",
"registry+https://github.com/rust-lang/crates.io-index#zeroize@1.8.1": "1pjdrmjwmszpxfd7r860jx54cyk94qk59x13sc307cvr5256glyf",
"registry+https://github.com/rust-lang/crates.io-index#zerovec-derive@0.10.3": "1ik322dys6wnap5d3gcsn09azmssq466xryn5czfm13mn7gsdbvf",
"registry+https://github.com/rust-lang/crates.io-index#zerovec@0.10.4": "0yghix7n3fjfdppwghknzvx9v8cf826h2qal5nqvy8yzg4yqjaxa",
"registry+https://github.com/rust-lang/crates.io-index#zune-inflate@0.2.54": "00kg24jh3zqa3i6rg6yksnb71bch9yi1casqydl00s7nw8pk7avk"
}

16
cyber-slides/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "cyber-slides"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = "1.13.0"
cairo-rs = "0.18"
cyberpunk = { path = "../cyberpunk" }
gio = "0.18"
glib = "0.18"
gtk = { version = "0.7", package = "gtk4" }
serde = { version = "1.0.210", features = ["derive"] }
serde_yml = "0.0.12"

410
cyber-slides/src/main.rs Normal file
View File

@ -0,0 +1,410 @@
use std::{
cell::RefCell,
collections::HashMap,
fs::File,
io::Read,
ops::Index,
path::Path,
rc::Rc,
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use cairo::{Context, Rectangle};
use cyberpunk::{AsymLine, AsymLineCutout, GlowPen, Pen, Text};
use glib::Object;
use gtk::{
glib::{self},
prelude::*,
subclass::prelude::*,
EventControllerKey,
};
use serde::{Deserialize, Serialize};
const FPS: u64 = 60;
const PURPLE: (f64, f64, f64) = (0.7, 0., 1.);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
enum Position {
Top,
Middle,
Bottom,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Step {
text: String,
position: Position,
transition: Duration,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Default)]
struct Script(Vec<Step>);
impl Script {
fn from_file(path: &Path) -> Result<Script, serde_yml::Error> {
let mut buf: Vec<u8> = Vec::new();
let mut f = File::open(path).unwrap();
f.read_to_end(&mut buf).unwrap();
let script = serde_yml::from_slice(&buf)?;
Ok(Self(script))
}
fn iter(&self) -> impl Iterator<Item = &'_ Step> {
self.0.iter()
}
fn len(&self) -> usize {
self.0.len()
}
}
impl Index<usize> for Script {
type Output = Step;
fn index(&self, index: usize) -> &Self::Output {
&self.0[index]
}
}
struct Fade {
text: String,
position: Position,
duration: Duration,
start_time: Instant,
}
trait Animation {
fn position(&self) -> Position;
fn tick(&self, now: Instant, context: &Context, width: f64);
}
impl Animation for Fade {
fn position(&self) -> Position {
self.position.clone()
}
fn tick(&self, now: Instant, context: &Context, width: f64) {
let total_frames = self.duration.as_secs() * FPS;
let alpha_rate: f64 = 1. / total_frames as f64;
let frames = (now - self.start_time).as_secs_f64() * FPS as f64;
let alpha = alpha_rate * frames;
let text_display = Text::new(self.text.clone(), context, 64., width);
context.move_to(0., text_display.extents().height());
context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, alpha);
text_display.draw();
}
}
struct CrossFade {
old_text: String,
new_text: String,
position: Position,
duration: Duration,
start_time: Instant,
}
impl Animation for CrossFade {
fn position(&self) -> Position {
self.position.clone()
}
fn tick(&self, now: Instant, context: &Context, width: f64) {
let total_frames = self.duration.as_secs() * FPS;
let alpha_rate: f64 = 1. / total_frames as f64;
let frames = (now - self.start_time).as_secs_f64() * FPS as f64;
let alpha = alpha_rate * frames;
let text_display = Text::new(self.old_text.clone(), context, 64., width);
context.move_to(0., text_display.extents().height());
context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, 1. - alpha);
text_display.draw();
let text_display = Text::new(self.new_text.clone(), context, 64., width);
context.move_to(0., text_display.extents().height());
context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, alpha);
text_display.draw();
}
}
#[derive(Debug)]
pub struct CyberScreenState {
script: Script,
idx: Option<usize>,
top: Option<Step>,
middle: Option<Step>,
bottom: Option<Step>,
}
impl Default for CyberScreenState {
fn default() -> Self {
Self {
script: Script(vec![]),
idx: None,
top: None,
middle: None,
bottom: None,
}
}
}
impl CyberScreenState {
fn new(script: Script) -> CyberScreenState {
CyberScreenState { script, ..Default::default() }
}
fn next_page(&mut self) -> Box<dyn Animation> {
let idx = match self.idx {
None => 0,
Some(idx) => {
if idx < self.script.len() {
idx + 1
} else {
idx
}
}
};
self.idx = Some(idx);
let step = self.script[idx].clone();
let (old, new) = match step.position {
Position::Top => {
let old = self.top.replace(step.clone());
(old, step)
}
Position::Middle => {
let old = self.middle.replace(step.clone());
(old, step)
}
Position::Bottom => {
let old = self.bottom.replace(step.clone());
(old, step)
}
};
match old {
Some(old) => Box::new(CrossFade {
old_text: old.text.clone(),
new_text: new.text.clone(),
position: new.position,
duration: new.transition,
start_time: Instant::now(),
}),
None => Box::new(Fade {
text: new.text.clone(),
position: new.position,
duration: new.transition,
start_time: Instant::now(),
}),
}
}
}
#[derive(Default)]
pub struct CyberScreenPrivate {
state: Rc<RefCell<CyberScreenState>>,
// For crossfading to work, I have to detect that there is an old animation in a position, and
// replace it with the new one.
animations: Rc<RefCell<HashMap<Position, Box<dyn Animation>>>>,
}
#[glib::object_subclass]
impl ObjectSubclass for CyberScreenPrivate {
const NAME: &'static str = "CyberScreen";
type Type = CyberScreen;
type ParentType = gtk::DrawingArea;
}
impl ObjectImpl for CyberScreenPrivate {}
impl WidgetImpl for CyberScreenPrivate {}
impl DrawingAreaImpl for CyberScreenPrivate {}
impl CyberScreenPrivate {
fn set_script(&self, script: Script) {
*self.state.borrow_mut() = CyberScreenState::new(script);
}
fn next_page(&self) {
let transition = self.state.borrow_mut().next_page();
self.animations
.borrow_mut()
.insert(transition.position(), transition);
}
}
glib::wrapper! {
pub struct CyberScreen(ObjectSubclass<CyberScreenPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl CyberScreen {
fn new(script: Script) -> Self {
let s: Self = Object::builder().build();
s.imp().set_script(script);
s.set_draw_func({
let s = s.clone();
move |_, context, width, height| {
let now = Instant::now();
context.set_source_rgb(0., 0., 0.);
let _ = context.paint();
let pen = GlowPen::new(width, height, 2., 8., (0.7, 0., 1.));
AsymLineCutout {
orientation: gtk::Orientation::Horizontal,
start_x: 25.,
start_y: height as f64 / 7.,
start_length: width as f64 / 3.,
cutout_length: width as f64 / 3. - 100.,
height: 50.,
end_length: width as f64 / 3. - 50.,
invert: false,
}
.draw(&pen);
pen.stroke();
AsymLine {
orientation: gtk::Orientation::Horizontal,
start_x: width as f64 / 4.,
start_y: height as f64 * 6. / 7.,
start_length: width as f64 * 2. / 3. - 25.,
height: 50.,
end_length: 0.,
invert: false,
}
.draw(&pen);
pen.stroke();
let tracery = pen.finish();
let _ = context.set_source(tracery);
let _ = context.paint();
let animations = s.imp().animations.borrow_mut();
let lr_margin = 50.;
let max_width = width as f64 - lr_margin * 2.;
let region_height = height as f64 / 5.;
if let Some(animation) = animations.get(&Position::Top) {
let y = height as f64 * 1. / 5.;
let surface = context
.target()
.create_for_rectangle(Rectangle::new(20., y, max_width, region_height))
.unwrap();
let ctx = Context::new(&surface).unwrap();
animation.tick(now, &ctx, max_width);
}
if let Some(animation) = animations.get(&Position::Middle) {
let y = height as f64 * 2. / 5.;
let surface = context
.target()
.create_for_rectangle(Rectangle::new(20., y, max_width, region_height))
.unwrap();
let ctx = Context::new(&surface).unwrap();
animation.tick(now, &ctx, max_width);
}
if let Some(animation) = animations.get(&Position::Bottom) {
let y = height as f64 * 3. / 5.;
let surface = context
.target()
.create_for_rectangle(Rectangle::new(20., y, max_width, region_height))
.unwrap();
let ctx = Context::new(&surface).unwrap();
animation.tick(now, &ctx, max_width);
}
}
});
s
}
fn next_page(&self) {
self.imp().next_page();
self.queue_draw();
}
}
fn main() {
let script = Arc::new(RwLock::new(Script::default()));
let app = gtk::Application::builder()
.application_id("com.luminescent-dreams.cyberpunk-slideshow")
.build();
app.add_main_option(
"script",
glib::char::Char::from(b's'),
glib::OptionFlags::IN_MAIN,
glib::OptionArg::String,
"",
None,
);
app.connect_handle_local_options({
let script = script.clone();
move |_, options| {
if let Some(script_path) = options.lookup::<String>("script").unwrap() {
let mut script = script.write().unwrap();
*script = Script::from_file(Path::new(&script_path)).unwrap();
-1
} else {
1
}
}
});
app.connect_activate(move |app| {
let window = gtk::ApplicationWindow::new(app);
let screen = CyberScreen::new(script.read().unwrap().clone());
let events = EventControllerKey::new();
events.connect_key_released({
let app = app.clone();
let window = window.clone();
let screen = screen.clone();
move |_, key, _, _| {
let name = key
.name()
.map(|s| s.as_str().to_owned())
.unwrap_or("".to_owned());
match name.as_ref() {
"Right" => screen.next_page(),
"q" => app.quit(),
"Escape" => window.unfullscreen(),
_ => {}
}
}
});
window.add_controller(events);
window.set_child(Some(&screen));
window.set_width_request(800);
window.set_height_request(600);
window.present();
window.connect_maximized_notify(|window| {
window.fullscreen();
});
let _ = glib::spawn_future_local({
let screen = screen.clone();
async move {
loop {
screen.queue_draw();
async_std::task::sleep(Duration::from_millis(1000 / FPS)).await;
}
}
});
});
app.run();
}

View File

@ -7,7 +7,9 @@ license = "GPL-3.0-only"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cairo-rs = { version = "0.17" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gtk = { version = "0.6", package = "gtk4" }
async-std = "1.13.0"
cairo-rs = { version = "0.18" }
cyberpunk = { path = "../cyberpunk" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4" }

View File

@ -1,7 +1,8 @@
use cairo::{
Context, FontSlant, FontWeight, Format, ImageSurface, LineCap, LinearGradient, Pattern,
Context, FontSlant, FontWeight, Format, ImageSurface, LinearGradient, Pattern,
TextExtents,
};
use cyberpunk::{AsymLine, AsymLineCutout, GlowPen, Pen, SlashMeter};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*, EventControllerKey};
use std::{
@ -171,7 +172,7 @@ impl SplashPrivate {
start_y: extents.height() + 10.,
start_length: 0.,
height: extents.height() / 2.,
total_length: extents.width() + extents.height() / 2.,
end_length: 0.,
invert: false,
}
.draw(&pen);
@ -183,7 +184,7 @@ impl SplashPrivate {
start_y: extents.height() + 60.,
start_length: extents.width(),
height: extents.height() / 2.,
total_length: extents.width() + extents.height() / 2.,
end_length: 0.,
invert: false,
}
.draw(&pen);
@ -208,7 +209,7 @@ impl SplashPrivate {
start_x: 20.,
start_y: center_y - 20. - title_height / 2.,
start_length,
total_length: *self.width.borrow() as f64 - 120.,
end_length: *self.width.borrow() as f64 - 120. - start_length,
cutout_length: title_width,
height: title_height,
invert: false,
@ -243,7 +244,7 @@ impl SplashPrivate {
start_y: *self.height.borrow() as f64 / 2. + 100.,
start_length: 400.,
height: 50.,
total_length: 650.,
end_length: 0.,
invert: true,
}
.draw(&pen);
@ -258,7 +259,7 @@ impl SplashPrivate {
start_y: *self.height.borrow() as f64 / 2. + 200.,
start_length: 600.,
height: 50.,
total_length: 650.,
end_length: 0.,
invert: false,
}
.draw(&pen);
@ -419,212 +420,6 @@ impl Splash {
}
}
struct AsymLineCutout {
orientation: gtk::Orientation,
start_x: f64,
start_y: f64,
start_length: f64,
total_length: f64,
cutout_length: f64,
height: f64,
invert: bool,
}
impl AsymLineCutout {
fn draw(&self, pen: &impl Pen) {
let dodge = if self.invert {
self.height
} else {
-self.height
};
match self.orientation {
gtk::Orientation::Horizontal => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x + self.start_length, self.start_y);
pen.line_to(
self.start_x + self.start_length + self.height,
self.start_y + dodge,
);
pen.line_to(
self.start_x + self.start_length + self.height + self.cutout_length,
self.start_y + dodge,
);
pen.line_to(
self.start_x
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.),
self.start_y + dodge / 2.,
);
pen.line_to(self.total_length, self.start_y + dodge / 2.);
}
gtk::Orientation::Vertical => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x, self.start_y + self.start_length);
pen.line_to(
self.start_x + dodge,
self.start_y + self.start_length + self.height,
);
pen.line_to(
self.start_x + dodge,
self.start_y + self.start_length + self.height + self.cutout_length,
);
pen.line_to(
self.start_x + dodge / 2.,
self.start_y
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.),
);
pen.line_to(self.start_x + dodge / 2., self.total_length);
}
_ => panic!("unknown orientation"),
}
}
}
struct AsymLine {
orientation: gtk::Orientation,
start_x: f64,
start_y: f64,
start_length: f64,
height: f64,
total_length: f64,
invert: bool,
}
impl AsymLine {
fn draw(&self, pen: &impl Pen) {
let dodge = if self.invert {
self.height
} else {
-self.height
};
match self.orientation {
gtk::Orientation::Horizontal => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x + self.start_length, self.start_y);
pen.line_to(
self.start_x + self.start_length + self.height,
self.start_y + dodge,
);
pen.line_to(self.start_x + self.total_length, self.start_y + dodge);
}
gtk::Orientation::Vertical => {}
_ => panic!("unknown orientation"),
}
}
}
struct SlashMeter {
orientation: gtk::Orientation,
start_x: f64,
start_y: f64,
count: u8,
fill_count: u8,
height: f64,
length: f64,
}
impl SlashMeter {
fn draw(&self, context: &Context) {
match self.orientation {
gtk::Orientation::Horizontal => {
let angle: f64 = 0.8;
let run = self.height / angle.tan();
let width = self.length / (self.count as f64 * 2.);
for c in 0..self.count {
context.set_line_width(1.);
let start_x = self.start_x + c as f64 * width * 2.;
context.move_to(start_x, self.start_y);
context.line_to(start_x + run, self.start_y - self.height);
context.line_to(start_x + run + width, self.start_y - self.height);
context.line_to(start_x + width, self.start_y);
context.line_to(start_x, self.start_y);
if c < self.fill_count {
let _ = context.fill();
} else {
let _ = context.stroke();
}
}
}
gtk::Orientation::Vertical => {}
_ => panic!("unknown orientation"),
}
}
}
trait Pen {
fn move_to(&self, x: f64, y: f64);
fn line_to(&self, x: f64, y: f64);
fn stroke(&self);
fn finish(self) -> Pattern;
}
struct GlowPen {
blur_context: Context,
draw_context: Context,
}
impl GlowPen {
fn new(
width: i32,
height: i32,
line_width: f64,
blur_line_width: f64,
color: (f64, f64, f64),
) -> Self {
let blur_context =
Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap();
blur_context.set_line_width(blur_line_width);
blur_context.set_source_rgba(color.0, color.1, color.2, 0.5);
blur_context.push_group();
blur_context.set_line_cap(LineCap::Round);
let draw_context =
Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap();
draw_context.set_line_width(line_width);
draw_context.set_source_rgb(color.0, color.1, color.2);
draw_context.push_group();
draw_context.set_line_cap(LineCap::Round);
Self {
blur_context,
draw_context,
}
}
}
impl Pen for GlowPen {
fn move_to(&self, x: f64, y: f64) {
self.blur_context.move_to(x, y);
self.draw_context.move_to(x, y);
}
fn line_to(&self, x: f64, y: f64) {
self.blur_context.line_to(x, y);
self.draw_context.line_to(x, y);
}
fn stroke(&self) {
self.blur_context.stroke().expect("to draw the blur line");
self.draw_context
.stroke()
.expect("to draw the regular line");
}
fn finish(self) -> Pattern {
let foreground = self.draw_context.pop_group().unwrap();
self.blur_context.set_source(foreground).unwrap();
self.blur_context.paint().unwrap();
self.blur_context.pop_group().unwrap()
}
}
fn main() {
let app = gtk::Application::builder()
@ -702,8 +497,7 @@ fn main() {
});
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<State>(gtk::glib::PRIORITY_DEFAULT);
let (gtk_tx, gtk_rx) = async_std::channel::unbounded();
let window = gtk::ApplicationWindow::new(app);
window.present();
@ -734,19 +528,25 @@ fn main() {
});
window.add_controller(keyboard_events);
gtk_rx.attach(None, move |state| {
glib::spawn_future_local({
let splash = splash.clone();
async move {
while let Ok(state) = gtk_rx.recv().await {
println!("received state");
splash.set_state(state);
Continue(true)
}
}
});
std::thread::spawn({
glib::spawn_future_local({
let state = state.clone();
move || {
async move {
state.write().unwrap().start();
loop {
std::thread::sleep(Duration::from_millis(1000 / 60));
async_std::task::sleep(Duration::from_millis(1000 / 60)).await;
state.write().unwrap().run(Instant::now());
let _ = gtk_tx.send(*state.read().unwrap());
println!("state: {:?}", state.read().unwrap());
let _ = gtk_tx.send(*state.read().unwrap()).await;
}
}
});

12
cyberpunk/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "cyberpunk"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cairo-rs = { version = "0.18" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4" }

301
cyberpunk/src/lib.rs Normal file
View File

@ -0,0 +1,301 @@
use cairo::{
Context, FontSlant, FontWeight, Format, ImageSurface, LineCap, Pattern,
TextExtents,
};
pub struct AsymLineCutout {
pub orientation: gtk::Orientation,
pub start_x: f64,
pub start_y: f64,
pub start_length: f64,
pub cutout_length: f64,
pub end_length: f64,
pub height: f64,
pub invert: bool,
}
impl AsymLineCutout {
pub fn draw(&self, pen: &impl Pen) {
let dodge = if self.invert {
self.height
} else {
-self.height
};
match self.orientation {
gtk::Orientation::Horizontal => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x + self.start_length, self.start_y);
pen.line_to(
self.start_x + self.start_length + self.height,
self.start_y + dodge,
);
pen.line_to(
self.start_x + self.start_length + self.height + self.cutout_length,
self.start_y + dodge,
);
pen.line_to(
self.start_x
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.),
self.start_y + dodge / 2.,
);
pen.line_to(
self.start_x
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.)
+ self.end_length,
self.start_y + dodge / 2.,
);
}
gtk::Orientation::Vertical => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x, self.start_y + self.start_length);
pen.line_to(
self.start_x + dodge,
self.start_y + self.start_length + self.height,
);
pen.line_to(
self.start_x + dodge,
self.start_y + self.start_length + self.height + self.cutout_length,
);
pen.line_to(
self.start_x + dodge / 2.,
self.start_y
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.),
);
pen.line_to(
self.start_x + dodge / 2.,
self.start_y
+ self.start_length
+ self.height
+ self.cutout_length
+ (self.height / 2.)
+ self.end_length,
);
}
_ => panic!("unknown orientation"),
}
}
}
// Represents an asymetrical line that starts at one location, then a 45-degree angle and then
// another line afterwards.
pub struct AsymLine {
// Will this be drawn left-to-right or up-to-down?
pub orientation: gtk::Orientation,
// Starting address
pub start_x: f64,
pub start_y: f64,
// Length of the first segment
pub start_length: f64,
// Height to dodge over to the next section
pub height: f64,
// Total length of the entire line.
pub end_length: f64,
// When normal, the angle dodge is upwards. When inverted, the angle dodge is downwards.
pub invert: bool,
}
impl AsymLine {
pub fn draw(&self, pen: &impl Pen) {
let dodge = if self.invert {
self.height
} else {
-self.height
};
match self.orientation {
gtk::Orientation::Horizontal => {
pen.move_to(self.start_x, self.start_y);
pen.line_to(self.start_x + self.start_length, self.start_y);
pen.line_to(
self.start_x + self.start_length + self.height,
self.start_y + dodge,
);
pen.line_to(
self.start_x + self.start_length + self.height + self.end_length,
self.start_y + dodge,
);
}
gtk::Orientation::Vertical => {}
_ => panic!("unknown orientation"),
}
}
}
pub struct SlashMeter {
pub orientation: gtk::Orientation,
pub start_x: f64,
pub start_y: f64,
pub count: u8,
pub fill_count: u8,
pub height: f64,
pub length: f64,
}
impl SlashMeter {
pub fn draw(&self, context: &Context) {
match self.orientation {
gtk::Orientation::Horizontal => {
let angle: f64 = 0.8;
let run = self.height / angle.tan();
let width = self.length / (self.count as f64 * 2.);
for c in 0..self.count {
context.set_line_width(1.);
let start_x = self.start_x + c as f64 * width * 2.;
context.move_to(start_x, self.start_y);
context.line_to(start_x + run, self.start_y - self.height);
context.line_to(start_x + run + width, self.start_y - self.height);
context.line_to(start_x + width, self.start_y);
context.line_to(start_x, self.start_y);
if c < self.fill_count {
let _ = context.fill();
} else {
let _ = context.stroke();
}
}
}
gtk::Orientation::Vertical => {}
_ => panic!("unknown orientation"),
}
}
}
/// Represents a pen for drawing a pattern. This is good for complex patterns that may require
/// multiple identical steps.
pub trait Pen {
/// Move the pen to a location.
fn move_to(&self, x: f64, y: f64);
/// Draw a line from the current location to the specified destination.
fn line_to(&self, x: f64, y: f64);
/// Instantiate the line.
fn stroke(&self);
/// Convert all of the drawing into a pattern that can be painted to a drawing context.
fn finish(self) -> Pattern;
}
pub struct GlowPen {
blur_context: Context,
draw_context: Context,
}
impl GlowPen {
pub fn new(
width: i32,
height: i32,
line_width: f64,
blur_line_width: f64,
color: (f64, f64, f64),
) -> Self {
let blur_context =
Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap();
blur_context.set_line_width(blur_line_width);
blur_context.set_source_rgba(color.0, color.1, color.2, 0.5);
blur_context.push_group();
blur_context.set_line_cap(LineCap::Round);
let draw_context =
Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap();
draw_context.set_line_width(line_width);
draw_context.set_source_rgb(color.0, color.1, color.2);
draw_context.push_group();
draw_context.set_line_cap(LineCap::Round);
Self {
blur_context,
draw_context,
}
}
}
impl Pen for GlowPen {
fn move_to(&self, x: f64, y: f64) {
self.blur_context.move_to(x, y);
self.draw_context.move_to(x, y);
}
fn line_to(&self, x: f64, y: f64) {
self.blur_context.line_to(x, y);
self.draw_context.line_to(x, y);
}
fn stroke(&self) {
self.blur_context.stroke().expect("to draw the blur line");
self.draw_context
.stroke()
.expect("to draw the regular line");
}
fn finish(self) -> Pattern {
let foreground = self.draw_context.pop_group().unwrap();
self.blur_context.set_source(foreground).unwrap();
self.blur_context.paint().unwrap();
self.blur_context.pop_group().unwrap()
}
}
pub struct Text<'a> {
content: Vec<String>,
context: &'a Context,
}
impl<'a> Text<'a> {
pub fn new(content: String, context: &'a Context, size: f64, width: f64) -> Self {
context.select_font_face("Alegreya Sans SC", FontSlant::Normal, FontWeight::Bold);
context.set_font_size(size);
let lines = word_wrap(content, context, width);
Self { content: lines, context }
}
pub fn extents(&self) -> TextExtents {
self.context.text_extents(&self.content[0]).unwrap()
}
pub fn draw(&self) {
let mut baseline = 0.;
for line in self.content.iter() {
baseline += self.context.text_extents(line).unwrap().height() + 10.;
self.context.move_to(0., baseline);
let _ = self.context.show_text(line);
}
}
}
fn word_wrap(content: String, context: &Context, max_width: f64) -> Vec<String> {
let mut lines = vec![];
let words: Vec<&str> = content.split_whitespace().collect();
let mut start: usize = 0;
let mut line = String::new();
for idx in 0..words.len() + 1 {
line = words[start..idx].join(" ");
let extents = context.text_extents(&line).unwrap();
if extents.width() > max_width {
let line = words[start..idx-1].join(" ");
start = idx-1;
lines.push(line.clone());
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}

View File

@ -1,32 +1,31 @@
[package]
name = "dashboard"
version = "0.1.1"
version = "0.1.3"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.17" }
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
async-std = { version = "1.13" }
cairo-rs = { version = "0.18" }
chrono = { version = "0.4", features = ["serde"] }
fluent-ergonomics = { path = "../fluent-ergonomics/" }
fluent = { version = "0.16" }
futures = { version = "0.3" }
geo-types = { path = "../geo-types/" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gdk = { version = "0.6", package = "gdk4" }
gtk = { version = "0.6", package = "gtk4" }
ifc = { path = "../ifc/" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4" }
lazy_static = { version = "1.4" }
memorycache = { path = "../memorycache/" }
reqwest = { version = "0.11", features = ["json"] }
serde_derive = { version = "1" }
serde_json = { version = "1" }
serde = { version = "1" }
serde = { version = "1", features = [ "derive" ] }
tokio = { version = "1", features = ["full"] }
unic-langid = { version = "0.9" }
[build-dependencies]
glib-build-tools = "0.16"
glib-build-tools = "0.18"

View File

@ -1,7 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
"resources/gresources.xml",
&["resources"],
"gresources.xml",
"com.luminescent-dreams.dashboard.gresource",
);
}

View File

@ -31,7 +31,9 @@ impl ApplicationWindow {
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
#[allow(deprecated)]
let context = window.style_context();
#[allow(deprecated)]
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let layout = gtk::Box::builder()
@ -41,7 +43,10 @@ impl ApplicationWindow {
.build();
let date_label = Date::default();
layout.append(&date_label);
let header = adw::HeaderBar::builder()
.title_widget(&date_label)
.build();
layout.append(&header);
let events = Events::default();
layout.append(&events);

View File

@ -1,20 +1,19 @@
use chrono::Datelike;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use ifc::IFC;
use std::{cell::RefCell, rc::Rc};
use chrono::NaiveDate;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
pub struct DatePrivate {
date: Rc<RefCell<IFC>>,
date: Rc<RefCell<NaiveDate>>,
label: Rc<RefCell<gtk::Label>>,
}
impl Default for DatePrivate {
fn default() -> Self {
let date = chrono::Local::now().date_naive();
Self {
date: Rc::new(RefCell::new(IFC::from(
chrono::Local::now().date_naive().with_year(12023).unwrap(),
))),
date: Rc::new(RefCell::new(date)),
label: Rc::new(RefCell::new(gtk::Label::new(None))),
}
}
@ -51,19 +50,16 @@ impl Default for Date {
}
impl Date {
pub fn update_date(&self, date: IFC) {
pub fn update_date(&self, date: NaiveDate) {
*self.imp().date.borrow_mut() = date;
self.redraw();
}
fn redraw(&self) {
let date = self.imp().date.borrow().clone();
self.imp().label.borrow_mut().set_text(&format!(
"{:?}, {:?} {}, {}",
date.weekday(),
date.month(),
date.day(),
date.year()
));
let date = self.imp().date.borrow();
self.imp()
.label
.borrow_mut()
.set_text(&date.format("%Y %B %d").to_string());
}
}

View File

@ -4,7 +4,6 @@ use crate::{
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use ifc::IFC;
/*
#[derive(PartialEq)]
@ -59,19 +58,19 @@ impl Events {
pub fn set_events(&self, events: YearlyEvents, next_event: solstices::Event) {
self.imp()
.spring_equinox
.update_date(IFC::from(events.spring_equinox.date_naive()));
.update_date(events.spring_equinox.date_naive());
self.imp()
.summer_solstice
.update_date(IFC::from(events.summer_solstice.date_naive()));
.update_date(events.summer_solstice.date_naive());
self.imp()
.autumn_equinox
.update_date(IFC::from(events.autumn_equinox.date_naive()));
.update_date(events.autumn_equinox.date_naive());
self.imp()
.winter_solstice
.update_date(IFC::from(events.winter_solstice.date_naive()));
.update_date(events.winter_solstice.date_naive());
self.imp().spring_equinox.remove_css_class("highlight");
self.imp().summer_solstice.remove_css_class("highlight");

View File

@ -36,6 +36,7 @@ impl Default for TransitClock {
s.set_draw_func({
let s = s.clone();
move |_, context, width, height| {
#[allow(deprecated)]
let style_context = WidgetExt::style_context(&s);
let center_x = width as f64 / 2.;
let center_y = height as f64 / 2.;
@ -45,7 +46,9 @@ impl Default for TransitClock {
let sunrise = info.sunrise - NaiveTime::from_hms_opt(0, 0, 0).unwrap();
let sunset = info.sunset - NaiveTime::from_hms_opt(0, 0, 0).unwrap();
#[allow(deprecated)]
let night_color = style_context.lookup_color("dark_5").unwrap();
#[allow(deprecated)]
let day_color = style_context.lookup_color("blue_1").unwrap();
PieChart::new(&style_context)

View File

@ -1,5 +1,7 @@
use cairo::Context;
use gtk::{gdk::RGBA, prelude::*, StyleContext};
use gtk::{gdk::RGBA, prelude::*};
#[allow(deprecated)]
use gtk::StyleContext;
use std::f64::consts::PI;
#[derive(Clone, Debug)]
@ -27,7 +29,9 @@ pub struct PieChart {
}
impl PieChart {
#[allow(deprecated)]
pub fn new(style_context: &StyleContext) -> Self {
#[allow(deprecated)]
Self {
rotation: 0.,
wedges: vec![],

View File

@ -1,13 +1,13 @@
use chrono::{Datelike, Local, Utc};
use geo_types::{Latitude, Longitude};
use glib::Sender;
use gtk::prelude::*;
use ifc::IFC;
use std::{
env,
sync::{Arc, RwLock},
};
use async_std::channel::Sender;
use chrono::{Datelike, Local, Utc};
use geo_types::{Latitude, Longitude};
use gtk::prelude::*;
mod app_window;
use app_window::ApplicationWindow;
@ -102,14 +102,17 @@ pub fn main() {
let now = Local::now();
let state = State {
date: IFC::from(now.date_naive().with_year(12023).unwrap()),
date: now.date_naive(),
next_event: EVENTS.next_event(now.with_timezone(&Utc)).unwrap(),
events: EVENTS.yearly_events(now.year()).unwrap(),
transit: Some(transit),
};
if let Some(ref gtk_tx) = *core.tx.read().unwrap() {
let _ = gtk_tx.send(Message::Refresh(state.clone()));
let gtk_tx = core.tx.read().unwrap().clone();
if let Some(gtk_tx) = gtk_tx {
let state = state.clone();
let _ = gtk_tx.send(Message::Refresh(state)).await;
std::thread::sleep(std::time::Duration::from_secs(60));
} else {
std::thread::sleep(std::time::Duration::from_secs(1));
@ -119,21 +122,17 @@ pub fn main() {
});
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
let (gtk_tx, gtk_rx) = async_std::channel::unbounded();
*core.tx.write().unwrap() = Some(gtk_tx);
let window = ApplicationWindow::new(app);
window.window.present();
gtk_rx.attach(None, {
let window = window.clone();
move |msg| {
let Message::Refresh(state) = msg;
ApplicationWindow::update_state(&window, state);
Continue(true)
glib::spawn_future_local(async move {
loop {
let Message::Refresh(state) = gtk_rx.recv().await.unwrap();
window.update_state(state);
}
});
});

View File

@ -1,7 +1,8 @@
use std::collections::HashMap;
use chrono::prelude::*;
use lazy_static::lazy_static;
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// http://astropixels.com/ephemeris/soleq2001.html
const SOLSTICE_TEXT: &str = "

View File

@ -132,7 +132,7 @@ impl SolunaClient {
#[cfg(test)]
mod test {
use super::*;
use serde_json;
const EXAMPLE: &str = "{\"sunRise\":\"7:15\",\"sunTransit\":\"12:30\",\"sunSet\":\"17:45\",\"moonRise\":null,\"moonTransit\":\"7:30\",\"moonUnder\":\"19:54\",\"moonSet\":\"15:02\",\"moonPhase\":\"Waning Crescent\",\"moonIllumination\":0.35889454647387764,\"sunRiseDec\":7.25,\"sunTransitDec\":12.5,\"sunSetDec\":17.75,\"moonRiseDec\":null,\"moonSetDec\":15.033333333333333,\"moonTransitDec\":7.5,\"moonUnderDec\":19.9,\"minor1Start\":null,\"minor1Stop\":null,\"minor2StartDec\":14.533333333333333,\"minor2Start\":\"14:32\",\"minor2StopDec\":15.533333333333333,\"minor2Stop\":\"15:32\",\"major1StartDec\":6.5,\"major1Start\":\"06:30\",\"major1StopDec\":8.5,\"major1Stop\":\"08:30\",\"major2StartDec\":18.9,\"major2Start\":\"18:54\",\"major2StopDec\":20.9,\"major2Stop\":\"20:54\",\"dayRating\":1,\"hourlyRating\":{\"0\":20,\"1\":20,\"2\":0,\"3\":0,\"4\":0,\"5\":0,\"6\":20,\"7\":40,\"8\":40,\"9\":20,\"10\":0,\"11\":0,\"12\":0,\"13\":0,\"14\":0,\"15\":20,\"16\":20,\"17\":20,\"18\":40,\"19\":20,\"20\":20,\"21\":20,\"22\":0,\"23\":0}}";

View File

@ -2,11 +2,11 @@ use crate::{
solstices::{Event, YearlyEvents},
soluna_client::SunMoon,
};
use ifc::IFC;
use chrono::NaiveDate;
#[derive(Clone, Debug)]
pub struct State {
pub date: IFC,
pub date: NaiveDate,
pub next_event: Event,
pub events: YearlyEvents,
pub transit: Option<SunMoon>,

View File

@ -9,6 +9,7 @@ documentation = "https://docs.rs/emseries"
homepage = "https://github.com/luminescent-dreams/emseries"
repository = "https://github.com/luminescent-dreams/emseries"
categories = ["database-implementations"]
edition = "2021"
include = [
"**/*.rs",

View File

@ -10,7 +10,7 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use types::{Recordable, Timestamp};
use crate::types::{Recordable, Timestamp};
/// This trait is used for constructing queries for searching the database.
pub trait Criteria {

View File

@ -1,198 +0,0 @@
/*
Copyright 2020-2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of the Luminescent Dreams Tools.
Luminescent Dreams Tools is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Luminescent Dreams Tools is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate chrono;
extern crate chrono_tz;
use chrono::SecondsFormat;
use chrono_tz::Etc::UTC;
use serde::de::{self, Deserialize, Deserializer, Visitor};
use serde::ser::{Serialize, Serializer};
use std::{fmt, str::FromStr};
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
/// string.
///
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
/// and the named time zone allows future parsers to know the exact interpretation of the time in
/// the frame of reference of the original recording.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
impl fmt::Display for DateTimeTz {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
if self.0.timezone() == UTC {
write!(f, "{}", self.0.to_rfc3339_opts(SecondsFormat::Secs, true))
} else {
write!(
f,
"{} {}",
self.0
.with_timezone(&chrono_tz::Etc::UTC)
.to_rfc3339_opts(SecondsFormat::Secs, true,),
self.0.timezone().name()
)
}
}
}
impl DateTimeTz {
pub fn map<F>(&self, f: F) -> DateTimeTz
where
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
{
DateTimeTz(f(self.0))
}
}
impl std::str::FromStr for DateTimeTz {
type Err = chrono::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: Vec<&str> = s.split_terminator(' ').collect();
if v.len() == 2 {
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
} else {
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
}
}
}
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
DateTimeTz(dt)
}
}
struct DateTimeTzVisitor;
impl<'de> Visitor<'de> for DateTimeTzVisitor {
type Value = DateTimeTz;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string date time representation that can be parsed")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
DateTimeTz::from_str(s).or(Err(E::custom(
"string is not a parsable datetime representation".to_owned(),
)))
}
}
impl Serialize for DateTimeTz {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for DateTimeTz {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(DateTimeTzVisitor)
}
}
#[cfg(test)]
mod test {
extern crate serde_json;
use super::*;
use chrono::TimeZone;
use chrono_tz::America::Phoenix;
use chrono_tz::Etc::UTC;
use chrono_tz::US::{Arizona, Central};
use std::str::FromStr;
#[test]
fn it_creates_timestamp_with_z() {
let t = DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap());
assert_eq!(t.to_string(), "2019-05-15T12:00:00Z");
}
#[test]
fn it_parses_utc_rfc3339_z() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00Z").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap())
);
}
#[test]
fn it_parses_rfc3339_with_offset() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00-06:00").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 18, 0, 0).unwrap())
);
}
#[test]
fn it_parses_rfc3339_with_tz() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z US/Arizona").unwrap();
assert_eq!(
t,
DateTimeTz(UTC.with_ymd_and_hms(2019, 6, 15, 19, 0, 0).unwrap())
);
assert_eq!(
t,
DateTimeTz(Arizona.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
);
assert_eq!(
t,
DateTimeTz(Central.with_ymd_and_hms(2019, 6, 15, 14, 0, 0).unwrap())
);
assert_eq!(t.to_string(), "2019-06-15T19:00:00Z US/Arizona");
}
#[derive(Serialize)]
struct DemoStruct {
id: String,
dt: DateTimeTz,
}
// I used Arizona here specifically because large parts of Arizona do not honor DST, and so
// that adds in more ambiguity of the -0700 offset with Pacific time.
#[test]
fn it_json_serializes() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z America/Phoenix").unwrap();
assert_eq!(
serde_json::to_string(&t).unwrap(),
"\"2019-06-15T19:00:00Z America/Phoenix\""
);
let demo = DemoStruct {
id: String::from("abcdefg"),
dt: t,
};
assert_eq!(
serde_json::to_string(&demo).unwrap(),
"{\"id\":\"abcdefg\",\"dt\":\"2019-06-15T19:00:00Z America/Phoenix\"}"
);
}
#[test]
fn it_json_parses() {
let t =
serde_json::from_str::<DateTimeTz>("\"2019-06-15T19:00:00Z America/Phoenix\"").unwrap();
assert_eq!(
t,
DateTimeTz(Phoenix.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
);
}
}

View File

@ -71,11 +71,9 @@ extern crate thiserror;
extern crate uuid;
mod criteria;
mod date_time_tz;
mod series;
mod types;
pub use criteria::*;
pub use date_time_tz::DateTimeTz;
pub use series::Series;
pub use types::{EmseriesReadError, EmseriesWriteError, Recordable, Timestamp, UniqueId};
pub use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable, Timestamp};

View File

@ -10,21 +10,55 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate serde;
extern crate serde_json;
extern crate uuid;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, LineWriter, Write};
use std::iter::Iterator;
use criteria::Criteria;
use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId};
use crate::criteria::Criteria;
use crate::types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable};
// A RecordOnDisk, a private data structure, is useful for handling all of the on-disk
// representations of a record. Unlike [Record], this one can accept an empty data value to
// represent that the data may have been deleted. This is not made public because, so far as the
// user is concerned, any record in the system must have data associated with it.
#[derive(Clone, Deserialize, Serialize)]
struct RecordOnDisk<T: Clone + Recordable> {
id: RecordId,
data: Option<T>,
}
/*
impl<T> FromStr for RecordOnDisk<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
}
}
*/
impl<T: Clone + Recordable> TryFrom<RecordOnDisk<T>> for Record<T> {
type Error = EmseriesReadError;
fn try_from(disk_record: RecordOnDisk<T>) -> Result<Self, Self::Error> {
match disk_record.data {
Some(data) => Ok(Record {
id: disk_record.id,
data,
}),
None => Err(Self::Error::RecordDeleted(disk_record.id)),
}
}
}
/// An open time series database.
///
@ -33,7 +67,7 @@ use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId}
pub struct Series<T: Clone + Recordable + DeserializeOwned + Serialize> {
//path: String,
writer: LineWriter<File>,
records: HashMap<UniqueId, T>,
records: HashMap<RecordId, Record<T>>,
}
impl<T> Series<T>
@ -42,7 +76,7 @@ where
{
/// Open a time series database at the specified path. `path` is the full path and filename for
/// the database.
pub fn open(path: &str) -> Result<Series<T>, EmseriesReadError> {
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Series<T>, EmseriesReadError> {
let f = OpenOptions::new()
.read(true)
.append(true)
@ -62,20 +96,18 @@ where
}
/// Load a file and return all of the records in it.
fn load_file(f: &File) -> Result<HashMap<UniqueId, T>, EmseriesReadError> {
let mut records: HashMap<UniqueId, T> = HashMap::new();
fn load_file(f: &File) -> Result<HashMap<RecordId, Record<T>>, EmseriesReadError> {
let mut records: HashMap<RecordId, Record<T>> = HashMap::new();
let reader = BufReader::new(f);
for line in reader.lines() {
match line {
Ok(line_) => {
/* Can't create a JSONParseError because I can't actually create the underlying error.
fail_point!("parse-line", Err(Error::JSONParseError()))
*/
match line_.parse::<Record<T>>() {
Ok(record) => match record.data {
Some(val) => records.insert(record.id.clone(), val),
None => records.remove(&record.id.clone()),
},
match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref())
.map_err(EmseriesReadError::JSONParseError)
.and_then(Record::try_from)
{
Ok(record) => records.insert(record.id, record.clone()),
Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),
Err(err) => return Err(err),
};
}
@ -87,18 +119,20 @@ where
/// Put a new record into the database. A unique id will be assigned to the record and
/// returned.
pub fn put(&mut self, entry: T) -> Result<UniqueId, EmseriesWriteError> {
let uuid = UniqueId::default();
self.update(uuid.clone(), entry).map(|_| uuid)
pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> {
let id = RecordId::default();
let record = Record { id, data: entry };
self.update(record)?;
Ok(id)
}
/// Update an existing record. The `UniqueId` of the record passed into this function must match
/// the `UniqueId` of a record already in the database.
pub fn update(&mut self, uuid: UniqueId, entry: T) -> Result<(), EmseriesWriteError> {
self.records.insert(uuid.clone(), entry.clone());
let write_res = match serde_json::to_string(&Record {
id: uuid,
data: Some(entry),
/// Update an existing record. The [RecordId] of the record passed into this function must match
/// the [RecordId] of a record already in the database.
pub fn update(&mut self, record: Record<T>) -> Result<(), EmseriesWriteError> {
self.records.insert(record.id, record.clone());
let write_res = match serde_json::to_string(&RecordOnDisk {
id: record.id,
data: Some(record.data),
}) {
Ok(rec_str) => self
.writer
@ -118,14 +152,14 @@ where
/// Future note: while this deletes a record from the view, it only adds an entry to the
/// database that indicates `data: null`. If record histories ever become important, the record
/// and its entire history (including this delete) will still be available.
pub fn delete(&mut self, uuid: &UniqueId) -> Result<(), EmseriesWriteError> {
pub fn delete(&mut self, uuid: &RecordId) -> Result<(), EmseriesWriteError> {
if !self.records.contains_key(uuid) {
return Ok(());
};
self.records.remove(uuid);
let rec: Record<T> = Record {
id: uuid.clone(),
let rec: RecordOnDisk<T> = RecordOnDisk {
id: *uuid,
data: None,
};
match serde_json::to_string(&rec) {
@ -138,8 +172,8 @@ where
}
/// Get all of the records in the database.
pub fn records(&self) -> impl Iterator<Item = (&UniqueId, &T)> {
self.records.iter()
pub fn records(&self) -> impl Iterator<Item = &Record<T>> {
self.records.values()
}
/* The point of having Search is so that a lot of internal optimizations can happen once the
@ -148,29 +182,29 @@ where
pub fn search<'s>(
&'s self,
criteria: impl Criteria + 's,
) -> impl Iterator<Item = (&'s UniqueId, &'s T)> + 's {
self.records().filter(move |&tr| criteria.apply(tr.1))
) -> impl Iterator<Item = &'s Record<T>> + 's {
self.records().filter(move |&tr| criteria.apply(&tr.data))
}
/// Perform a search and sort the resulting records based on the comparison.
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<(&UniqueId, &T)>
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<&'s Record<T>>
where
C: Criteria + 's,
CMP: FnMut(&(&UniqueId, &T), &(&UniqueId, &T)) -> Ordering,
CMP: FnMut(&&Record<T>, &&Record<T>) -> Ordering,
{
let search_iter = self.search(criteria);
let mut records: Vec<(&UniqueId, &T)> = search_iter.collect();
let mut records: Vec<&Record<T>> = search_iter.collect();
records.sort_by(compare);
records
}
/// Get an exact record from the database based on unique id.
pub fn get(&self, uuid: &UniqueId) -> Option<T> {
pub fn get(&self, uuid: &RecordId) -> Option<Record<T>> {
self.records.get(uuid).cloned()
}
/*
pub fn remove(&self, uuid: UniqueId) -> Result<(), EmseriesError> {
pub fn remove(&self, uuid: RecordId) -> Result<(), EmseriesError> {
unimplemented!()
}
*/

View File

@ -10,10 +10,7 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use chrono::NaiveDate;
use date_time_tz::DateTimeTz;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use chrono::{DateTime, FixedOffset, NaiveDate};
use std::{cmp::Ordering, fmt, io, str};
use thiserror::Error;
use uuid::Uuid;
@ -28,6 +25,9 @@ pub enum EmseriesReadError {
#[error("Error parsing JSON: {0}")]
JSONParseError(serde_json::error::Error),
#[error("Record was deleted")]
RecordDeleted(RecordId),
/// Indicates a general IO error
#[error("IO Error: {0}")]
IOError(io::Error),
@ -44,17 +44,47 @@ pub enum EmseriesWriteError {
JSONWriteError(serde_json::error::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[derive(Debug, Clone, PartialEq, Eq)]
/// A Timestamp, stored with reference to human reckoning. This could be either a Naive Date or a
/// date and a time with a timezone. The idea of the "human reckoning" is that, no matter what
/// timezone the record was created in, we want to group things based on the date that the human
/// was perceiving at the time it was recorded.
pub enum Timestamp {
DateTime(DateTimeTz),
DateTime(DateTime<FixedOffset>),
Date(NaiveDate),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TimestampJS {
DateTime(String),
Date(String),
}
impl From<Timestamp> for TimestampJS {
fn from(s: Timestamp) -> TimestampJS {
match s {
Timestamp::DateTime(ts) => TimestampJS::DateTime(ts.to_rfc3339()),
Timestamp::Date(ts) => TimestampJS::Date(ts.to_string()),
}
}
}
impl From<TimestampJS> for Timestamp {
fn from(s: TimestampJS) -> Timestamp {
match s {
TimestampJS::DateTime(ts) => {
Timestamp::DateTime(DateTime::parse_from_rfc3339(&ts).unwrap())
}
TimestampJS::Date(ts) => Timestamp::Date(ts.parse::<NaiveDate>().unwrap()),
}
}
}
impl str::FromStr for Timestamp {
type Err = chrono::ParseError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
DateTimeTz::from_str(line)
DateTime::parse_from_rfc3339(line)
.map(Timestamp::DateTime)
.or(NaiveDate::from_str(line).map(Timestamp::Date))
}
@ -70,25 +100,13 @@ impl Ord for Timestamp {
fn cmp(&self, other: &Timestamp) -> Ordering {
match (self, other) {
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(dt2),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.0.date_naive().cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.0.date_naive()),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive().cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.date_naive()),
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => dt1.cmp(dt2),
}
}
}
impl From<DateTimeTz> for Timestamp {
fn from(d: DateTimeTz) -> Self {
Self::DateTime(d)
}
}
impl From<NaiveDate> for Timestamp {
fn from(d: NaiveDate) -> Self {
Self::Date(d)
}
}
/// Any element to be put into the database needs to be Recordable. This is the common API that
/// will aid in searching and later in indexing records.
pub trait Recordable {
@ -102,75 +120,88 @@ pub trait Recordable {
/// Uniquely identifies a record.
///
/// This is a wrapper around a basic uuid with some extra convenience methods.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct UniqueId(Uuid);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct RecordId(Uuid);
impl Default for UniqueId {
impl Default for RecordId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
impl str::FromStr for UniqueId {
impl str::FromStr for RecordId {
type Err = EmseriesReadError;
/// Parse a UniqueId from a string. Raise UUIDParseError if the parsing fails.
/// Parse a RecordId from a string. Raise UUIDParseError if the parsing fails.
fn from_str(val: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(val)
.map(UniqueId)
.map(RecordId)
.map_err(EmseriesReadError::UUIDParseError)
}
}
impl fmt::Display for UniqueId {
impl fmt::Display for RecordId {
/// Convert to a hyphenated string
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.0.to_hyphenated())
}
}
/// Every record contains a unique ID and then the primary data, which itself must implementd the
/// Recordable trait.
#[derive(Clone, Deserialize, Serialize)]
/// A record represents data that actually exists in the database. Users cannot make the record
/// directly, as the database will create them.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Record<T: Clone + Recordable> {
pub id: UniqueId,
pub data: Option<T>,
pub id: RecordId,
pub data: T,
}
impl<T> str::FromStr for Record<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
impl<T: Clone + Recordable> Record<T> {
pub fn date(&self) -> NaiveDate {
match self.data.timestamp() {
Timestamp::DateTime(dt) => dt.date_naive(),
Timestamp::Date(dt) => dt,
}
}
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
pub fn timestamp(&self) -> Timestamp {
self.data.timestamp()
}
pub fn map<Map, U>(self, map: Map) -> Record<U>
where
Map: Fn(T) -> U,
U: Clone + Recordable,
{
Record {
id: self.id,
data: map(self.data),
}
}
}
#[cfg(test)]
mod test {
extern crate dimensioned;
extern crate serde_json;
use self::dimensioned::si::{Kilogram, KG};
use super::*;
use chrono::TimeZone;
use chrono_tz::{Etc::UTC, US::Central};
use date_time_tz::DateTimeTz;
use chrono_tz::Etc::UTC;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Weight(Kilogram<f64>);
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct WeightRecord {
pub date: Timestamp,
pub date: NaiveDate,
pub weight: Weight,
}
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.clone()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
@ -179,12 +210,14 @@ mod test {
}
#[test]
fn timestamp_parses_datetimetz_without_timezone() {
fn timestamp_parses_utc_time() {
assert_eq!(
"2003-11-10T06:00:00Z".parse::<Timestamp>().unwrap(),
Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
)),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
),
);
}
@ -196,9 +229,10 @@ mod test {
);
}
#[test]
/*
#[ignore]
fn v_alpha_serialization() {
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109,\"date\":\"2003-11-10T06:00:00.000000000000Z\"},\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109},\"date\":\"2003-11-10\",\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
let rec: Record<WeightRecord> = WEIGHT_ENTRY
.parse()
@ -209,66 +243,64 @@ mod test {
);
assert_eq!(
rec.data,
Some(WeightRecord {
date: Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
)),
WeightRecord {
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.79109 * KG),
})
}
);
}
*/
#[test]
fn serialization_output() {
let rec = WeightRecord {
date: Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
)),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
let rec2 = WeightRecord {
date: Timestamp::DateTime(
Central
.with_ymd_and_hms(2003, 11, 10, 0, 0, 0)
.unwrap()
.into(),
),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec2).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z US/Central\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
}
#[test]
fn two_datetimes_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
));
let time2 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0).unwrap(),
));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
assert!(time1 < time2);
}
#[test]
fn two_dates_can_be_compared() {
let time1 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
let time1: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
let time2: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2);
}
#[test]
fn datetime_and_date_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2)
}

View File

@ -20,7 +20,7 @@ extern crate emseries;
#[cfg(test)]
mod test {
use chrono::prelude::*;
use chrono::{prelude::*};
use chrono_tz::Etc::UTC;
use dimensioned::si::{Kilogram, Meter, Second, M, S};
@ -34,7 +34,7 @@ mod test {
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
struct BikeTrip {
datetime: DateTimeTz,
datetime: DateTime<FixedOffset>,
distance: Distance,
duration: Duration,
comments: String,
@ -42,7 +42,7 @@ mod test {
impl Recordable for BikeTrip {
fn timestamp(&self) -> Timestamp {
self.datetime.clone().into()
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
Vec::new()
@ -52,31 +52,46 @@ mod test {
fn mk_trips() -> [BikeTrip; 5] {
[
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(58741.055 * M),
duration: Duration(11040.0 * S),
comments: String::from("long time ago"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(17702.0 * M),
duration: Duration(2880.0 * S),
comments: String::from("day 2"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(41842.945 * M),
duration: Duration(7020.0 * S),
comments: String::from("Do Some Distance!"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(34600.895 * M),
duration: Duration(5580.0 * S),
comments: String::from("I did a lot of distance back then"),
},
BikeTrip {
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()),
datetime: UTC
.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(6437.376 * M),
duration: Duration(960.0 * S),
comments: String::from("day 5"),
@ -84,7 +99,7 @@ mod test {
]
}
fn run_test<T>(test: T) -> ()
fn run_test<T>(test: T)
where
T: FnOnce(tempfile::TempPath),
{
@ -93,14 +108,14 @@ mod test {
test(tmp_path);
}
fn run<T>(test: T) -> ()
fn run<T>(test: T)
where
T: FnOnce(Series<BikeTrip>),
{
let tmp_file = tempfile::NamedTempFile::new().expect("temporary path created");
let tmp_path = tmp_file.into_temp_path();
let ts: Series<BikeTrip> = Series::open(&tmp_path.to_string_lossy())
.expect("the time series should open correctly");
let ts: Series<BikeTrip> =
Series::open(&tmp_path).expect("the time series should open correctly");
test(ts);
}
@ -122,11 +137,15 @@ mod test {
Some(tr) => {
assert_eq!(
tr.timestamp(),
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()).into()
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
)
);
assert_eq!(tr.duration, Duration(11040.0 * S));
assert_eq!(tr.comments, String::from("long time ago"));
assert_eq!(tr, trips[0]);
assert_eq!(tr.data.duration, Duration(11040.0 * S));
assert_eq!(tr.data.comments, String::from("long time ago"));
assert_eq!(tr.data, trips[0]);
}
}
})
@ -136,20 +155,22 @@ mod test {
pub fn can_search_for_an_entry_with_exact_time() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
))
let v: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(v.len(), 1);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(v[0].data, trips[1]);
})
}
@ -157,26 +178,34 @@ mod test {
pub fn can_get_entries_in_time_range() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
})
}
@ -186,8 +215,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
@ -195,21 +224,29 @@ mod test {
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
}
})
}
@ -220,8 +257,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=2] {
ts.put(trip.clone()).expect("expect a successful put");
@ -229,41 +266,57 @@ mod test {
}
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 2);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
ts.put(trips[3].clone()).expect("expect a successful put");
ts.put(trips[4].clone()).expect("expect a successful put");
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 4);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(*v[3].1, trips[4]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
assert_eq!(v[3].data, trips[4]);
}
})
}
@ -273,8 +326,8 @@ mod test {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -283,9 +336,8 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id.clone(), trip)
.expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
@ -293,12 +345,12 @@ mod test {
None => assert!(false, "record not found"),
Some(trip) => {
assert_eq!(
trip.datetime,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
trip.data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()
);
assert_eq!(trip.distance, Distance(50000.0 * M));
assert_eq!(trip.duration, Duration(7020.0 * S));
assert_eq!(trip.comments, String::from("Do Some Distance!"));
assert_eq!(trip.data.distance, Distance(50000.0 * M));
assert_eq!(trip.data.duration, Duration(7020.0 * S));
assert_eq!(trip.data.comments, String::from("Do Some Distance!"));
}
}
})
@ -310,8 +362,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -320,32 +372,36 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id, trip).expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trips: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let trips: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(trips.len(), 3);
let trips: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()).into(),
))
let trips: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(trips.len(), 1);
assert_eq!(
trips[0].1.datetime,
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
trips[0].data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
);
assert_eq!(trips[0].1.distance, Distance(50000.0 * M));
assert_eq!(trips[0].1.duration, Duration(7020.0 * S));
assert_eq!(trips[0].1.comments, String::from("Do Some Distance!"));
assert_eq!(trips[0].data.distance, Distance(50000.0 * M));
assert_eq!(trips[0].data.duration, Duration(7020.0 * S));
assert_eq!(trips[0].data.comments, String::from("Do Some Distance!"));
}
})
}
@ -356,21 +412,21 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trip_id = ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
ts.put(trips[2].clone()).expect("expect a successful put");
ts.delete(&trip_id).expect("successful delete");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
})
@ -387,7 +443,7 @@ mod test {
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.into()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {

View File

@ -1,6 +1,6 @@
[package]
name = "file-service"
version = "0.1.2"
version = "0.2.0"
authors = ["savanni@luminescent-dreams.com"]
edition = "2018"
@ -14,13 +14,10 @@ path = "src/lib.rs"
name = "file-service"
path = "src/main.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
[target.auth-cli.dependencies]
[dependencies]
authdb = { path = "../authdb/" }
base64ct = { version = "1", features = [ "alloc" ] }
build_html = { version = "2" }
bytes = { version = "1" }
@ -38,9 +35,8 @@ mime_guess = "2.0.3"
pretty_env_logger = { version = "0.5" }
serde_json = "*"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
thiserror = "1.0.20"
sha2 = { version = "0.10" }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
warp = { version = "0.3" }

11
file-service/Taskfile.yml Normal file
View File

@ -0,0 +1,11 @@
version: '3'
tasks:
build:
cmds:
- cargo build
lint:
cmds:
- cargo watch -x clippy

View File

@ -87,7 +87,7 @@ pub async fn handle_auth(
app: App,
form: HashMap<String, String>,
) -> Result<http::Response<String>, Error> {
match form.get("token") {
match form.get("password") {
Some(token) => match app.authenticate(AuthToken::from(token.clone())).await {
Ok(Some(session_token)) => Response::builder()
.header("location", "/")
@ -134,6 +134,25 @@ pub async fn handle_upload(
}
}
pub async fn handle_delete(
app: App,
token: SessionToken,
id: FileId,
) -> Result<http::Response<String>, Error> {
match app.validate_session(token).await {
Ok(Some(_)) => match app.delete_file(id).await {
Ok(_) => Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Err(_) => unimplemented!(),
},
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("".to_owned()),
}
}
fn serve_file<F>(
info: FileInfo,
file: F,

View File

@ -1,3 +1,5 @@
use std::fmt::Display;
use build_html::{self, Html, HtmlContainer};
#[derive(Clone, Debug, Default)]
@ -23,13 +25,14 @@ impl FromIterator<(&str, &str)> for Attributes {
}
*/
impl ToString for Attributes {
fn to_string(&self) -> String {
self.0
impl Display for Attributes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let result = self.0
.iter()
.map(|(key, value)| format!("{}=\"{}\"", key, value))
.collect::<Vec<String>>()
.join(" ")
.join(" ");
write!(f, "{}", result)
}
}
@ -74,7 +77,7 @@ impl Html for Form {
None => "".to_owned(),
};
format!(
"<form action=\"{path}\" method=\"{method}\" {encoding}\n{elements}\n</form>\n",
"<form action=\"{path}\" method=\"{method}\" {encoding}>\n{elements}\n</form>\n",
path = self.path,
method = self.method,
encoding = encoding,
@ -137,11 +140,6 @@ impl Input {
self
}
pub fn with_value(mut self, val: &str) -> Self {
self.value = Some(val.to_owned());
self
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
@ -156,31 +154,6 @@ impl Input {
}
}
#[derive(Clone, Debug)]
pub struct Label {
target: String,
text: String,
}
impl Label {
pub fn new(target: &str, text: &str) -> Self {
Self {
target: target.to_owned(),
text: text.to_owned(),
}
}
}
impl Html for Label {
fn to_html_string(&self) -> String {
format!(
"<label for=\"{target}\">{text}</label>",
target = self.target,
text = self.text
)
}
}
#[derive(Clone, Debug)]
pub struct Button {
ty: Option<String>,
@ -232,96 +205,7 @@ impl Html for Button {
"<button {ty} {name} {attrs}>{label}</button>",
name = name,
label = self.label,
attrs = self.attributes.to_string()
attrs = self.attributes
)
}
}
#[derive(Clone, Debug)]
pub struct Image {
path: String,
attributes: Attributes,
}
impl Image {
pub fn new(path: &str) -> Self {
Self {
path: path.to_owned(),
attributes: Attributes::default(),
}
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
self.attributes = Attributes(
values
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
.collect::<Vec<(String, String)>>(),
);
self
}
}
impl Html for Image {
fn to_html_string(&self) -> String {
format!(
"<img src={path} {attrs} />",
path = self.path,
attrs = self.attributes.to_string()
)
}
}
#[derive(Debug)]
pub struct UnorderedList {
children: Vec<String>,
attributes: Attributes,
}
impl UnorderedList {
pub fn new() -> Self {
Self {
children: vec![],
attributes: Attributes::default(),
}
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
self.attributes = Attributes(
values
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
.collect::<Vec<(String, String)>>(),
);
self
}
}
impl Html for UnorderedList {
fn to_html_string(&self) -> String {
let children = self
.children
.iter()
.map(|item| format!("<li>{}</li>", item.to_html_string()))
.collect::<Vec<String>>();
format!(
"<ul {attrs}>
{children}
</ul>",
attrs = self.attributes.to_string(),
children = children.join("\n")
)
}
}
impl HtmlContainer for UnorderedList {
fn add_html<H: Html>(&mut self, html: H) {
self.children.push(html.to_html_string())
}
}

View File

@ -1,6 +1,5 @@
mod store;
pub use store::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};

View File

@ -1,7 +1,7 @@
extern crate log;
use cookie::Cookie;
use handlers::{file, handle_auth, handle_css, handle_upload, thumbnail};
use handlers::{file, handle_auth, handle_css, handle_delete, handle_upload, thumbnail};
use std::{
collections::{HashMap, HashSet},
convert::Infallible,
@ -18,9 +18,10 @@ mod pages;
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
pub use file_service::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
use authdb::{AuthDB, AuthError, AuthToken, SessionToken, Username};
use file_service::{
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};
pub use handlers::handle_index;
@ -64,6 +65,11 @@ impl App {
) -> Result<FileHandle, WriteFileError> {
self.store.write().await.add_file(filename, content)
}
pub async fn delete_file(&self, id: FileId) -> Result<(), DeleteFileError> {
self.store.write().await.delete_file(&id)?;
Ok(())
}
}
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
@ -134,6 +140,12 @@ pub async fn main() {
.and(warp::multipart::form().max_length(MAX_UPLOAD))
.then(handle_upload);
let delete_via_form = warp::path!("delete" / String)
.and(warp::post())
.and(with_app(app.clone()))
.and(with_session())
.then(|id, app, token| handle_delete(app, token, FileId::from(id)));
let thumbnail = warp::path!(String / "tn")
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
@ -150,6 +162,7 @@ pub async fn main() {
root.or(styles)
.or(auth)
.or(upload_via_form)
.or(delete_via_form)
.or(thumbnail)
.or(file)
.with(log),

View File

@ -4,13 +4,17 @@ use file_service::{FileHandle, FileInfo, ReadFileError};
pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
build_html::HtmlPage::new()
.with_title("Authentication")
.with_title("Sign In")
.with_stylesheet("/css")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-page")])
.with_container(
Container::new(ContainerType::Div)
.with_container(auth_form()),
)
}
fn auth_form() -> Container {
Container::default()
.with_attributes([("class", "card authentication-form")])
.with_html(
Form::new()
@ -18,18 +22,17 @@ pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
.with_method("post")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-form__label")])
.with_html(Label::new("for-token-input", "Authentication")),
)
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-form__input")])
.with_html(
Input::new("token", "token")
Input::new("password", "password")
.with_id("for-token-input")
.with_attributes([("size", "50")]),
),
),
.with_attributes([
("size", "50"),
("class", "authentication-form__input"),
]),
)
.with_html(
Button::new("Sign In")
.with_attributes([("class", "authentication-form__button")]),
),
),
)
@ -87,24 +90,24 @@ pub fn thumbnail(info: &FileInfo) -> Container {
.with_html(
Container::new(ContainerType::Div).with_link(
format!("/{}", *info.id),
Image::new(&format!("{}/tn", *info.id))
.with_attributes([("class", "thumbnail__image")])
Container::default()
.with_attributes([("class", "thumbnail")])
.with_image(format!("{}/tn", *info.id), "test data")
.to_html_string(),
),
)
.with_html(
Container::new(ContainerType::Div)
.with_html(
UnorderedList::new()
Container::new(ContainerType::UnorderedList)
.with_attributes(vec![("class", "thumbnail__metadata")])
.with_html(info.name.clone())
.with_html(format!("{}", info.created.format("%Y-%m-%d"))),
)
.with_html(
Form::new()
.with_path(&format!("/{}", *info.id))
.with_path(&format!("/delete/{}", *info.id))
.with_method("post")
.with_html(Input::new("hidden", "_method").with_value("delete"))
.with_html(Button::new("Delete")),
),
)

View File

@ -1,13 +1,6 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::{collections::HashSet, ops::Deref, path::PathBuf};
use thiserror::Error;
use uuid::Uuid;
mod filehandle;
mod fileinfo;
@ -53,9 +46,6 @@ pub enum ReadFileError {
#[error("permission denied")]
PermissionDenied,
#[error("invalid path")]
InvalidPath,
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
@ -64,132 +54,32 @@ pub enum ReadFileError {
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
pub enum DeleteFileError {
#[error("file not found")]
FileNotFound(PathBuf),
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("metadata path is not a file")]
NotAFile,
#[error("database failed")]
SqlError(sqlx::Error),
#[error("cannot read metadata")]
PermissionDenied,
#[error("invalid metadata path")]
MetadataParseError(serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
impl From<ReadFileError> for DeleteFileError {
fn from(err: ReadFileError) -> Self {
match err {
ReadFileError::FileNotFound(path) => DeleteFileError::FileNotFound(path),
ReadFileError::NotAFile => DeleteFileError::NotAFile,
ReadFileError::PermissionDenied => DeleteFileError::PermissionDenied,
ReadFileError::JSONError(err) => DeleteFileError::MetadataParseError(err),
ReadFileError::IOError(err) => DeleteFileError::IOError(err),
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
@ -228,106 +118,21 @@ impl Deref for FileId {
}
}
/*
pub trait FileRoot {
fn root(&self) -> PathBuf;
}
*/
pub struct Context(PathBuf);
// pub struct Context(PathBuf);
/*
impl FileRoot for Context {
fn root(&self) -> PathBuf {
self.0.clone()
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.is_empty() {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.is_empty() {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
*/
pub struct Store {
files_root: PathBuf,
@ -369,7 +174,7 @@ impl Store {
FileHandle::load(id, &self.files_root)
}
pub fn delete_file(&mut self, id: &FileId) -> Result<(), WriteFileError> {
pub fn delete_file(&mut self, id: &FileId) -> Result<(), DeleteFileError> {
let handle = FileHandle::load(id, &self.files_root)?;
handle.delete();
Ok(())
@ -466,74 +271,3 @@ mod test {
});
}
}
#[cfg(test)]
mod authdb_test {
use super::*;
use cool_asserts::assert_matches;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.authenticate(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.authenticate(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.authenticate(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.validate_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -29,6 +29,7 @@ body {
justify-content: center;
align-items: center;
height: 200px;
margin: 8px;
}
.authentication-form {
@ -133,6 +134,16 @@ body {
.authentication-form {
width: 100%;
display: flex;
flex-direction: column;
}
.authentication-form__input {
font-size: x-large;
}
.authentication-form__button {
font-size: x-large;
}
.upload-form__selector {

View File

@ -0,0 +1,26 @@
[package]
name = "fitnesstrax"
version = "0.6.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2.1" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" }
chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] }
emseries = { path = "../../emseries" }
ft-core = { path = "../core" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
thiserror = { version = "1.0" }
tokio = { version = "1.34", features = [ "full" ] }
[build-dependencies]
glib-build-tools = "0.18"

7
fitnesstrax/app/build.rs Normal file
View File

@ -0,0 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
&["resources"],
"gresources.xml",
"com.luminescent-dreams.fitnesstrax.gresource",
);
}

12
fitnesstrax/app/dist.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
mkdir -p dist
cp ../../target/release/fitnesstrax dist
cp resources/com.luminescent-dreams.fitnesstrax.gschema.xml resources/fitnesstrax.desktop dist
strip dist/fitnesstrax
tar -czf fitnesstrax-${VERSION}.tgz dist/

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/">
<file>style.css</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">cycling-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">running-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions/">
<file preprocess="xml-stripblanks">walking-symbolic.svg</file>
</gresource>
</gresources>

View File

@ -0,0 +1,14 @@
{ gtkNativeInputs }:
attrs: {
nativeBuildInputs = gtkNativeInputs;
postInstall = ''
install -Dt $out/share/applications resources/fitnesstrax.desktop
install -Dt $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas resources/com.luminescent-dreams.fitnesstrax.gschema.xml
glib-compile-schemas $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas
'';
preFixup = ''
gappsWrapperArgs+=(
--prefix XDG_DATA_DIRS : $out/gsettings-schemas/${attrs.crateName}-${attrs.version}
)
'';
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax.dev" path="/com/luminescent-dreams/fitnesstrax/dev/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax" path="/com/luminescent-dreams/fitnesstrax/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 2 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m 0 0"/><path d="m 4.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><path d="m 8.992188 13.007812 v -3.003906 c 0 -0.359375 -0.1875 -0.6875 -0.5 -0.867187 l -2.558594 -1.476563 l 0.363281 1.363282 l 1.671875 -2.890626 l -1.367188 0.363282 l 0.910157 0.527344 l -0.40625 -0.4375 c 0.773437 1.621093 1.96875 1.933593 1.96875 1.933593 s 0.578125 0.242188 1.9375 0.429688 c 0.546875 0.074219 1.050781 -0.304688 1.128906 -0.851563 c 0.074219 -0.550781 -0.308594 -1.054687 -0.855469 -1.128906 c -1.179687 -0.164062 -1.601562 -0.355469 -1.601562 -0.355469 s -0.425782 -0.164062 -0.769532 -0.886719 c -0.089843 -0.183593 -0.226562 -0.335937 -0.402343 -0.4375 l -0.910157 -0.523437 c -0.476562 -0.277344 -1.089843 -0.113281 -1.363281 0.367187 l -1.671875 2.890626 c -0.277344 0.480468 -0.113281 1.089843 0.367188 1.367187 l 2.558594 1.480469 l -0.5 -0.867188 v 3.003906 c 0 0.550782 0.449218 1 1 1 c 0.554687 0 1 -0.449218 1 -1 z m 0 0"/><path d="m 14.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,5 @@
[Desktop Entry]
Version=0.2
Type=Application
Name=FitnessTrax
Exec=fitnesstrax

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 8.5 0 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m -2.5 4 c -0.117188 0 -0.230469 0.027344 -0.335938 0.082031 l -2 1 c -0.144531 0.070313 -0.261718 0.1875 -0.332031 0.332031 l -1 2 c -0.1875 0.371094 -0.039062 0.820313 0.332031 1.007813 c 0.371094 0.183594 0.820313 0.035156 1.003907 -0.335937 l 0.890625 -1.777344 l 1.5625 -0.773438 c -0.042969 0.074219 -0.726563 2.835938 -0.726563 2.835938 c -0.230469 0.949218 0.398438 1.523437 0.398438 1.523437 l 3.351562 2.703125 l 0.90625 2.71875 c 0.175781 0.523438 0.742188 0.808594 1.265625 0.632813 c 0.523438 -0.175781 0.808594 -0.742188 0.632813 -1.265625 l -1 -3 c -0.0625 -0.183594 -0.171875 -0.34375 -0.324219 -0.464844 l -2 -1.597656 l 0.679688 -2.714844 l 0.25 0.625 c 0.085937 0.222656 0.28125 0.390625 0.515624 0.449219 l 2 0.5 c 0.402344 0.097656 0.808594 -0.144531 0.910157 -0.546875 c 0.097656 -0.40625 -0.144531 -0.8125 -0.546875 -0.910156 l -1.628906 -0.40625 l -0.855469 -2.144532 c -0.117188 -0.285156 -0.390625 -0.472656 -0.699219 -0.472656 z m -1.164062 6.328125 l -0.710938 2.128906 l -1.832031 1.835938 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 2 -2 c 0.109375 -0.109375 0.191407 -0.242187 0.242188 -0.390625 l 0.542969 -1.628906 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,85 @@
.welcome {
margin: 64px;
}
.welcome__title {
font-size: x-large;
padding: 8px;
}
.welcome__content {
padding: 8px;
}
.historical {
margin: 32px;
border-radius: 8px;
}
.date-range-picker {
margin-bottom: 16px;
}
/*
.date-range-picker > box:not(:last-child) {
margin-bottom: 8px;
}
*/
.date-range-picker__date-field {
margin: 8px;
}
.date-range-picker__search-button {
margin: 8px;
}
.date-range-picker__range-button {
margin: 8px;
}
.date-field__year {
margin: 0px 4px 0px 0px;
}
.date-field__month {
margin: 0px 4px 0px 4px;
}
.date-field__day {
margin: 0px 0px 0px 4px;
}
.day-summary {
padding: 8px;
}
.day-summary > *:not(:last-child) {
margin-bottom: 8px;
}
.day-summary__date {
font-size: x-large;
}
.day-summary__weight {
margin: 4px;
}
.weight-view {
padding: 8px;
margin: 8px;
}
.step-view {
padding: 8px;
margin: 8px;
}
.about__content {
padding: 32px;
}
.about label {
margin-bottom: 16px;
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 1.5 c 0 0.828125 -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 s 1.5 0.671875 1.5 1.5 z m 0 0"/><path d="m 7 4 c -0.550781 0 -1 0.449219 -1 1 v 4 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 l 0.445312 0.449219 l -2.59375 4.328125 c -0.285156 0.476563 -0.132812 1.089844 0.34375 1.375 c 0.472657 0.28125 1.085938 0.128906 1.367188 -0.34375 l 2.34375 -3.902344 l 0.925781 0.929688 l 0.925781 2.773437 c 0.082031 0.25 0.265625 0.460938 0.5 0.578125 c 0.238281 0.121094 0.515625 0.140625 0.765625 0.054688 c 0.25 -0.082031 0.460938 -0.265625 0.578125 -0.5 c 0.121094 -0.238281 0.140625 -0.515625 0.054688 -0.765625 l -1 -3 c -0.050781 -0.148438 -0.132813 -0.28125 -0.242188 -0.390625 l -1.707031 -1.707031 v -4.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 4 c -0.101562 0 -0.207031 0.019531 -0.300781 0.0625 c 0 0 -2.113281 0.847656 -2.199219 2.90625 v 0.03125 v 2.25 c 0 0.414062 0.335938 0.75 0.75 0.75 s 0.75 -0.335938 0.75 -0.75 v -2.21875 c 0.039062 -0.894531 1.050781 -1.449219 1.207031 -1.53125 h 2.332031 l 1.042969 2.085938 c 0.097657 0.195312 0.273438 0.339843 0.488281 0.394531 l 2 0.5 c 0.191407 0.046875 0.394532 0.015625 0.566407 -0.085938 c 0.171875 -0.101562 0.292969 -0.269531 0.34375 -0.460937 c 0.046875 -0.195313 0.015625 -0.398438 -0.085938 -0.570313 c -0.101562 -0.171875 -0.269531 -0.292969 -0.464843 -0.34375 l -1.664063 -0.414062 l -1.097656 -2.191407 c -0.125 -0.253906 -0.382813 -0.414062 -0.667969 -0.414062 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,80 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct AboutWindowPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for AboutWindowPrivate {
const NAME: &'static str = "AboutWindow";
type Type = AboutWindow;
type ParentType = gtk::Window;
}
impl ObjectImpl for AboutWindowPrivate {}
impl WidgetImpl for AboutWindowPrivate {}
impl WindowImpl for AboutWindowPrivate {}
glib::wrapper! {
pub struct AboutWindow(ObjectSubclass<AboutWindowPrivate>) @extends gtk::Window, gtk::Widget;
}
impl Default for AboutWindow {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(600);
s.set_height_request(700);
s.add_css_class("about");
s.set_title(Some("About Fitnesstrax"));
let copyright = gtk::Label::builder()
.label("Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>")
.halign(gtk::Align::Start)
.build();
let gtk_rs_thanks = gtk::Label::builder()
.label("I owe a huge debt of gratitude to the GTK-RS project (https://gtk-rs.org/), which makes it possible for me to write this application to begin with. Further, I owe a particular debt to Julian Hofer and his book, GUI development with Rust and GTK 4 (https://gtk-rs.org/gtk4-rs/stable/latest/book/). Without this book, I would have continued to stumble around writing bad user interfaces with even worse code.")
.halign(gtk::Align::Start).wrap(true)
.build();
let dependencies = gtk::Label::builder()
.label("This application depends on many libraries, most of which are licensed under the BSD-3 or GPL-3 licenses.")
.halign(gtk::Align::Start).wrap(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.css_classes(["about__content"])
.build();
content.append(&copyright);
content.append(&gtk_rs_thanks);
content.append(&dependencies);
let scroller = gtk::ScrolledWindow::builder()
.child(&content)
.hexpand(true)
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
s.set_child(Some(&scroller));
s
}
}

167
fitnesstrax/app/src/app.rs Normal file
View File

@ -0,0 +1,167 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use async_trait::async_trait;
use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord;
use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use thiserror::Error;
use tokio::runtime::Runtime;
#[derive(Debug, Error)]
pub enum AppError {
#[error("no database loaded")]
NoDatabase,
#[error("failed to open the database")]
FailedToOpenDatabase,
#[error("unhandled error")]
Unhandled,
}
#[derive(Debug, Error)]
pub enum ReadError {
#[error("no database loaded")]
NoDatabase,
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("no database loaded")]
NoDatabase,
#[error("unhandled error")]
Unhandled,
}
#[async_trait]
pub trait RecordProvider: Send + Sync {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
}
/// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)]
pub struct App {
runtime: Arc<Runtime>,
database: Arc<RwLock<Option<Series<TraxRecord>>>>,
}
impl App {
pub fn new(db_path: Option<PathBuf>) -> Self {
let database = db_path.map(|path| Series::open(path).unwrap());
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap(),
);
Self {
runtime,
database: Arc::new(RwLock::new(database)),
}
}
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
let db_ref = self.database.clone();
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
}
pub fn database_is_open(&self) -> bool {
self.database.read().unwrap().is_some()
}
}
#[async_trait]
impl RecordProvider for App {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref db) = *db.read().unwrap() {
let records = db
.search(time_range(
Timestamp::Date(start),
true,
Timestamp::Date(end),
true,
))
.cloned()
.collect::<Vec<Record<TraxRecord>>>();
Ok(records)
} else {
Err(ReadError::NoDatabase)
}
})
.await
.unwrap()
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
let id = db.put(record).unwrap();
Ok(id)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
db.update(record).map_err(|_| AppError::Unhandled)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn delete_record(&self, _id: RecordId) -> Result<(), WriteError> {
unimplemented!()
}
}

View File

@ -0,0 +1,215 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::App,
types::DayInterval,
view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
};
use adw::prelude::*;
use chrono::{Duration, Local};
use gio::resources_lookup_data;
use gtk::STYLE_PROVIDER_PRIORITY_USER;
use std::{cell::RefCell, path::PathBuf, rc::Rc};
/// The application window, or the main window, is the main user interface for the app. Almost
/// everything occurs here.
#[derive(Clone)]
pub struct AppWindow {
app: App,
layout: gtk::Box,
current_view: Rc<RefCell<View>>,
settings: gio::Settings,
navigation: adw::NavigationView,
}
impl AppWindow {
/// Construct a new App Window.
///
/// adw_app is an Adwaita application. Application windows need to have access to this, but
/// otherwise we don't use this.
///
/// app is a core [crate::app::App] object which encapsulates all of the basic logic.
pub fn new(
app_id: &str,
resource_path: &str,
adw_app: &adw::Application,
ft_app: App,
) -> AppWindow {
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.width_request(800)
.height_request(746)
.build();
window.connect_destroy(|s| {
let _ = gtk::prelude::WidgetExt::activate_action(s, "app.quit", None);
});
let stylesheet = String::from_utf8(
resources_lookup_data(
&format!("{}style.css", resource_path),
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet must be available in the resources")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
#[allow(deprecated)]
let context = window.style_context();
#[allow(deprecated)]
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let navigation = adw::NavigationView::new();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let initial_view = View::Placeholder(PlaceholderView::default().upcast());
let header_bar = adw::HeaderBar::new();
let main_menu = gio::Menu::new();
main_menu.append(Some("About"), Some("app.about"));
main_menu.append(Some("Quit"), Some("app.quit"));
let main_menu_button = gtk::MenuButton::builder()
.icon_name("open-menu")
.direction(gtk::ArrowType::Down)
.halign(gtk::Align::End)
.menu_model(&main_menu)
.build();
header_bar.pack_end(&main_menu_button);
layout.append(&initial_view.widget());
let nav_layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
nav_layout.append(&header_bar);
nav_layout.append(&layout);
navigation.push(
&adw::NavigationPage::builder()
.can_pop(false)
.title("FitnessTrax")
.child(&nav_layout)
.build(),
);
window.set_content(Some(&navigation));
window.present();
let s = Self {
app: ft_app,
layout,
current_view: Rc::new(RefCell::new(initial_view)),
settings: gio::Settings::new(app_id),
navigation,
};
s.load_records();
s.navigation.connect_popped({
let s = s.clone();
move |_, _| {
if let View::Historical(_) = *s.current_view.borrow() {
s.load_records();
}
}
});
s
}
fn show_welcome_view(&self) {
let view = View::Welcome(WelcomeView::new({
let s = self.clone();
move |path| s.on_apply_config(path)
}));
self.swap_main(view);
}
fn show_historical_view(&self, interval: DayInterval) {
let on_select_day = {
let s = self.clone();
move |date| {
let s = s.clone();
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&adw::HeaderBar::new());
// layout.append(&DayDetailView::new(date, records, s.app.clone()));
layout.append(&DayDetailView::new(view_model));
let page = &adw::NavigationPage::builder()
.title(date.format("%Y-%m-%d").to_string())
.child(&layout)
.build();
s.navigation.push(page);
});
}
};
let view = View::Historical(HistoricalView::new(
self.app.clone(),
interval,
Rc::new(on_select_day),
));
self.swap_main(view);
}
fn load_records(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.database_is_open() {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.show_historical_view(DayInterval { start, end });
} else {
s.show_welcome_view();
}
}
});
}
// Switch views.
//
// This function only replaces the old view with the one which matches the current view state.
// It is responsible for ensuring that the new view goes into the layout in the correct
// position.
fn swap_main(&self, view: View) {
let mut current_widget = self.current_view.borrow_mut();
self.layout.remove(&current_widget.widget());
*current_widget = view;
self.layout.append(&current_widget.widget());
}
#[allow(unused)]
fn on_apply_config(&self, path: PathBuf) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.open_db(path.clone()).await.is_ok() {
let _ = s.settings.set("series-path", path.to_str().unwrap());
s.load_records();
}
}
});
}
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! ActionGroup and related structures
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct ActionGroupPrivate;
#[glib::object_subclass]
impl ObjectSubclass for ActionGroupPrivate {
const NAME: &'static str = "ActionGroup";
type Type = ActionGroup;
type ParentType = gtk::Box;
}
impl ObjectImpl for ActionGroupPrivate {}
impl WidgetImpl for ActionGroupPrivate {}
impl BoxImpl for ActionGroupPrivate {}
glib::wrapper! {
pub struct ActionGroup(ObjectSubclass<ActionGroupPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl ActionGroup {
fn new(builder: ActionGroupBuilder) -> Self {
let s: Self = Object::builder().build();
s.set_orientation(builder.orientation);
let primary_button = builder.primary_action.button();
let secondary_button = builder.secondary_action.map(|action| action.button());
let tertiary_button = builder.tertiary_action.map(|action| action.button());
if let Some(button) = tertiary_button {
s.append(&button);
}
s.set_halign(gtk::Align::End);
if let Some(button) = secondary_button {
s.append(&button);
}
s.append(&primary_button);
s
}
pub fn builder() -> ActionGroupBuilder {
ActionGroupBuilder {
orientation: gtk::Orientation::Horizontal,
primary_action: Action {
label: "Ok".to_owned(),
action: Box::new(|| {}),
},
secondary_action: None,
tertiary_action: None,
}
}
}
struct Action {
label: String,
action: Box<dyn Fn()>,
}
impl Action {
fn button(self) -> gtk::Button {
let button = gtk::Button::builder().label(self.label).build();
button.connect_clicked(move |_| (self.action)());
button
}
}
pub struct ActionGroupBuilder {
orientation: gtk::Orientation,
primary_action: Action,
secondary_action: Option<Action>,
tertiary_action: Option<Action>,
}
impl ActionGroupBuilder {
pub fn orientation(mut self, orientation: gtk::Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn primary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.primary_action = Action {
label: label.to_owned(),
action: Box::new(action),
};
self
}
pub fn secondary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.secondary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn tertiary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.tertiary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn build(self) -> ActionGroup {
ActionGroup::new(self)
}
}

View File

@ -0,0 +1,175 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::{i32_field_builder, TextEntry, month_field_builder}, types::ParseError};
use chrono::{Datelike, Local};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub struct DateFieldPrivate {
date: Rc<RefCell<chrono::NaiveDate>>,
year: TextEntry<i32>,
month: TextEntry<u32>,
day: TextEntry<u32>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateFieldPrivate {
const NAME: &'static str = "DateField";
type Type = DateField;
type ParentType = gtk::Box;
fn new() -> Self {
let date = Rc::new(RefCell::new(Local::now().date_naive()));
let year = i32_field_builder()
.with_value(date.borrow().year())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(year) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_year(year) {
*date = new_date;
}
}
}
})
.with_length(4)
.with_css_classes(vec!["date-field__year".to_owned()]).build();
let month = month_field_builder()
.with_value(date.borrow().month())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(month) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_month(month) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__month".to_owned()])
.build();
/* Modify this so that it enforces the number of days per month */
let day = TextEntry::builder()
.with_placeholder("day".to_owned())
.with_value(date.borrow().day())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let date = date.clone();
move |value| {
if let Some(day) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_day(day) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__day".to_owned()])
.build();
Self {
date,
year,
month,
day,
}
}
}
impl ObjectImpl for DateFieldPrivate {}
impl WidgetImpl for DateFieldPrivate {}
impl BoxImpl for DateFieldPrivate {}
glib::wrapper! {
pub struct DateField(ObjectSubclass<DateFieldPrivate>) @extends gtk::Box, gtk::Widget;
}
/* Render a date in the format 2006 Jan 01. The entire date is editable. When the user moves to one part of the date, it will be erased and replaced with a grey placeholder.
*/
impl DateField {
pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build();
s.add_css_class("date-field");
s.append(&s.imp().year.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().month.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().day.widget());
s.set_date(date);
s
}
pub fn set_date(&self, date: chrono::NaiveDate) {
self.imp().year.set_value(Some(date.year()));
self.imp().month.set_value(Some(date.month()));
self.imp().day.set_value(Some(date.day()));
*self.imp().date.borrow_mut() = date;
}
pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow()
}
/*
pub fn is_valid(&self) -> bool {
false
}
*/
}
#[cfg(test)]
mod test {
use super::*;
// use crate::gtk_init::gtk_init;
// Enabling this test pushes tests on the TextField into an infinite loop. That likely indicates a bad interaction within the TextField itself, and that is going to need to be fixed.
#[test]
#[ignore]
fn it_allows_valid_dates() {
let reference = chrono::NaiveDate::from_ymd_opt(2006, 1, 2).unwrap();
let field = DateField::new(reference);
field.imp().year.set_value(Some(2023));
field.imp().month.set_value(Some(10));
field.imp().day.set_value(Some(13));
// assert!(field.is_valid());
}
#[test]
#[ignore]
fn it_disallows_out_of_range_months() {
unimplemented!()
}
#[test]
#[ignore]
fn it_allows_days_within_range_for_month() {
unimplemented!()
}
}

View File

@ -0,0 +1,189 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::DateField, types::DayInterval};
use chrono::{Duration, Local, Months};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
type OnSearch = dyn Fn(DayInterval) + 'static;
pub struct DateRangePickerPrivate {
start: DateField,
end: DateField,
on_search: RefCell<Box<OnSearch>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateRangePickerPrivate {
const NAME: &'static str = "DateRangePicker";
type Type = DateRangePicker;
type ParentType = gtk::Box;
fn new() -> Self {
let default_date = Local::now().date_naive();
let start = DateField::new(default_date);
start.add_css_class("date-range-picker__date-field");
let end = DateField::new(default_date);
end.add_css_class("date-range-picker__date-field");
Self {
start,
end,
on_search: RefCell::new(Box::new(|_| {})),
}
}
}
impl ObjectImpl for DateRangePickerPrivate {}
impl WidgetImpl for DateRangePickerPrivate {}
impl BoxImpl for DateRangePickerPrivate {}
glib::wrapper! {
pub struct DateRangePicker(ObjectSubclass<DateRangePickerPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DateRangePicker {
pub fn connect_on_search<OnSearch>(&self, f: OnSearch)
where
OnSearch: Fn(DayInterval) + 'static,
{
*self.imp().on_search.borrow_mut() = Box::new(f);
}
pub fn set_interval(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) {
self.imp().start.set_date(start);
self.imp().end.set_date(end);
}
pub fn interval(&self) -> DayInterval {
DayInterval {
start: self.imp().start.date(),
end: self.imp().end.date(),
}
}
}
impl Default for DateRangePicker {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.add_css_class("date-range-picker");
let search_button = gtk::Button::builder()
.css_classes(["date-range-picker__search-button"])
.label("Search")
.build();
search_button.connect_clicked({
let s = s.clone();
move |_| (s.imp().on_search.borrow())(s.interval())
});
let last_week_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("week")
.build();
last_week_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let two_weeks_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("two weeks")
.build();
two_weeks_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(14);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_month_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("month")
.build();
last_month_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(1);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let six_months_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("six months")
.build();
six_months_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(6);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_year_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("year")
.build();
last_year_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(12);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let date_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
date_row.append(&s.imp().start);
date_row.append(&gtk::Label::new(Some("to")));
date_row.append(&s.imp().end);
date_row.append(&search_button);
let quick_picker = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
quick_picker.append(&last_week_button);
quick_picker.append(&two_weeks_button);
quick_picker.append(&last_month_button);
quick_picker.append(&six_months_button);
quick_picker.append(&last_year_button);
s.append(&date_row);
s.append(&quick_picker);
s
}
}

View File

@ -0,0 +1,400 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
// use chrono::NaiveDate;
// use ft_core::TraxRecord;
use crate::{
components::{
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
},
types::{DistanceFormatter, DurationFormatter, WeightFormatter},
view_models::DayDetailViewModel,
};
use emseries::{Record, RecordId};
use ft_core::{TimeDistanceActivity, TraxRecord, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
use super::{time_distance::TimeDistanceEdit, time_distance_detail};
pub struct DaySummaryPrivate {
date: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for DaySummaryPrivate {
const NAME: &'static str = "DaySummary";
type Type = DaySummary;
type ParentType = gtk::Box;
fn new() -> Self {
let date = gtk::Label::builder()
.css_classes(["day-summary__date"])
.halign(gtk::Align::Start)
.build();
Self { date }
}
}
impl ObjectImpl for DaySummaryPrivate {}
impl WidgetImpl for DaySummaryPrivate {}
impl BoxImpl for DaySummaryPrivate {}
glib::wrapper! {
/// The DaySummary displays one day's activities in a narrative style. This is meant to give
/// an overall feel of everything that happened during the day without going into details.
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for DaySummary {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["day-summary"]);
s.append(&s.imp().date);
s
}
}
impl DaySummary {
pub fn new() -> Self {
Self::default()
}
pub fn set_data(&self, view_model: DayDetailViewModel) {
self.imp()
.date
.set_text(&view_model.date.format("%Y-%m-%d").to_string());
let row = gtk::Box::builder().build();
let weight_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__weight"])
.build();
if let Some(w) = view_model.weight() {
weight_label.set_label(&w.to_string())
}
let steps_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__steps"])
.build();
if let Some(s) = view_model.steps() {
steps_label.set_label(&format!("{} steps", s));
}
row.append(&weight_label);
row.append(&steps_label);
self.append(&row);
for activity in TIME_DISTANCE_ACTIVITIES {
let summary = view_model.time_distance_summary(activity);
if let Some(label) = time_distance_summary(
activity,
DistanceFormatter::from(summary.0),
DurationFormatter::from(summary.1),
) {
self.append(&label);
}
}
}
}
#[derive(Default)]
pub struct DayDetailPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailPrivate {
const NAME: &'static str = "DayDetail";
type Type = DayDetail;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailPrivate {}
impl WidgetImpl for DayDetailPrivate {}
impl BoxImpl for DayDetailPrivate {}
glib::wrapper! {
pub struct DayDetail(ObjectSubclass<DayDetailPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayDetail {
pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
where
OnEdit: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.append(
&ActionGroup::builder()
.primary_action("Edit", Box::new(on_edit))
.build(),
);
let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from));
top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps());
top_row.append(&steps_view.widget());
s.append(&top_row);
let records = view_model.time_distance_records();
for emseries::Record { data, .. } in records {
s.append(&time_distance_detail(data));
}
s
}
}
pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>,
#[allow(unused)]
workout_rows: RefCell<gtk::Box>,
view_model: RefCell<Option<DayDetailViewModel>>,
}
impl Default for DayEditPrivate {
fn default() -> Self {
Self {
on_finished: RefCell::new(Box::new(|| {})),
workout_rows: RefCell::new(
gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build(),
),
view_model: RefCell::new(None),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for DayEditPrivate {
const NAME: &'static str = "DayEdit";
type Type = DayEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayEditPrivate {}
impl WidgetImpl for DayEditPrivate {}
impl BoxImpl for DayEditPrivate {}
glib::wrapper! {
pub struct DayEdit(ObjectSubclass<DayEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayEdit {
pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
where
OnFinished: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
*s.imp().view_model.borrow_mut() = Some(view_model.clone());
let workout_buttons = workout_buttons(view_model.clone(), {
let s = s.clone();
move |workout| s.add_row(workout)
});
view_model
.records()
.into_iter()
.filter_map({
let s = s.clone();
move |record| match record.data {
TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, {
let s = s.clone();
move |data| {
s.update_workout(record.id, data);
}
})),
_ => None,
}
})
.for_each(|row| s.imp().workout_rows.borrow().append(&row));
s.append(&control_buttons(&s, &view_model));
s.append(&weight_and_steps_row(&view_model));
s.append(&*s.imp().workout_rows.borrow());
s.append(&workout_buttons);
s
}
fn finish(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
let view_model = {
let view_model = s.imp().view_model.borrow();
view_model
.as_ref()
.expect("DayEdit has not been initialized with the view model")
.clone()
};
let _ = view_model.async_save().await;
(s.imp().on_finished.borrow())()
}
});
}
fn add_row(&self, workout: Record<TraxRecord>) {
let workout_rows = self.imp().workout_rows.borrow();
#[allow(clippy::single_match)]
match workout.data {
TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, {
let s = self.clone();
move |data| {
println!("update workout callback on workout: {:?}", workout.id);
s.update_workout(workout.id, data)
}
})),
_ => {}
}
}
fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) {
if let Some(ref view_model) = *self.imp().view_model.borrow() {
let record = Record {
id,
data: TraxRecord::TimeDistance(data),
};
view_model.update_record(record);
}
}
}
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
ActionGroup::builder()
.primary_action("Save", {
let s = s.clone();
move || s.finish()
})
.secondary_action("Cancel", {
let s = s.clone();
let view_model = view_model.clone();
move || {
let s = s.clone();
let view_model = view_model.clone();
glib::spawn_future_local(async move {
view_model.revert().await;
s.finish();
});
}
})
.build()
}
fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
row.append(
&weight_field(view_model.weight().map(WeightFormatter::from), {
let view_model = view_model.clone();
move |w| match w {
Some(w) => view_model.set_weight(*w),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row.append(
&steps_editor(view_model.steps(), {
let view_model = view_model.clone();
move |s| match s {
Some(s) => view_model.set_steps(s),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row
}
fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let add_row = Rc::new(add_row);
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
for (activity, icon, label) in [
(
TimeDistanceActivity::Biking,
"cycling-symbolic",
"Bike Ride",
),
(TimeDistanceActivity::Rowing, "rowing-symbolic", "Rowing"),
(TimeDistanceActivity::Running, "running-symbolic", "Run"),
(TimeDistanceActivity::Swimming, "swimming-symbolic", "Swim"),
(TimeDistanceActivity::Walking, "walking-symbolic", "Walk"),
] {
let button = workout_button(activity, icon, label, view_model.clone(), {
let add_row = add_row.clone();
move |record| add_row(record)
});
layout.append(&button);
}
layout
}
fn workout_button<AddRow>(
activity: TimeDistanceActivity,
_icon: &str,
label: &str,
view_model: DayDetailViewModel,
add_row: AddRow,
) -> gtk::Button
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let button = gtk::Button::builder()
.label(label)
.width_request(64)
.height_request(64)
.build();
button.connect_clicked({
let view_model = view_model.clone();
move |_| {
let workout = view_model.new_time_distance(activity);
add_row(workout.map(TraxRecord::TimeDistance));
}
});
button
}

View File

@ -0,0 +1,155 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not,
see <https://www.gnu.org/licenses/>.
*/
mod action_group;
pub use action_group::ActionGroup;
mod day;
pub use day::{DayDetail, DayEdit, DaySummary};
mod date_field;
pub use date_field::DateField;
mod date_range;
pub use date_range::DateRangePicker;
mod singleton;
pub use singleton::{Singleton, SingletonImpl};
mod steps;
pub use steps::{steps_editor, Steps};
mod text_entry;
pub use text_entry::{distance_field, duration_field, time_field, weight_field, i32_field_builder, month_field_builder, TextEntry};
mod time_distance;
pub use time_distance::{time_distance_detail, time_distance_summary};
mod weight;
pub use weight::WeightLabel;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, path::PathBuf, rc::Rc};
pub struct FileChooserRowPrivate {
path: RefCell<Option<PathBuf>>,
label: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for FileChooserRowPrivate {
const NAME: &'static str = "FileChooser";
type Type = FileChooserRow;
type ParentType = gtk::Box;
fn new() -> Self {
Self {
path: RefCell::new(None),
label: gtk::Label::builder().hexpand(true).build(),
}
}
}
impl ObjectImpl for FileChooserRowPrivate {}
impl WidgetImpl for FileChooserRowPrivate {}
impl BoxImpl for FileChooserRowPrivate {}
glib::wrapper! {
pub struct FileChooserRow(ObjectSubclass<FileChooserRowPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl FileChooserRow {
pub fn new<F>(on_selected: F) -> Self
where
F: Fn(PathBuf) + 'static,
{
let s: Self = Object::builder().build();
s.set_css_classes(&["dialog-row", "card"]);
s.set_orientation(gtk::Orientation::Horizontal);
s.set_spacing(8);
// The database selection row should be a box that shows a default database path, along with a
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
// updated to reflect the chosen path.
s.imp().label.set_text("No database selected");
let on_selected = Rc::new(Box::new(on_selected));
let import_button = gtk::Button::builder().label("Import a Database").build();
let handle_file_selection = Rc::new(Box::new({
let s = s.clone();
let on_selected = on_selected.clone();
move |file_id: Result<gio::File, glib::Error>| match file_id {
Ok(file_id) => match file_id.path() {
Some(path) => {
s.imp().label.set_text(path.to_str().unwrap());
on_selected(path.clone());
*s.imp().path.borrow_mut() = Some(path);
}
None => {
*s.imp().path.borrow_mut() = None;
s.imp().label.set_text("No database selected");
}
},
Err(err) => println!("file opening failed: {}", err),
}
}));
import_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().open(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
let new_button = gtk::Button::builder().label("Create Database").build();
new_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().save(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
s.imp().label.set_halign(gtk::Align::Start);
s.append(&s.imp().label);
s.append(&import_button);
s.append(&new_button);
s
}
pub fn path(&self) -> Option<PathBuf> {
self.imp().path.borrow().clone()
}
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! A Widget container for a single components
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
pub struct SingletonPrivate {
widget: RefCell<gtk::Widget>,
}
impl Default for SingletonPrivate {
fn default() -> Self {
Self {
widget: RefCell::new(gtk::Label::new(None).upcast()),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for SingletonPrivate {
const NAME: &'static str = "Singleton";
type Type = Singleton;
type ParentType = gtk::Box;
}
impl ObjectImpl for SingletonPrivate {}
impl WidgetImpl for SingletonPrivate {}
impl BoxImpl for SingletonPrivate {}
glib::wrapper! {
/// The Singleton component contains exactly one child widget. The swap function makes it easy
/// to handle the job of swapping that child out for a different one.
pub struct Singleton(ObjectSubclass<SingletonPrivate>) @extends gtk::Box, gtk::Widget;
}
impl Default for Singleton {
fn default() -> Self {
let s: Self = Object::builder().build();
s.append(&*s.imp().widget.borrow());
s
}
}
impl Singleton {
pub fn swap(&self, new_widget: &impl IsA<gtk::Widget>) {
let new_widget = new_widget.clone().upcast();
self.remove(&*self.imp().widget.borrow());
self.append(&new_widget);
*self.imp().widget.borrow_mut() = new_widget;
}
}
pub trait SingletonImpl: WidgetImpl + BoxImpl {}
unsafe impl<T: SingletonImpl> IsSubclassable<T> for Singleton {}

View File

@ -0,0 +1,60 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::TextEntry, types::ParseError};
use gtk::prelude::*;
#[derive(Default)]
pub struct Steps {
label: gtk::Label,
}
impl Steps {
pub fn new(steps: Option<u32>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "step-view"])
.can_focus(true)
.build();
match steps {
Some(s) => label.set_text(&format!("{}", s)),
None => label.set_text("No steps recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
where
OnUpdate: Fn(Option<u32>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder( "0".to_owned())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}.build()
}

View File

@ -0,0 +1,373 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{
DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter,
};
use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
pub type OnUpdate<T> = dyn Fn(Option<T>);
// TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget.
#[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry,
renderer: Rc<dyn Fn(&T) -> String>,
parser: Rc<Parser<T>>,
on_update: Rc<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
f,
"{{ value: {:?}, widget: {:?} }}",
self.value, self.widget
)
}
}
// I do not understand why the data should be 'static.
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
fn from_builder(builder: TextEntryBuilder<T>) -> TextEntry<T> {
let widget = gtk::Entry::builder()
.placeholder_text(builder.placeholder)
.build();
if let Some(ref v) = builder.value {
widget.set_text(&(builder.renderer)(v))
}
let s = Self {
value: Rc::new(RefCell::new(builder.value)),
widget,
renderer: Rc::new(builder.renderer),
parser: Rc::new(builder.parser),
on_update: Rc::new(builder.on_update),
};
s.widget.buffer().connect_text_notify({
let s = s.clone();
move |buffer| s.handle_text_change(buffer)
});
if let Some(length) = builder.length {
s.widget.set_max_length(length.try_into().unwrap());
}
// let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect();
let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect();
s.widget.set_css_classes(&classes);
s
}
pub fn builder() -> TextEntryBuilder<T> {
TextEntryBuilder::default()
}
pub fn set_value(&self, val: Option<T>) {
if let Some(ref v) = val {
self.widget.set_text(&(self.renderer)(v));
}
}
fn handle_text_change(&self, buffer: &gtk::EntryBuffer) {
if buffer.text().is_empty() {
*self.value.borrow_mut() = None;
self.widget.remove_css_class("error");
(self.on_update)(None);
return;
}
match (self.parser)(buffer.text().as_str()) {
Ok(v) => {
*self.value.borrow_mut() = Some(v.clone());
self.widget.remove_css_class("error");
(self.on_update)(Some(v));
}
// need to change the border to provide a visual indicator of an error
Err(_) => {
self.widget.add_css_class("error");
}
}
}
pub fn widget(&self) -> gtk::Widget {
self.widget.clone().upcast::<gtk::Widget>()
}
#[cfg(test)]
fn has_parse_error(&self) -> bool {
self.widget.has_css_class("error")
}
}
pub struct TextEntryBuilder<T: Clone + std::fmt::Debug + 'static> {
placeholder: String,
value: Option<T>,
length: Option<usize>,
css_classes: Vec<String>,
renderer: Box<dyn Fn(&T) -> String>,
parser: Box<Parser<T>>,
on_update: Box<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug + 'static> Default for TextEntryBuilder<T> {
fn default() -> TextEntryBuilder<T> {
TextEntryBuilder {
placeholder: "".to_owned(),
value: None,
length: None,
css_classes: vec![],
renderer: Box::new(|_| "".to_owned()),
parser: Box::new(|_| Err(ParseError)),
on_update: Box::new(|_| {}),
}
}
}
impl<T: Clone + std::fmt::Debug + 'static> TextEntryBuilder<T> {
pub fn build(self) -> TextEntry<T> {
TextEntry::from_builder(self)
}
pub fn with_placeholder(self, placeholder: String) -> Self {
Self {
placeholder,
..self
}
}
pub fn with_value(self, value: T) -> Self {
Self {
value: Some(value),
..self
}
}
pub fn with_length(self, length: usize) -> Self {
Self {
length: Some(length),
..self
}
}
pub fn with_css_classes(self, classes: Vec<String>) -> Self {
Self {
css_classes: classes,
..self
}
}
pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self {
Self {
renderer: Box::new(renderer),
..self
}
}
pub fn with_parser(self, parser: impl Fn(&str) -> Result<T, ParseError> + 'static) -> Self {
Self {
parser: Box::new(parser),
..self
}
}
pub fn with_on_update(self, on_update: impl Fn(Option<T>) + 'static) -> Self {
Self {
on_update: Box::new(on_update),
..self
}
}
}
pub fn time_field<OnUpdate>(
value: Option<TimeFormatter>,
on_update: OnUpdate,
) -> TextEntry<TimeFormatter>
where
OnUpdate: Fn(Option<TimeFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("HH:MM".to_owned())
.with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated))
.with_parser(TimeFormatter::parse)
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}
.build()
}
pub fn distance_field<OnUpdate>(
value: Option<DistanceFormatter>,
on_update: OnUpdate,
) -> TextEntry<DistanceFormatter>
where
OnUpdate: Fn(Option<DistanceFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 km".to_owned())
.with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DistanceFormatter::parse)
.with_on_update(on_update);
if let Some(distance) = value {
text_entry.with_value(distance)
} else {
text_entry
}
.build()
}
pub fn duration_field<OnUpdate>(
value: Option<DurationFormatter>,
on_update: OnUpdate,
) -> TextEntry<DurationFormatter>
where
OnUpdate: Fn(Option<DurationFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 m".to_owned())
.with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DurationFormatter::parse)
.with_on_update(on_update);
if let Some(duration) = value {
text_entry.with_value(duration)
} else {
text_entry
}
.build()
}
pub fn weight_field<OnUpdate>(
weight: Option<WeightFormatter>,
on_update: OnUpdate,
) -> TextEntry<WeightFormatter>
where
OnUpdate: Fn(Option<WeightFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 kg".to_owned())
.with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated))
.with_parser(WeightFormatter::parse)
.with_on_update(on_update);
if let Some(weight) = weight {
text_entry.with_value(weight)
} else {
text_entry
}
.build()
}
pub fn i32_field_builder() -> TextEntryBuilder<i32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| v.parse::<i32>().map_err(|_| ParseError))
}
pub fn month_field_builder() -> TextEntryBuilder<u32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| {
let val = v.parse::<u32>().map_err(|_| ParseError)?;
if val == 0 || val > 12 {
return Err(ParseError);
}
Ok(val)
})
}
#[cfg(test)]
mod test {
use super::*;
use crate::gtk_init::gtk_init;
fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) {
let current_value = Rc::new(RefCell::new(None));
let entry = TextEntry::builder()
.with_placeholder("step count".to_owned())
.with_renderer(|steps| format!("{}", steps))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let current_value = current_value.clone();
move |v| *current_value.borrow_mut() = v
})
.build();
(current_value, entry)
}
#[test]
fn it_responds_to_field_changes() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("15");
assert_eq!(*current_value.borrow(), Some(15));
}
#[test]
fn it_preserves_last_value_in_parse_error() {
crate::gtk_init::gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("a5");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
}
#[test]
fn it_update_on_empty_strings() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("1a");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
assert!(!entry.has_parse_error());
}
}

View File

@ -0,0 +1,271 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{distance_field, duration_field, time_field},
types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
};
use dimensioned::si;
use ft_core::{TimeDistance, TimeDistanceActivity, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub fn time_distance_summary(
activity: TimeDistanceActivity,
distance: DistanceFormatter,
duration: DurationFormatter,
) -> Option<gtk::Label> {
let text = match (*distance > si::M, *duration > si::S) {
(true, true) => Some(format!(
"{} of {:?} in {}",
distance.format(FormatOption::Full),
activity,
duration.format(FormatOption::Full)
)),
(true, false) => Some(format!(
"{} of {:?}",
distance.format(FormatOption::Full),
activity
)),
(false, true) => Some(format!(
"{} of {:?}",
duration.format(FormatOption::Full),
activity
)),
(false, false) => None,
};
text.map(|text| gtk::Label::builder().halign(gtk::Align::Start).label(&text).build())
}
pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box {
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build();
let first_row = gtk::Box::builder().homogeneous(true).build();
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(record.datetime.format("%H:%M").to_string())
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(format!("{:?}", record.activity))
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.distance
.map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()),
)
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.duration
.map(|duration| {
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
})
.unwrap_or("".to_owned()),
)
.build(),
);
layout.append(&first_row);
layout.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.comments
.map(|comments| comments.to_string())
.unwrap_or("".to_owned()),
)
.build(),
);
layout
}
type OnUpdate = Rc<RefCell<Box<dyn Fn(TimeDistance)>>>;
pub struct TimeDistanceEditPrivate {
#[allow(unused)]
workout: RefCell<ft_core::TimeDistance>,
on_update: OnUpdate,
}
impl Default for TimeDistanceEditPrivate {
fn default() -> Self {
Self {
workout: RefCell::new(TimeDistance {
datetime: chrono::Utc::now().into(),
activity: TimeDistanceActivity::Biking,
duration: None,
distance: None,
comments: None,
}),
on_update: Rc::new(RefCell::new(Box::new(|_| {}))),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TimeDistanceEditPrivate {
const NAME: &'static str = "TimeDistanceEdit";
type Type = TimeDistanceEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for TimeDistanceEditPrivate {}
impl WidgetImpl for TimeDistanceEditPrivate {}
impl BoxImpl for TimeDistanceEditPrivate {}
glib::wrapper! {
pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for TimeDistanceEdit {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.set_css_classes(&["time-distance-edit"]);
s
}
}
impl TimeDistanceEdit {
pub fn new<OnUpdate>(workout: TimeDistance, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(TimeDistance) + 'static,
{
let s = Self::default();
*s.imp().workout.borrow_mut() = workout.clone();
*s.imp().on_update.borrow_mut() = Box::new(on_update);
let details_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
details_row.append(
&time_field(
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
{
let s = s.clone();
move |t| s.update_time(t)
},
)
.widget(),
);
details_row.append(&s.activity_menu(workout.activity));
details_row.append(
&distance_field(workout.distance.map(DistanceFormatter::from), {
let s = s.clone();
move |d| s.update_distance(d)
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(DurationFormatter::from), {
let s = s.clone();
move |d| s.update_duration(d)
})
.widget(),
);
s.append(&details_row);
s.append(&gtk::Entry::new());
s
}
fn update_time(&self, time: Option<TimeFormatter>) {
if let Some(time_formatter) = time {
let mut workout = self.imp().workout.borrow_mut();
let tz = workout.datetime.timezone();
let new_time = workout
.datetime
.date_naive()
.and_time(*time_formatter)
.and_local_timezone(tz)
.unwrap()
.fixed_offset();
workout.datetime = new_time;
(self.imp().on_update.borrow())(workout.clone());
}
}
fn update_workout_type(&self, type_: TimeDistanceActivity) {
let mut workout = self.imp().workout.borrow_mut();
workout.activity = type_;
(self.imp().on_update.borrow())(workout.clone())
}
fn update_distance(&self, distance: Option<DistanceFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn update_duration(&self, duration: Option<DurationFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn activity_menu(&self, selected: TimeDistanceActivity) -> gtk::DropDown {
let options = TIME_DISTANCE_ACTIVITIES
.iter()
.map(|item| format!("{:?}", item))
.collect::<Vec<String>>();
let options = options.iter().map(|o| o.as_ref()).collect::<Vec<&str>>();
let selected_idx = TIME_DISTANCE_ACTIVITIES
.iter()
.position(|&v| v == selected)
.unwrap_or(0);
let menu = gtk::DropDown::from_strings(&options);
menu.set_selected(selected_idx as u32);
menu.connect_selected_item_notify({
let s = self.clone();
move |menu| {
let new_item = TIME_DISTANCE_ACTIVITIES[menu.selected() as usize];
s.update_workout_type(new_item);
}
});
menu
}
}

View File

@ -0,0 +1,42 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{FormatOption, WeightFormatter};
use gtk::prelude::*;
pub struct WeightLabel {
label: gtk::Label,
}
impl WeightLabel {
pub fn new(weight: Option<WeightFormatter>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "weight-view"])
.can_focus(true)
.build();
match weight {
Some(w) => label.set_text(&w.format(FormatOption::Abbreviated)),
None => label.set_text("No weight recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}

View File

@ -0,0 +1,10 @@
use std::sync::Once;
static INITIALIZED: Once = Once::new();
pub fn gtk_init() {
INITIALIZED.call_once(|| {
eprintln!("initializing GTK");
let _ = gtk::init();
});
}

101
fitnesstrax/app/src/main.rs Normal file
View File

@ -0,0 +1,101 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod about;
mod app;
mod app_window;
mod components;
#[cfg(test)]
mod gtk_init;
mod types;
mod view_models;
mod views;
use adw::prelude::*;
use app_window::AppWindow;
use gio::ActionEntry;
use std::{env, path::PathBuf};
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
fn setup_app_about_action(app: &adw::Application) {
let action = ActionEntry::builder("about")
.activate(|_app: &adw::Application, _, _| {
let window = about::AboutWindow::default();
window.present();
}).build();
app.add_action_entries([action]);
}
/// Sets up an application-global action, `app.quit`, which will terminate the application.
fn setup_app_close_action(app: &adw::Application) {
let action = ActionEntry::builder("quit")
.activate(|app: &adw::Application, _, _| {
// right now, stopping the application is dirt simple. But we could use this
// block to add extra code that does additional shutdown steps if we ever want
// some states that shouldn't be discarded.
app.quit();
})
.build();
app.add_action_entries([action]);
app.set_accels_for_action("app.quit", &["<Ctrl>Q"]);
}
fn main() {
// I still don't fully understand gio resources. resources_register_include! is convenient
// because I don't have to deal with filesystem locations at runtime. However, I think other
// GTK applications do that rather than compiling the resources directly into the app. So, I'm
// unclear as to how I want to handle this.
gio::resources_register_include!("com.luminescent-dreams.fitnesstrax.gresource")
.expect("to register resources");
let app_id = if std::env::var_os("ENV") == Some("dev".into()) {
APP_ID_DEV
} else {
APP_ID_PROD
};
let settings = gio::Settings::new(app_id);
let ft_app = app::App::new({
let path = settings.string("series-path");
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
});
let adw_app = adw::Application::builder()
.application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH)
.build();
adw_app.connect_activate(move |adw_app| {
let icon_theme = gtk::IconTheme::for_display(&gdk::Display::default().unwrap());
icon_theme.add_resource_path(&(RESOURCE_BASE_PATH.to_owned() + "/icons/scalable/actions"));
setup_app_about_action(adw_app);
setup_app_close_action(adw_app);
AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone());
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -0,0 +1,349 @@
use chrono::{Local, NaiveDate};
use dimensioned::si;
#[derive(Clone, Debug, PartialEq)]
pub struct ParseError;
// This interval doesn't feel right, either. The idea that I have a specific interval type for just
// NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live
// here, but in utilities.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DayInterval {
pub start: NaiveDate,
pub end: NaiveDate,
}
impl Default for DayInterval {
fn default() -> Self {
Self {
start: (Local::now() - chrono::Duration::days(7)).date_naive(),
end: Local::now().date_naive(),
}
}
}
impl DayInterval {
pub fn days(&self) -> impl Iterator<Item = NaiveDate> {
DayIterator {
current: self.start,
end: self.end,
}
}
}
struct DayIterator {
current: NaiveDate,
end: NaiveDate,
}
impl Iterator for DayIterator {
type Item = NaiveDate;
fn next(&mut self) -> Option<Self::Item> {
if self.current <= self.end {
let val = self.current;
self.current += chrono::Duration::days(1);
Some(val)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatOption {
Abbreviated,
#[allow(unused)]
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TimeFormatter(chrono::NaiveTime);
impl TimeFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => self.0.format("%H:%M"),
FormatOption::Full => self.0.format("%H:%M:%S"),
}
.to_string()
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
let parts = s
.split(':')
.map(|part| part.parse::<u32>().map_err(|_| ParseError))
.collect::<Result<Vec<u32>, ParseError>>()?;
match parts.len() {
0 => Err(ParseError),
1 => Err(ParseError),
2 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], 0)
.map(TimeFormatter)
.ok_or(ParseError),
3 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], parts[2])
.map(TimeFormatter)
.ok_or(ParseError),
_ => Err(ParseError),
}
}
}
impl std::ops::Deref for TimeFormatter {
type Target = chrono::NaiveTime;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<chrono::NaiveTime> for TimeFormatter {
fn from(value: chrono::NaiveTime) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct WeightFormatter(si::Kilogram<f64>);
impl WeightFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe),
FormatOption::Full => format!("{} kilograms", self.0.value_unsafe),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
s.parse::<f64>()
.map(|w| WeightFormatter(w * si::KG))
.map_err(|_| ParseError)
}
}
impl std::ops::Add for WeightFormatter {
type Output = WeightFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for WeightFormatter {
type Output = WeightFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for WeightFormatter {
type Target = si::Kilogram<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Kilogram<f64>> for WeightFormatter {
fn from(value: si::Kilogram<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DistanceFormatter(si::Meter<f64>);
impl DistanceFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.),
FormatOption::Full => format!("{} kilometers", self.0.value_unsafe / 1000.),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DistanceFormatter(value * 1000. * si::M))
}
}
impl std::ops::Add for DistanceFormatter {
type Output = DistanceFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DistanceFormatter {
type Output = DistanceFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DistanceFormatter {
type Target = si::Meter<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Meter<f64>> for DistanceFormatter {
fn from(value: si::Meter<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DurationFormatter(si::Second<f64>);
impl DurationFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option {
FormatOption::Abbreviated => ("h", "m"),
FormatOption::Full => (" hours", " minutes"),
};
if hours > 0 {
format!("{}{} {}{}", hours, h, minutes, m)
} else {
format!("{}{}", minutes, m)
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DurationFormatter(value * 60. * si::S))
}
#[allow(unused)]
fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60;
let minutes = minutes - (hours * 60);
(hours, minutes)
}
}
impl std::ops::Add for DurationFormatter {
type Output = DurationFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DurationFormatter {
type Output = DurationFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DurationFormatter {
type Target = si::Second<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Second<f64>> for DurationFormatter {
fn from(value: si::Second<f64>) -> Self {
Self(value)
}
}
#[cfg(test)]
mod test {
use super::*;
use dimensioned::si;
#[test]
fn it_parses_weight_values() {
assert_eq!(
WeightFormatter::parse("15.3"),
Ok(WeightFormatter(15.3 * si::KG))
);
assert_eq!(WeightFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_weight_values() {
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Abbreviated),
"15.3 kg"
);
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Full),
"15.3 kilograms"
);
}
#[test]
fn it_parses_distance_values() {
assert_eq!(
DistanceFormatter::parse("70"),
Ok(DistanceFormatter(70000. * si::M))
);
assert_eq!(DistanceFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_distance_values() {
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Abbreviated),
"70 km"
);
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Full),
"70 kilometers"
);
}
#[test]
fn it_parses_duration_values() {
assert_eq!(
DurationFormatter::parse("70"),
Ok(DurationFormatter(4200. * si::S))
);
assert_eq!(DurationFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_duration_values() {
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Abbreviated),
"1h 10m"
);
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Full),
"1 hours 10 minutes"
);
}
#[test]
fn it_parses_time_values() {
assert_eq!(
TimeFormatter::parse("13:25"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 0).unwrap()
)),
);
assert_eq!(
TimeFormatter::parse("13:25:50"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap()
)),
);
}
#[test]
fn it_formats_time_values() {
let time = TimeFormatter::from(chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap());
assert_eq!(time.format(FormatOption::Abbreviated), "13:25".to_owned());
assert_eq!(time.format(FormatOption::Full), "13:25:50".to_owned());
}
}

View File

@ -0,0 +1,657 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::app::{ReadError, RecordProvider};
use dimensioned::si;
use emseries::{Record, RecordId, Recordable};
use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord};
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros.
#[allow(unused_imports)]
use crate::app::WriteError;
#[allow(unused_imports)]
use chrono::NaiveDate;
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(Record<T>),
Updated(Record<T>),
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
fn exists(&self) -> bool {
match self {
RecordState::Original(_) => true,
RecordState::New(_) => true,
RecordState::Updated(_) => true,
RecordState::Deleted(_) => false,
}
}
#[allow(unused)]
fn data(&self) -> Option<&Record<T>> {
match self {
RecordState::Original(ref r) => Some(r),
RecordState::New(ref r) => None,
RecordState::Updated(ref r) => Some(r),
RecordState::Deleted(ref r) => Some(r),
}
}
fn set_value(&mut self, value: T) {
*self = match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::New(r) => RecordState::New(Record { data: value, ..*r }),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..*r }),
};
}
fn with_value(mut self, value: T) -> RecordState<T> {
self.set_value(value);
self
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => &r.data,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
impl<T: Clone + emseries::Recordable> std::ops::DerefMut for RecordState<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
match self {
RecordState::Original(ref mut r) => &mut r.data,
RecordState::New(ref mut r) => &mut r.data,
RecordState::Updated(ref mut r) => &mut r.data,
RecordState::Deleted(ref mut r) => &mut r.data,
}
}
}
#[derive(Clone)]
pub struct DayDetailViewModel {
provider: Arc<dyn RecordProvider>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub async fn new(
date: chrono::NaiveDate,
provider: impl RecordProvider + 'static,
) -> Result<Self, ReadError> {
let s = Self {
provider: Arc::new(provider),
date,
weight: Arc::new(RwLock::new(None)),
steps: Arc::new(RwLock::new(None)),
records: Arc::new(RwLock::new(HashMap::new())),
};
s.populate_records().await;
Ok(s)
}
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
}
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Weight {
date: self.date,
weight: new_weight,
},
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Steps {
date: self.date,
count: new_count,
},
}),
};
*record = Some(new_record);
}
pub fn new_time_distance(&self, activity: TimeDistanceActivity) -> Record<TimeDistance> {
let now = chrono::Local::now();
let base_time = now.time();
let tz = now.timezone();
let datetime = self
.date
.clone()
.and_time(base_time)
.and_local_timezone(tz)
.unwrap()
.into();
let id = RecordId::default();
let workout = TimeDistance {
datetime,
activity,
distance: None,
duration: None,
comments: None,
};
let tr = TraxRecord::from(workout.clone());
self.records
.write()
.unwrap()
.insert(id, RecordState::New(Record { id, data: tr }));
Record { id, data: workout }
}
pub fn time_distance_records(&self) -> Vec<Record<TimeDistance>> {
self.records
.read()
.unwrap()
.iter()
.filter(|(_, record)| record.exists())
.filter_map(|(id, record_state)| match **record_state {
TraxRecord::TimeDistance(ref workout) => Some(Record {
id: *id,
data: workout.clone(),
}),
_ => None,
})
.collect()
}
pub fn time_distance_summary(
&self,
activity: TimeDistanceActivity,
) -> (si::Meter<f64>, si::Second<f64>) {
self.time_distance_records()
.into_iter()
.filter(|rec| rec.data.activity == activity)
.fold(
(0. * si::M, 0. * si::S),
|(distance, duration), workout| match (workout.data.distance, workout.data.duration)
{
(Some(distance_), Some(duration_)) => {
(distance + distance_, duration + duration_)
}
(Some(distance_), None) => (distance + distance_, duration),
(None, Some(duration_)) => (distance, duration + duration_),
(None, None) => (distance, duration),
},
)
}
pub fn update_record(&self, update: Record<TraxRecord>) {
let mut records = self.records.write().unwrap();
records
.entry(update.id)
.and_modify(|record| record.set_value(update.data));
}
pub fn records(&self) -> Vec<Record<TraxRecord>> {
let read_lock = self.records.read().unwrap();
read_lock
.iter()
.filter_map(|(_, record_state)| record_state.data())
.cloned()
.collect::<Vec<Record<TraxRecord>>>()
}
#[allow(unused)]
fn get_record(&self, id: &RecordId) -> Option<Record<TraxRecord>> {
let record_set = self.records.read().unwrap();
record_set.get(id).map(|record| Record {
id: *id,
data: (**record).clone(),
})
}
pub fn remove_record(&self, id: RecordId) {
let mut record_set = self.records.write().unwrap();
let updated_record = match record_set.remove(&id) {
Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::New(_)) => None,
Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)),
None => None,
};
if let Some(updated_record) = updated_record {
record_set.insert(id, updated_record);
}
}
pub fn save(&self) {
let s = self.clone();
glib::spawn_future(async move { s.async_save().await });
}
pub async fn async_save(&self) {
let weight_record = self.weight.read().unwrap().clone();
match weight_record {
Some(RecordState::New(data)) => {
let _ = self
.provider
.put_record(TraxRecord::Weight(data.data))
.await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => {
let _ = self
.provider
.update_record(Record {
id: weight.id,
data: TraxRecord::Weight(weight.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let steps_record = self.steps.read().unwrap().clone();
match steps_record {
Some(RecordState::New(data)) => {
let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => {
let _ = self
.provider
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = self
.records
.write()
.unwrap()
.drain()
.map(|(_, record)| record)
.collect::<Vec<RecordState<TraxRecord>>>();
for record in records {
println!("saving record: {:?}", record);
match record {
RecordState::New(data) => {
let _ = self.provider.put_record(data.data).await;
}
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = self.provider.update_record(r.clone()).await;
}
RecordState::Deleted(r) => {
let _ = self.provider.delete_record(r.id).await;
}
}
}
self.populate_records().await;
}
pub async fn revert(&self) {
self.populate_records().await;
}
async fn populate_records(&self) {
let records = self.provider.records(self.date, self.date).await.unwrap();
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
*self.weight.write().unwrap() = weight_records
.first()
.and_then(|r| match r.data {
TraxRecord::Weight(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.steps.write().unwrap() = step_records
.first()
.and_then(|r| match r.data {
TraxRecord::Steps(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.records.write().unwrap() = records
.into_iter()
.map(|r| (r.id, RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>();
}
}
#[cfg(test)]
mod test {
use super::*;
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use dimensioned::si;
use emseries::Record;
#[derive(Clone, Debug)]
struct MockProvider {
records: Arc<RwLock<HashMap<RecordId, Record<TraxRecord>>>>,
put_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
updated_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
deleted_records: Arc<RwLock<Vec<RecordId>>>,
}
impl MockProvider {
fn new(records: Vec<Record<TraxRecord>>) -> Self {
let record_map = records
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<RecordId, Record<TraxRecord>>>();
Self {
records: Arc::new(RwLock::new(record_map)),
put_records: Arc::new(RwLock::new(vec![])),
updated_records: Arc::new(RwLock::new(vec![])),
deleted_records: Arc::new(RwLock::new(vec![])),
}
}
}
#[async_trait]
impl RecordProvider for MockProvider {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let start = emseries::Timestamp::Date(start);
let end = emseries::Timestamp::Date(end);
Ok(self
.records
.read()
.unwrap()
.iter()
.map(|(_, r)| r)
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
.cloned()
.collect::<Vec<Record<TraxRecord>>>())
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let id = RecordId::default();
let record = Record {
id,
data: record,
};
self.put_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(id, record);
Ok(id)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
println!("updated record: {:?}", record);
self.updated_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(record.id, record);
Ok(())
}
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
self.deleted_records.write().unwrap().push(id);
let _ = self.records.write().unwrap().remove(&id);
Ok(())
}
}
async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) {
let provider = MockProvider::new(vec![]);
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
async fn create_view_model() -> (DayDetailViewModel, MockProvider) {
let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap();
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let oct_13_am: DateTime<FixedOffset> = oct_13
.clone()
.and_hms_opt(3, 28, 0)
.unwrap()
.and_utc()
.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
let provider = MockProvider::new(vec![
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_12,
weight: 93. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_13,
weight: 95. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Steps(ft_core::Steps {
date: oct_13,
count: 2500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::TimeDistance(ft_core::TimeDistance {
datetime: oct_13_am,
activity: TimeDistanceActivity::Biking,
distance: Some(15000. * si::M),
duration: Some(3600. * si::S),
comments: Some("somecomments present".to_owned()),
}),
},
]);
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
#[tokio::test]
async fn it_honors_only_the_first_weight_and_step_record() {
let (view_model, _provider) = create_view_model().await;
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(2500));
}
#[tokio::test]
async fn it_can_create_a_weight_and_stepcount() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(view_model.weight(), None);
assert_eq!(view_model.steps(), None);
view_model.set_weight(95. * si::KG);
view_model.set_steps(250);
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(250));
view_model.set_weight(93. * si::KG);
view_model.set_steps(255);
assert_eq!(view_model.weight(), Some(93. * si::KG));
assert_eq!(view_model.steps(), Some(255));
view_model.async_save().await;
println!("provider: {:?}", provider);
assert_eq!(provider.put_records.read().unwrap().len(), 2);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_construct_new_records() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_a_new_record_before_saving() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
let record = record.map(TraxRecord::TimeDistance);
view_model.update_record(record.clone());
assert_eq!(view_model.get_record(&record.id), Some(record));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 60. * si::S)
);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Running),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let mut workout = view_model.time_distance_records().first().cloned().unwrap();
workout.data.duration = Some(1800. * si::S);
view_model.update_record(workout.map(TraxRecord::TimeDistance));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(15000. * si::M, 1800. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 1);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_remove_a_new_record() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let record = view_model.new_time_distance(TimeDistanceActivity::Biking);
view_model.remove_record(record.id);
view_model.save();
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_delete_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let workout = view_model.time_distance_records().first().cloned().unwrap();
view_model.remove_record(workout.id);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 1);
}
}

View File

@ -0,0 +1,18 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod day_detail;
pub use day_detail::DayDetailViewModel;

View File

@ -0,0 +1,84 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{DayDetail, DayEdit, Singleton, SingletonImpl},
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
#[derive(Default)]
pub struct DayDetailViewPrivate {
container: Singleton,
view_model: RefCell<Option<DayDetailViewModel>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailViewPrivate {
const NAME: &'static str = "DayDetailView";
type Type = DayDetailView;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailViewPrivate {}
impl WidgetImpl for DayDetailViewPrivate {}
impl BoxImpl for DayDetailViewPrivate {}
impl SingletonImpl for DayDetailViewPrivate {}
glib::wrapper! {
pub struct DayDetailView(ObjectSubclass<DayDetailViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl DayDetailView {
pub fn new(view_model: DayDetailViewModel) -> Self {
let s: Self = Object::builder().build();
*s.imp().view_model.borrow_mut() = Some(view_model);
s.append(&s.imp().container);
s.view();
s
}
fn view(&self) {
let view_model = self.imp().view_model.borrow();
let view_model = view_model
.as_ref()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayDetail::new(view_model, {
let s = self.clone();
move || s.edit()
}));
}
fn edit(&self) {
let view_model = self.imp().view_model.borrow();
let view_model = view_model
.as_ref()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayEdit::new(view_model, {
let s = self.clone();
move || s.view()
}));
}
}

View File

@ -0,0 +1,182 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::App,
components::{DateRangePicker, DaySummary},
types::DayInterval,
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
/// The historical view will show a window into the main database. It will show some version of
/// daily summaries, daily details, and will provide all functions the user may need for editing
/// records.
pub struct HistoricalViewPrivate {
app: Rc<RefCell<Option<App>>>,
list_view: gtk::ListView,
date_range_picker: DateRangePicker,
}
#[glib::object_subclass]
impl ObjectSubclass for HistoricalViewPrivate {
const NAME: &'static str = "HistoricalView";
type Type = HistoricalView;
type ParentType = gtk::Box;
fn new() -> Self {
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |_, list_item| {
list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.set_child(Some(&DaySummary::new()));
});
let date_range_picker = DateRangePicker::default();
let s = Self {
app: Rc::new(RefCell::new(None)),
list_view: gtk::ListView::builder()
.factory(&factory)
.single_click_activate(true)
.show_separators(true)
.build(),
date_range_picker,
};
factory.connect_bind({
let app = s.app.clone();
move |_, list_item| {
let date = list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.item()
.and_downcast::<Date>()
.expect("should be a Date");
let summary = list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
.child()
.and_downcast::<DaySummary>()
.expect("should be a DaySummary");
if let Some(app) = app.borrow().clone() {
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date.date(), app.clone())
.await
.unwrap();
summary.set_data(view_model);
});
}
}
});
s
}
}
impl ObjectImpl for HistoricalViewPrivate {}
impl WidgetImpl for HistoricalViewPrivate {}
impl BoxImpl for HistoricalViewPrivate {}
glib::wrapper! {
pub struct HistoricalView(ObjectSubclass<HistoricalViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl HistoricalView {
pub fn new<SelectFn>(app: App, interval: DayInterval, on_select_day: Rc<SelectFn>) -> Self
where
SelectFn: Fn(chrono::NaiveDate) + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["historical"]);
*s.imp().app.borrow_mut() = Some(app);
s.imp().date_range_picker.connect_on_search({
let s = s.clone();
move |interval| s.set_interval(interval)
});
s.set_interval(interval);
s.imp().list_view.connect_activate({
let on_select_day = on_select_day.clone();
move |s, idx| {
// This gets triggered whenever the user clicks on an item on the list.
let item = s.model().unwrap().item(idx).unwrap();
let date = item.downcast_ref::<Date>().unwrap();
on_select_day(date.date());
}
});
let scroller = gtk::ScrolledWindow::builder()
.child(&s.imp().list_view)
.hexpand(true)
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
s.append(&s.imp().date_range_picker);
s.append(&scroller);
s
}
pub fn set_interval(&self, interval: DayInterval) {
let mut model = gio::ListStore::new::<Date>();
let mut days = interval.days().map(Date::new).collect::<Vec<Date>>();
days.reverse();
model.extend(days);
self.imp()
.list_view
.set_model(Some(&gtk::NoSelection::new(Some(model))));
self.imp().date_range_picker.set_interval(interval.start, interval.end);
}
}
#[derive(Default)]
pub struct DatePrivate {
date: RefCell<chrono::NaiveDate>,
}
#[glib::object_subclass]
impl ObjectSubclass for DatePrivate {
const NAME: &'static str = "Date";
type Type = Date;
}
impl ObjectImpl for DatePrivate {}
glib::wrapper! {
pub struct Date(ObjectSubclass<DatePrivate>);
}
impl Date {
pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build();
*s.imp().date.borrow_mut() = date;
s
}
pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow()
}
}

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