From 46cf46c272e858697c0a6a761c5f683da9e9a84e Mon Sep 17 00:00:00 2001 From: keinsell Date: Sun, 19 Jan 2025 16:22:34 +0100 Subject: [PATCH] add experimental terminal user interface --- .gitignore | 3 +- Cargo.toml | 30 +- docs/{assets => }/inventory.md | 0 docs/reference/.widgets/area-chart.txt | 14 + .../.widgets/horizontal-bar-chart.txt | 8 + .../.widgets/neuronek-welcome-screen.txt | 22 + docs/reference/.widgets/oscilloscope.txt | 15 + .../.widgets/probabilistic-timeline.txt | 9 + docs/reference/.widgets/scatter-plot.txt | 10 + .../.widgets/synthetic-widget-design.md | 590 +++++++ docs/reference/.widgets/thermometer.txt | 19 + docs/reference/tuirealm.md | 1498 +++++++++++++++++ docs/report.md | 84 + docs/tui/ingestion.md | 89 + rust-toolchain.toml | 2 +- src/analyzer/mod.rs | 244 +-- src/analyzer/model.rs | 66 + src/analyzer/service.rs | 118 ++ src/cli/analyze.rs | 36 +- src/{ => cli}/formatter.rs | 0 src/cli/ingestion.rs | 26 +- src/cli/mod.rs | 9 +- src/cli/stats.rs | 215 +++ src/cli/substance.rs | 10 +- src/core/config.rs | 42 + src/core/error_handling.rs | 12 + src/core/foundation.rs | 5 + src/core/logging.rs | 13 + src/core/mod.rs | 14 + src/{migration => database}/README.md | 0 src/{orm => database/entities}/ingestion.rs | 2 +- src/{orm => database/entities}/mod.rs | 2 +- src/database/entities/prelude.rs | 3 + src/{orm => database/entities}/substance.rs | 2 +- .../substance_route_of_administration.rs | 3 +- ...ubstance_route_of_administration_dosage.rs | 3 +- ...substance_route_of_administration_phase.rs | 3 +- src/{migration => database}/justfile | 0 .../20250101000001_add_ingestion_table.sql | 18 +- .../20250101000002_import_substance.sql | 89 +- .../20250101235153_drop_unrelated_data.sql | 21 +- .../20250104060831_update_dosage_bounds.sql | 0 ...f_administration_classification_values.sql | 32 + .../migrations/atlas.sum | 0 .../mod.rs => database/migrator.rs} | 2 +- src/database/mod.rs | 5 + src/{migration => database}/schema.sql | 12 +- src/ingestion/command.rs | 76 + src/ingestion/mod.rs | 42 +- src/ingestion/model.rs | 35 + src/ingestion/query.rs | 47 + src/journal/mod.rs | 9 +- src/main.rs | 49 +- ...f_administration_classification_values.sql | 12 - src/orm/prelude.rs | 3 - src/prelude.rs | 5 +- src/substance/error.rs | 12 + src/substance/mod.rs | 18 +- src/substance/repository.rs | 44 +- .../{ => route_of_administration}/dosage.rs | 41 +- src/substance/route_of_administration/mod.rs | 2 + .../route_of_administration/phase.rs | 7 + src/tui/app.rs | 687 ++++++++ src/tui/core.rs | 68 + src/tui/events.rs | 73 + src/tui/layout/footer.rs | 356 ++++ src/tui/layout/header.rs | 202 +++ src/tui/layout/help.rs | 372 ++++ src/tui/layout/mod.rs | 3 + src/tui/mod.rs | 134 +- src/tui/theme.rs | 62 + src/tui/views/ingestion/create_ingestion.rs | 755 +++++++++ src/tui/views/ingestion/get_ingestion.rs | 123 ++ src/tui/views/ingestion/list_ingestion.rs | 485 ++++++ src/tui/views/ingestion/mod.rs | 3 + src/tui/views/loading.rs | 129 ++ src/tui/views/mod.rs | 5 + src/tui/views/welcome.rs | 100 ++ src/tui/widgets/dosage.rs | 15 + src/tui/widgets/mod.rs | 48 + src/utils.rs | 75 +- 81 files changed, 6870 insertions(+), 627 deletions(-) rename docs/{assets => }/inventory.md (100%) create mode 100644 docs/reference/.widgets/area-chart.txt create mode 100644 docs/reference/.widgets/horizontal-bar-chart.txt create mode 100644 docs/reference/.widgets/neuronek-welcome-screen.txt create mode 100644 docs/reference/.widgets/oscilloscope.txt create mode 100644 docs/reference/.widgets/probabilistic-timeline.txt create mode 100644 docs/reference/.widgets/scatter-plot.txt create mode 100644 docs/reference/.widgets/synthetic-widget-design.md create mode 100644 docs/reference/.widgets/thermometer.txt create mode 100644 docs/reference/tuirealm.md create mode 100644 docs/report.md create mode 100644 docs/tui/ingestion.md create mode 100644 src/analyzer/model.rs create mode 100644 src/analyzer/service.rs rename src/{ => cli}/formatter.rs (100%) create mode 100644 src/cli/stats.rs create mode 100644 src/core/config.rs create mode 100644 src/core/error_handling.rs create mode 100644 src/core/foundation.rs create mode 100644 src/core/logging.rs create mode 100644 src/core/mod.rs rename src/{migration => database}/README.md (100%) rename src/{orm => database/entities}/ingestion.rs (90%) rename src/{orm => database/entities}/mod.rs (76%) create mode 100755 src/database/entities/prelude.rs rename src/{orm => database/entities}/substance.rs (95%) rename src/{orm => database/entities}/substance_route_of_administration.rs (96%) rename src/{orm => database/entities}/substance_route_of_administration_dosage.rs (95%) rename src/{orm => database/entities}/substance_route_of_administration_phase.rs (95%) rename src/{migration => database}/justfile (100%) rename src/{migration => database}/migrations/20250101000001_add_ingestion_table.sql (80%) rename src/{migration => database}/migrations/20250101000002_import_substance.sql (99%) rename src/{migration => database}/migrations/20250101235153_drop_unrelated_data.sql (77%) rename src/{migration => database}/migrations/20250104060831_update_dosage_bounds.sql (100%) create mode 100755 src/database/migrations/20250108183655_update_route_of_administration_classification_values.sql rename src/{migration => database}/migrations/atlas.sum (100%) rename src/{migration/mod.rs => database/migrator.rs} (98%) mode change 100755 => 100644 create mode 100755 src/database/mod.rs rename src/{migration => database}/schema.sql (94%) create mode 100644 src/ingestion/command.rs create mode 100644 src/ingestion/model.rs create mode 100644 src/ingestion/query.rs delete mode 100755 src/migration/migrations/20250108183655_update_route_of_administration_classification_values.sql delete mode 100755 src/orm/prelude.rs create mode 100644 src/substance/error.rs rename src/substance/{ => route_of_administration}/dosage.rs (60%) create mode 100644 src/tui/app.rs create mode 100644 src/tui/core.rs create mode 100644 src/tui/events.rs create mode 100644 src/tui/layout/footer.rs create mode 100644 src/tui/layout/header.rs create mode 100644 src/tui/layout/help.rs create mode 100644 src/tui/layout/mod.rs create mode 100644 src/tui/theme.rs create mode 100644 src/tui/views/ingestion/create_ingestion.rs create mode 100644 src/tui/views/ingestion/get_ingestion.rs create mode 100644 src/tui/views/ingestion/list_ingestion.rs create mode 100644 src/tui/views/ingestion/mod.rs create mode 100644 src/tui/views/loading.rs create mode 100644 src/tui/views/mod.rs create mode 100644 src/tui/views/welcome.rs create mode 100644 src/tui/widgets/dosage.rs create mode 100644 src/tui/widgets/mod.rs diff --git a/.gitignore b/.gitignore index 3cc347ee..0e19f275 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,5 @@ devenv.local.nix /dev.db # Enable persistance of database defs -!.idea/dataSources.xml \ No newline at end of file +!.idea/dataSources.xml +/debug.log diff --git a/Cargo.toml b/Cargo.toml index 2a98e862..0c8607d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ version = "0.0.1-alpha.2" edition = "2024" repository = "https://github.com/keinsell/neuronek" license-file = "LICENSE" +expand = ["sea-orm-migration/native"] [[bin]] name = "neuronek" @@ -20,8 +21,8 @@ license = false eula = false [dependencies] +async-std = { version = "1.12.0", features = ["attributes", "async-global-executor", "async-process"] } assert_cmd = "2.0.16" -async-std = { version = "1", features = ["attributes", "async-global-executor"] } atty = "0.2.14" chrono = { version = "0.4.39", features = ["std", "serde", "iana-time-zone", "rkyv"] } chrono-english = "0.1.7" @@ -34,7 +35,7 @@ delegate = "0.13.1" derivative = "2.2.0" directories = { version = "5.0.1" } float-pretty-print = "0.1.1" -futures = "0.3.31" +futures = { version = "0.3.31", features = ["futures-executor", "thread-pool"] } hashbrown = "0.15.2" iso8601-duration = "0.2.0" lazy_static = "1.5.0" @@ -48,28 +49,31 @@ serde = { version = "1.0.216", features = ["derive", "std", "unstable"] } tabled = { version = "0.17.0", features = ["std", "macros", "ansi", "derive", "tabled_derive"] } typed-builder = "0.20.0" sea-orm-migration = { version = "1.1.0", features = [ - "runtime-async-std-rustls", - "sqlx-sqlite" + "sqlx-sqlite", + "runtime-async-std-rustls" ] } serde_json = "1.0.134" -rstest = "0.24.0" owo-colors = "4.1.0" chrono-humanize = "0.2.3" -ratatui = { version = "0.26.0", features = ["all-widgets", "macros", "serde"] } +ratatui = { version = "0.26.1", features = ["all-widgets"] } crossterm = "0.27.0" async-trait = "0.1.85" indicatif = "0.17.9" -tracing-subscriber = "0.3.19" +tracing-subscriber = "0.3.18" tracing-indicatif = "0.3.8" -tracing = { version = "0.1.25", features = ["log", "std", "attributes", "valuable"] } -itertools = "0.13.0" -rayon = "1.10.0" +tracing = "0.1.40" predicates = "3.1.3" -serde_with = "3.12.0" +serde_derive = "1.0.217" +thiserror = "2.0.11" +ratatui-textarea = "0.4.1" +regex = "1.11.1" +cached = {version = "0.54.0", features = ["disk_store", "async"]} +derive_more = {version = "1.0.0", features = ["full"]} +strum = "0.26.3" [features] -# Enables the experimental TUI interface on `neuronek` binary. -experimental-tui = ["ratatui/all-widgets"] +default = ["tui"] +tui = [] [expand] color = "always" diff --git a/docs/assets/inventory.md b/docs/inventory.md similarity index 100% rename from docs/assets/inventory.md rename to docs/inventory.md diff --git a/docs/reference/.widgets/area-chart.txt b/docs/reference/.widgets/area-chart.txt new file mode 100644 index 00000000..31ea5a26 --- /dev/null +++ b/docs/reference/.widgets/area-chart.txt @@ -0,0 +1,14 @@ +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + +Cumulative Duration + | . + 160| / \ + | / \ + 120| / \ + | / \ + 80| / \ + | / \ + 40| / \ + | / \ + 0 +-----+-----+----+-----+-------+ + O C P CD A \ No newline at end of file diff --git a/docs/reference/.widgets/horizontal-bar-chart.txt b/docs/reference/.widgets/horizontal-bar-chart.txt new file mode 100644 index 00000000..fb58cbc7 --- /dev/null +++ b/docs/reference/.widgets/horizontal-bar-chart.txt @@ -0,0 +1,8 @@ +Phase Durations (minutes): +Onset | █████ (5) +Come-up | ██████████ (10) +Peak | █████████████████████████████████████████ (45) +Comedown | ████████████████████████████████████ (39) +Afterglow | ████████████████████████████████████████████████████████████ (81) + +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC \ No newline at end of file diff --git a/docs/reference/.widgets/neuronek-welcome-screen.txt b/docs/reference/.widgets/neuronek-welcome-screen.txt new file mode 100644 index 00000000..49ecdcd2 --- /dev/null +++ b/docs/reference/.widgets/neuronek-welcome-screen.txt @@ -0,0 +1,22 @@ +╭─────────────────────────────────────────────────────────────────────────╮ +│ Home │ 2 Ingestions │ 0 Settings v0.0.1-alpha.2│ +╰─────────────────────────────────────────────────────────────────────────╯ +╭Home─────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ ███▄ █ ▓█████ █ ██ ██▀███ ▒█████ ███▄ █ ▓█████ ██ ▄█ │ +│ ██ ▀█ █ ▓█ ▀ ██ ▓██▒▓██ ▒ ██▒▒██▒ ██▒ ██ ▀█ █ ▓█ ▀ ██▄█▒ │ +│ ▓██ ▀█ ██▒▒███ ▓██ ▒██░▓██ ░▄█ ▒▒██░ ██▒▓██ ▀█ ██▒▒███ ▓███▄░ │ +│ ▓██▒ ▐▌██▒▒▓█ ▄ ▓▓█ ░██░▒██▀▀█▄ ▒██ ██░▓██▒ ▐▌██▒▒▓█ ▄ ▓██ █▄ │ +│ ▒██░ ▓██░░▒████▒▒▒█████▓ ░██▓ ▒██▒░ ████▓▒░▒██░ ▓██░░▒████▒▒██▒ █ │ +│ ░ ▒░ ▒ ▒ ░░ ▒░ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ ░░ ▒░ ░▒ ▒▒ ▓ │ +│ │ +│ Press 2 to manage ingestions │ +│ Press 0 to access settings │ +│ Press ? for help │ +│ │ +│ │ +│ │ +│ │ +╰─────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/docs/reference/.widgets/oscilloscope.txt b/docs/reference/.widgets/oscilloscope.txt new file mode 100644 index 00000000..238a93df --- /dev/null +++ b/docs/reference/.widgets/oscilloscope.txt @@ -0,0 +1,15 @@ +Intensity + ^ + | High + | . + | / \__ + | / \_______ + | / \__ + | / \ + +-------------------------> Time + Onset Come-up Peak Comedown Afterglow + + Current Phase: Peak + Intensity: █████▒░░░░ (Medium) + Overall Progress: [==============] 85% + Estimated End: 20:21 UTC \ No newline at end of file diff --git a/docs/reference/.widgets/probabilistic-timeline.txt b/docs/reference/.widgets/probabilistic-timeline.txt new file mode 100644 index 00000000..6eca7464 --- /dev/null +++ b/docs/reference/.widgets/probabilistic-timeline.txt @@ -0,0 +1,9 @@ +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + +Phase Durations (Probabilistic): + +Onset : [==--] (Most likely: Short) +Come-up : [--====-] (Most likely: Medium) +Peak : [-----======------] (Most likely: Around Average) +Comedown : [---=========------] (Most likely: Slightly Longer) +Afterglow : [-------===========---------] (Wide Range, Centered Around Average) \ No newline at end of file diff --git a/docs/reference/.widgets/scatter-plot.txt b/docs/reference/.widgets/scatter-plot.txt new file mode 100644 index 00000000..0c6e3df6 --- /dev/null +++ b/docs/reference/.widgets/scatter-plot.txt @@ -0,0 +1,10 @@ +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + +Intensity + High ^ + | * + | +Medium + * + | + Low +-----------> Time + Onset Come-up Peak Comedown Afterglow \ No newline at end of file diff --git a/docs/reference/.widgets/synthetic-widget-design.md b/docs/reference/.widgets/synthetic-widget-design.md new file mode 100644 index 00000000..01765ba9 --- /dev/null +++ b/docs/reference/.widgets/synthetic-widget-design.md @@ -0,0 +1,590 @@ +You are advanced terminal user interface designer, your objective is to understand requirements provided by user and respond only in multiple concepts of terminal user interface widget/components that can be applied into interface of end product to deliver maximum value of product features to end-user. Your design should be made in ASCII. Be ultimately creative about your designs and make sure you do not list all the same designs, explore different ways of designing thing and let the user choice ones that he is like. You should never use emoji for designing your components and you should follow all the best, opiniated, research-backed UX design methods along with CLI/TUI designing guidelines. Try to extend specification made by user by doing your own research on topics around user specification and experiment with your own ideas about user's product. + +--- + +You are a terminal user interface (TUI) design specialist, tasked with creating ASCII-based widget concepts and complete layouts for a CLI application. Your goal is to interpret user requirements and deliver innovative, research-backed TUI designs that maximize usability and value. Focus on the following: + +1. **Component Design**: Generate individual ASCII widgets that are modular, reusable, and adhere to TUI best practices. Each widget should be self-contained and functional within a larger interface. + +2. **Complete Layouts**: Design full-screen or multi-panel ASCII layouts that integrate multiple widgets cohesively. Ensure layouts are intuitive, visually balanced, and optimized for terminal environments. + +3. **Creativity and Variety**: Explore diverse design approaches for each concept. Avoid repetitive designs and experiment with unique visual representations, interaction patterns, and information hierarchies. + +4. **UX Principles**: Apply research-backed UX methodologies, including clarity, consistency, and accessibility. Prioritize readability, logical flow, and user efficiency in all designs. + +5. **Specification Extension**: Extend the user’s requirements by incorporating relevant research and innovative ideas. Propose enhancements or additional features that align with the product’s goals. + +6. **ASCII Constraints**: Use only ASCII characters for all designs. Avoid emojis, Unicode symbols, or any non-ASCII elements. Ensure designs are compatible with standard terminal environments. + +7. **Attention to Detail**: Focus on fine-grained details such as alignment, spacing, and visual hierarchy. Ensure designs are polished and professional, suitable for production use. + +Deliver designs that are both functional and visually appealing, enabling the user to select the most effective concepts for their application. Your domain of research for applications is neuroscience, pharmacology, psychology, psychonautics, biohacking, research, and other related fields. + +--- # User Requirements + +- User should be able to read a prettified report of the user's ingestion, it should contain all information from the ingestion analyzer. +- Application has`Ingestion` a model which contains information about `substance_name`, `dosage` (which is float value from base unit which is kilogram), `route_of_administration` (representing the route by which `substance_name` was administrated) and `ingested_at` which is UTC DateTime when ingestion was ingested. +- Application feature `IngestionAnalyzer` which ingests (so far) information about `Ingestion` and `Substance` to extract and match patterns from a database which may be applicable to given ingestion, such analysis provide: approximate ingestion start date (which is date when substance was ingested), ingestion end date (which is estimated date when subjective effects of substance should not be potentially present or just aftereffects will be present), dosage classification (from threshold, light, common, strong, heavy), information about phases (from classification Onset, Come-up, Peak, Comedown, Afterglow) where for every phase we know range how much one phase may take and by name of classification we know when substance presence is the strongest and when the weakest. + +## Functional Requirements + +### Ingestion Overview + +- Show the name of the ingested substance +- Show dosage ingested (in the suggested unit, ex. mg instead of kg) +- Show the administration route which was used to ingest substance +- Show time when ingestion has happened and expected time when it will be ending (we assume complete ending of ingestion as afterglow is ended) + +### Progression Tracking + +- Show overall progress of ingestion as percentage +- Show time elapsed to end of up effects and separate extension of progression by tracking afterglow which often is associated with negative side-effects. +- Phase-specific progress + +### Timeline Visualisation (PhaseViz) + +- Display chronological progression of phases +- Show the current position in the timeline +- Indicate time-ranges when stages are transitioning +- Show real-time progress within the current phase +- Display expected duration of each phase + +### Peak Experience Control (PEC) + +- Current phase status with clear visual indication +- Expected duration of the current phase +- Time remaining in the current phase +- Phase intensity level +- Phase-specific characteristics + +## Non-Functional Requirements + +- Keyboard navigation for all components +- Focus states for interactive elements +- Responsive design for different terminal sizes +- All numerical values must include units +- Time displays must include timezone context +- Progress indicators must be visually distinct +- No emojis +- Phase transitions must be clearly marked +- Intensity levels must use consistent color coding +- Dosage strength additionally visually represented + + +--- # Design Examples (DO NOT USE) + +We're designing a page of interactive terminal which is representing single ingestion, +there is example of actual CLI application that is currently implemented yet that's not +enough as data representation is not good enough. + + + ███▄ █ ▓█████ █ ██ ██▀███ ▒█████ ███▄ █ ▓█████ ██ ▄█ + ██ ▀█ █ ▓█ ▀ ██ ▓██▒▓██ ▒ ██▒▒██▒ ██▒ ██ ▀█ █ ▓█ ▀ ██▄█▒ + ▓██ ▀█ ██▒▒███ ▓██ ▒██░▓██ ░▄█ ▒▒██░ ██▒▓██ ▀█ ██▒▒███ ▓███▄░ + ▓██▒ ▐▌██▒▒▓█ ▄ ▓▓█ ░██░▒██▀▀█▄ ▒██ ██░▓██▒ ▐▌██▒▒▓█ ▄ ▓██ █▄ + ▒██░ ▓██░░▒████▒▒▒█████▓ ░██▓ ▒██▒░ ████▓▒░▒██░ ▓██░░▒████▒▒██▒ █ + ░ ▒░ ▒ ▒ ░░ ▒░ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ ░░ ▒░ ░▒ ▒▒ ▓ + v0.0.1-alpha.2 +╭Ingestion Details──────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +││█████████████████████████████████████████ | +│╰──────────────────────────────────────────────────────────────────────│ +│╭─────────────────────────────────────────────────────────────────────╮│ +││Phase Duration Start End ││ +││Onset 05:00 17:21 17:26 ││ +││Comeup 10:00 17:26 17:36 ││ +││Peak 45:00 17:36 18:21 ││ +│╰─────────────────────────────────────────────────────────────────────╯│ +╰───────────────────────────────────────────────────────────────────────╯ + h Back │Esc Close │? Help Ingestion Details + + + ███▄ █ ▓█████ █ ██ ██▀███ ▒█████ ███▄ █ ▓█████ ██ ▄█ + ██ ▀█ █ ▓█ ▀ ██ ▓██▒▓██ ▒ ██▒▒██▒ ██▒ ██ ▀█ █ ▓█ ▀ ██▄█▒ + ▓██ ▀█ ██▒▒███ ▓██ ▒██░▓██ ░▄█ ▒▒██░ ██▒▓██ ▀█ ██▒▒███ ▓███▄░ + ▓██▒ ▐▌██▒▒▓█ ▄ ▓▓█ ░██░▒██▀▀█▄ ▒██ ██░▓██▒ ▐▌██▒▒▓█ ▄ ▓██ █▄ + ▒██░ ▓██░░▒████▒▒▒█████▓ ░██▓ ▒██▒░ ████▓▒░▒██░ ▓██░░▒████▒▒██▒ █ + ░ ▒░ ▒ ▒ ░░ ▒░ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ ░░ ▒░ ░▒ ▒▒ ▓ + v0.0.1-alpha.2 +╭Ingestion Details──────────────────────────────────────────────────────╮ +│ Caffeine ● Active 2023-10-27 17:21 UTC │ +│───────────────────────────────────────────────────────────────────────│ +│█████████████████████████████████████████░67%░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│───────────────────────────────────────────────────────────────────────│ +│ │ +│ | +│╰──────────────────────────────────────────────────────────────────────│ +┌─[Timeline]──────────────────────────────────────────────────────────────────┐ +│ [Onset]----[Comeup]----[Peak]==========|--------[Afterglow] │ +│ 17:21 17:26 17:36 18:21 19:21 │ +│ ▲ │ +│ │ Now: 18:36 │ +└─────────────────────────────────────────────────────────────────────────────┘ +╰───────────────────────────────────────────────────────────────────────╯ + h Back │Esc Close │? Help Ingestion Details + + +┌────────────────────────────────────────────┐ +│ Ingestion Details: Caffeine (200 mg oral) │ +├────────────────────────────────────────────┤ +│ Started: 17:21 UTC │ +│ Estimated End: 20:21 UTC │ +│ Current Phase: Afterglow (Low Intensity) │ +├────────────────────────────────────────────┤ +│ Progress: ██████████████░░░░░░░░░ (60%) │ +└────────────────────────────────────────────┘ + +┌──────────────────────────────────────────┐ +│ Caffeine (200 mg oral) │ +│ Started: 17:21 UTC, End: ~20:21 UTC │ +│ Phase: Afterglow (Low Intensity) │ +├──────────────────────────────────────────┤ +│ Progress: ██████████░░░░░░░░ (60%) | +└──────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Ingestion: Caffeine (10mg, oral) ● Active 2023-10-27 17:21 UTC │ +└──────────────────────────────────────────────────────────────────────────────┘ + +(.)--O-->(.)--C--> (*)--P--> (.)--CD--> (.)--A--> O + Now Expected Peak + +|----+----+----+----+----+----+----+----+----+----| Timeline +O C P CD A E (Phase Initials) +^ Current Position + +┌─[Timeline]──────────────────────────────────────────────────────────────────┐ +│ [Onset]----[Comeup]----[Peak]==========|--------[Afterglow] │ +│ 17:21 17:26 17:36 18:21 19:21 │ +│ ▲ │ +│ │ Now: 18:36 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌───────────────┬───────┬─────────┬────────┐ +│ Phase │ Prog │ Dur │ Int │ +├───────────────┼───────┼─────────┼────────┤ +│ Onset │██████ │ 5min │(Green) │ +│ Come-up │██████████████████│10min │(Yellow)│ +│ Peak │████████████████████████████████████████████████│45min │(Red) │ +│ Comedown │██████████████████░░░░░░░░░░░░░░░░░░░░░░│39min │(Orange)│ +│ Afterglow │████████████████████████████░░░░░░░░░░░░░░│81min │(Blue) │ +└───────────────┴───────┴─────────┴────────┘ +Total Progress: 60% + +┌─[Ingestion Progress]───────────────────────────┐ ┌─[Phase Details]───────────┐ +│ Overall: [========================-----] 75% │ │ Current: Peak │ +│ ├─> Up Effects: [===================▨----] 83% │ │ Intensity: █████░░░░░ │ +│ └─> Afterglow: [▨------------------------] 0% │ │ Duration: 45m (15m left) │ +└────────────────────────────────────────────────┘ └───────────────────────────┘ + +│ ─────── Phase Timeline ─────── │ +│ │ +│ Onset Come-up Peak Comedown Afterglow │ +│ [========>--] [========>--] [========] [========] [========] │ +│ ████████████████████████████████████│ +│ 14:30 15:00 16:30 19:30 21:00 22:00 + +Intensity: [████░░░░░] (Strong) + +Dosage Strength: █ █ █ █ ░ (Common) + +Intensity: [████░░░░░] (Strong) + + +[O]--[C]----[P]----[CD]----[A] + ▲ + Current Time + +Progress: [██████████░░░░░░░░░] 40% + +Overall Progress: [========----] 60% + +[||||||||......] 60% Complete + +Up: ████████░░░░ 80% +Down: ▒▒▒▒ 10% + +Onset --> Come-up --> Peak ----> Comedown --> Afterglow + ^ Current + +[Onset: 5m] [Come-up: 10m] [Peak: 45m]--------[Comedown: 39m]-----[Afterglow: 1h 21m] + ▲ Now + +|Onset|Come-up|====Peak====|Comedown|----Afterglow----| + ^ + +Dosage Strength: [●○○○○] Threshold +Dosage: ░░░░░●░░░░░ Light +Strength: [=--] Common + + +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + +[+] Current Phase: PEAK ────────────────────────────────────────────── + Intensity: █████▒░░░░ (Medium) + Duration: 45 minutes + Remaining: ~12 minutes + +[-] Timeline ─────────────────────────────────────────────────────────── + Onset: [=====] (17:21 - 17:26) + Come-up: [========] (17:26 - 17:36) + Peak: [==============] (17:36 - 18:21) <-- Currently Here + Comedown: [=========] (18:21 - 19:00) + Afterglow: [=========] (19:00 - 20:21) + +[+] Overall Progress: 85% ───────────────────────────────────────────── + +Ends Approx: 20:21 UTC + + + +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + +Intensity + ^ + | High + | . + | / \__ + | / \_______ + | / \__ + | / \ + +-------------------------> Time + Onset Come-up Peak Comedown Afterglow + + Current Phase: Peak + Intensity: █████▒░░░░ (Medium) + Overall Progress: [==============] 85% + Estimated End: 20:21 UTC + + +Phase Durations (Probabilistic): + +Onset : [==--] (Most likely: Short) +Come-up : [--====-] (Most likely: Medium) +Peak : [-----======------] (Most likely: Around Average) +Comedown : [---=========------] (Most likely: Slightly Longer) +Afterglow : [-------===========---------] (Wide Range, Centered Around Average) + + +Factor Influence on End Time Variability +------------------------------------------------------ +Metabolism Rate |========--------------| +Dosage Absorption |----==================| +Individual Sensitivity|-------========-------| + + +Estimated End Time Distribution: + +20:00-20:05 | ## +20:05-20:10 | #### +20:10-20:15 | ####### +20:15-20:20 | ########## +20:20-20:25 | ####### +20:25-20:30 | #### +20:30-20:35 | ## + +┌─[Metabolic Rate]──────────────────────────────┐ +│ Current: █████▒░░░░ (Medium) │ +│ Trend: ↗ (Increasing) │ +│ Last 24h: █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ │ +└───────────────────────────────────────────────┘ + +┌─[Circadian Rhythm]────────────────────────────┐ +│ Wake: ███████████████████████████████████████ │ +│ Sleep: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ Optimal Dosage Window: [=====] │ +└───────────────────────────────────────────────┘ + +┌─[Dosage History]──────────────────────────────┐ +│ █ █ █ █ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ │ +│ 2023-10-27 17:21 UTC - 200 mg │ +│ 2023-10-28 09:15 UTC - 100 mg │ +│ 2023-10-29 14:30 UTC - 150 mg │ +└───────────────────────────────────────────────┘ + +┌─[Substance Stack]─────────────────────────────┐ +│ Caffeine: █████░░░░░░ (50%) │ +│ L-Theanine: ██████████ (100%) │ +│ Modafinil: █░░░░░░░░░ (10%) │ +└───────────────────────────────────────────────┘ + +┌─[Dosage Optimization]─────────────────────────┐ +│ Current: █████▒░░░░ (Medium) │ +│ Optimal: ██████████ (High) │ +│ Tolerance: ██████████████████████████████████ │ +└───────────────────────────────────────────────┘ + +┌─[Dosage History]──────────────────────────────┐ +│ █ █ █ █ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ │ +│ 2023-10-27 17:21 UTC - 200 mg │ +│ 2023-10-28 09:15 UTC - 100 mg │ +│ 2023-10-29 14:30 UTC - 150 mg │ +└───────────────────────────────────────────────┘ + + +┌───────────────────────────────────────────────────────────────┐ +│ Substance: Caffeine (200 mg oral) │ +│ Ingestion Time: 17:21 UTC │ +│ Estimated End: 20:21 UTC │ +│ Current Phase: Afterglow (Low Intensity) │ +│ Progress: ██████████████░░░░░░░░░ (60%) │ +│───────────────────────────────────────────────────────────────┤ +│ │ +│ [Onset]----[Comeup]----[Peak]==========|--------[Afterglow] │ +│ 17:21 17:26 17:36 18:21 19:21 │ +│ ▲ │ +│ │ Now: 18:36 │ +│ │ +╰───────────────────────────────────────────────────────────────╯ + +┌─────────────────────────────────────────────────┐ +│ Overall: ████████████████░░░░░░░░░░░░░░░░░ 60% │ +└─────────────────────────────────────────────────┘ + +Neurological Response Map +------------------------- +Cognitive ████░░░░ Moderate Activation +Motor ███░░░░░ Slight Stimulation +Emotional █████░░░ High Responsiveness +Sensory ████░░░░ Enhanced Perception +Autonomic ███░░░░░ Mild Systemic Impact + + +Temporal Intensity Fractal +-------------------------- + Peak + / \ + / \ + / \ + / \ + / \ + / \ + Onset Comedown + + +Physiological Stress Indicator +------------------------------- +Cardiovascular ████░░░░ Moderate Elevation +Respiratory ███░░░░░ Slight Acceleration +Metabolic █████░░░ High Metabolic Rate +Neurological ████░░░░ Moderate Stimulation + +Absorption Efficiency +--------------------- +Oral ████████░░ 90% Efficiency +Sublingual ██████████ 100% Efficiency +Intranasal ███████░░░ 85% Efficiency +Intravenous ██████████ 100% Efficiency + +Tolerance and Sensitivity Tracker +---------------------------------- +Baseline Sensitivity ████████████████ +Current Tolerance ████████░░░░░░░░ +Adaptive Response ███████████░░░░░ + +Biochemical Interaction Web +--------------------------- +Neurotransmitters + Dopamine ●───┐ + Serotonin ●───┤ + Norepineph. ●───┘ +Hormones + Cortisol ● + Melatonin ● + +Chronobiological Rhythm +----------------------- +Circadian Cycle + Awake ████████████████████████ + Asleep ░░░░░░░░░░░░░░░░░░░░░░░░ +Metabolic Rate + Morning ███░░░░ + Afternoon ████░░░ + Evening ██░░░░░ + +Intensity Heatmap +----------------- +Time 00 01 02 03 04 05 06 07 08 09 10 +Onset ░░░░░░░░░░░░░░░░░░░░░░ +Come-up ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ +Peak ██████████████████████ +Comedown ████████████░░░░░░░░░ +Afterglow ██░░░░░░░░░░░░░░░░░░ + +Dosage Strength Radar +--------------------- + Threshold + ● + Light ● + Common + ● + Strong + ● + Heavy + ● + +Substance Timeline [Caffeine] +----------------------------- +O---C---P---CD---A +│ │ │ │ │ +│ │ │ │ Afterglow +│ │ │ Comedown +│ │ Peak +│ Come-up +Onset + + +Neurochemical Resonance +----------------------- +Dopamine ●───┐ +Serotonin ●───┤ +Norepineph. ●───┘ +Resonance Pattern: + [████████░░] Harmonic + +Psychonautic Resonance Mandala +------------------------------ + Cognitive + / \ + Emotional Physical + | ● | + | / \ | + Perceptual Spiritual + +Metabolic Fractal Mapper +------------------------ +Initial State: [░░░░░░░░] +Transformation: [████░░░░] +Final State: [██████░░] +Fractal Pattern: + /\ + / \ + / \ + / \ + +Quantum Probability Compass +--------------------------- +Onset N + ╱│╲ +Come-up W●─●E + ╲│/ +Peak S +Probability Vectors: + Expected: [████████░░] + Actual: [████████▓░] + +Neuroplastic Adaptation Mesh +---------------------------- +Baseline: [░░░░░░░░] +Adaptation: [████░░░░] +Plasticity: [██████░░] +Neural Network: + ●───●───● + \ \ \ + ●───●───● + +┌─[Ingestion Details]───────────────────────────────────────────────┐ +│ Substance: Caffeine (200 mg oral) │ +│ Ingestion Time: 17:21 UTC │ +│ Estimated End: 20:21 UTC │ +│ Current Phase: Afterglow (Low Intensity) │ +│ Progress: ██████████████░░░░░░░░░ (60%) │ +├───────────────────────────────────────────────────────────────────┤ +│ [Onset]----[Comeup]----[Peak]==========|--------[Afterglow] │ +│ 17:21 17:26 17:36 18:21 19:21 │ +│ ▲ │ +│ │ Now: 18:36 │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Phase Details]───────────────────────────────────────────────────┐ +│ Current Phase: Afterglow │ +│ Intensity: █████░░░░░ (Medium) │ +│ Duration: 45 minutes │ +│ Remaining: ~12 minutes │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Overall Progress]───────────────────────────────────────────────┐ +│ ████████████████░░░░░░░░░░░░░░░░░ 60% │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Neurological Response]───────────────────────────────────────────┐ +│ Cognitive ████░░░░ Moderate Activation │ +│ Motor ███░░░░░ Slight Stimulation │ +│ Emotional █████░░░ High Responsiveness │ +│ Sensory ████░░░░ Enhanced Perception │ +│ Autonomic ███░░░░░ Mild Systemic Impact │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Physiological Stress]────────────────────────────────────────────┐ +│ Cardiovascular ████░░░░ Moderate Elevation │ +│ Respiratory ███░░░░░ Slight Acceleration │ +│ Metabolic █████░░░ High Metabolic Rate │ +│ Neurological ████░░░░ Moderate Stimulation │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Dosage History]──────────────────────────────────────────────────┐ +│ █ █ █ █ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ │ +│ 2023-10-27 17:21 UTC - 200 mg │ +│ 2023-10-28 09:15 UTC - 100 mg │ +│ 2023-10-29 14:30 UTC - 150 mg │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Substance Stack]─────────────────────────────────────────────────┐ +│ Caffeine: █████░░░░░░ (50%) │ +│ L-Theanine: ██████████ (100%) │ +│ Modafinil: █░░░░░░░░░ (10%) │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Tolerance and Sensitivity]───────────────────────────────────────┐ +│ Baseline Sensitivity ████████████████ │ +│ Current Tolerance ████████░░░░░░░░ │ +│ Adaptive Response ███████████░░░░░ │ +└───────────────────────────────────────────────────────────────────┘ + +┌─[Biochemical Interaction]─────────────────────────────────────────┐ +│ Neurotransmitters │ +│ Dopamine ●───┐ │ +│ Serotonin ●───┤ │ +│ Norepine + + + v0.0.1-alpha.2 +╭Ingestion Details────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Summary │┌Timeline───────────────────────────────────────────────────────────────────────────────────┐│ +│ caffeine ▃ 100mg ││OnComeup Peak Comedown ││ +│ 00:15 → 07:15 ││━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ +│ 4% ││ ▲ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ │└───────────────────────────────────────────────────────────────────────────────────────────┘│ +│ │┌Current Phase──────────────────────────────────────────────────────────────────────────────┐│ +│ ││ Peak 2% ││ +│ ││ 44m remaining ││ +│ ││ ││ +│ ││ ││ +│ ││ ││ +│ │└───────────────────────────────────────────────────────────────────────────────────────────┘│ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + + + + + diff --git a/docs/reference/.widgets/thermometer.txt b/docs/reference/.widgets/thermometer.txt new file mode 100644 index 00000000..62d05b5c --- /dev/null +++ b/docs/reference/.widgets/thermometer.txt @@ -0,0 +1,19 @@ +Ingestion: Caffeine (Oral) - 200 mg - Started: 17:21 UTC + + Intensity: + High | + | Peak + | █████▒░░░░ + Medium | █████▒░░░░ + | ███▒░░░░░░░ + Low | ██░░░░░░░░░ + | █░░░░░░░░░░ + Threshold | ░░░░░░░░░░░░ + +--------------------- Time -------------------> + Onset Come-up Peak Comedown Afterglow + 17:21 17:26 17:36 18:21 19:00 + + Current Phase: Peak + Time Remaining: ~12 minutes + Overall Progress: [==============] 85% + Estimated End: 20:21 UTC \ No newline at end of file diff --git a/docs/reference/tuirealm.md b/docs/reference/tuirealm.md new file mode 100644 index 00000000..e427a372 --- /dev/null +++ b/docs/reference/tuirealm.md @@ -0,0 +1,1498 @@ + +# Get Started 🏁 + +- [Get Started 🏁](#get-started-) + - [An introduction to realm](#an-introduction-to-realm) + - [Key Concepts](#key-concepts) + - [MockComponent Vs. Component](#mockcomponent-vs-component) + - [The Mock Component](#the-mock-component) + - [The Component](#the-component) + - [Properties Vs. States](#properties-vs-states) + - [Events Vs. Commands](#events-vs-commands) + - [Application, Model and View](#application-model-and-view) + - [The View](#the-view) + - [Focus](#focus) + - [Model](#model) + - [The Application](#the-application) + - [Lifecycle (or "tick")](#lifecycle-or-tick) + - [Our first application](#our-first-application) + - [Let's implement the Counter](#lets-implement-the-counter) + - [Let's define the message type](#lets-define-the-message-type) + - [Let's define the component identifiers](#lets-define-the-component-identifiers) + - [Implementing the two counter components](#implementing-the-two-counter-components) + - [Implementing the model](#implementing-the-model) + - [Application setup and main loop](#application-setup-and-main-loop) + - [What's next](#whats-next) + +--- + +## An introduction to realm + +What you will learn: + +- The key concepts of tui-realm +- How to code a tui-realm application from scratch +- What makes tui-realm cool + +tui-realm is a ratatui **framework** which provides an easy way to implement stateful application. +First of all, let's give a look to the main features of tui-realm and why you should opt for this framework when building +terminal user interfaces: + +- ⌨️ **Event-driven** + + tui-realm uses the `Event -> Msg` approach, taken from Elm. **Events** are produced by some entities called `Port`, which work as event listener (such as a stdin reader or an HTTP client), which produce Events. These are then forwarded to **Components**, which will produce a **Message**. The message will cause then a certain behaviour on your application model, based on its variant. + Kinda simple and everything in your application will work around this logic, so it's really easy to implement whatever you want. + +- ⚛️ Based on **React** and **Elm** + + tui-realm is based on [React](https://reactjs.org/) and [Elm](https://elm-lang.org/). These two are kinda different as approach, but I decided to take the best from each of them to combine them in **Realm**. From React I took the **Component** concept. In realm each component represents a single graphic instance, which could potentially include some children; each component then has a **State** and some **Properties**. + From Elm I basically took every other concept implemented in Realm. I really like Elm as a language, in particular the **TEA**. + Indeed, as in Elm, in realm the lifecycle of the application is `Event -> Msg -> Update -> View -> Event -> ...` + +- 🍲 **Boilerplate** code + + tui-realm may look hard to work with at the beginning, but after a while you'll be start realizing how the code you're implementing is just boilerplate code you're copying from your previous components. + +- 🚀 Quick-setup + + Since the newest tui-realm API (1.x) tui-realm has become really easy to learn and to setup, thanks to the new `Application` data type, event listeners and to the `Terminal` helper. + +- 🎯 Single **focus** and **states** management + + Instead of managing focus and states by yourself, in realm everything is automatically managed by the **View**, which is where all components are mounted. With realm you don't have to worry about the application states and focus anymore. + +- 🙂 Easy to learn + + Thanks to the few data types exposed to the user and to the guides, it's really easy to learn tui-realm, even if you've never worked with tui or Elm before. + +- 🤖 Adaptable to any use case + + As you will learn through this guide, tui-realm exposes some advanced concepts to create your own event listener, to work with your own event and to implement complex components. + +--- + +## Key Concepts + +Let's see now what are the key concepts of tui-realm. In the introduction you've probably read about some of them in **bold**, but let's see them in details now. Key concepts are really important to understand, luckily they're easy to understand and there aren't many of them: + +- **MockComponent**: A Mock component represents a re-usable UI component, which can have some **properties** for rendering or to handle commands. It can also have its own **states**, if necessary. In practice it is a trait which exposes some methods to render and to handle properties, states and events. We'll see it in details in the next chapter. +- **Component**: A component is a wrapper around a mock component which represents a single component in your application. It directly takes events and generates messages for the application consumer. Underneath it relies on its Mock component for properties/states management and rendering. +- **State**: The state represents the current state for a component (e.g. the current text in a text input). The state depends on how the user (or other sources) interacts with the component (e.g. the user press 'a', and the char is pushed to the text input). +- **Attribute**: An attribute describes a single property in a component. The attribute shouldn't depend on the component state, but should only be configured by the user when the component is initialized. Usually a mock component exposes many attributes to be configured, and the component using the mock, sets them based on what the user requires. +- **Event**: an event is a **raw** entity describing an event caused mainly by the user (such as a keystroke), but could also be generated by an external source (we're going to talk about these last in the "advanced concepts"). +- **Message** (or usually called `Msg`): A message is a Logic event that is generated by the Components, after an **Event**. + + While the Event is *raw* (such as a keystroke), the message is application-oriented. The message is later consumed by the **Update routine**. I think an example would explain it better: let's say we have a popup component, that when `ESC` is pressed, it must report to the application to hide it. Then the event will be `Key::Esc`, it will consume it, and will return a `PopupClose` message. The mesage are totally user-defined through template types, but we'll see that later in this guide. + +- **Command** (or usually called `Cmd`): Is an entity generated by the **Component** when it receives an **Event**. It is used by the component to operate on its **MockComponent**. We'll see why of these two entities later. +- **View**: The view is where all the components are stored. The view has basically three tasks: + - **Managing components mounting/unmounting**: components are mounted into the view when they're created. The view prevents to mount duplicated components and will warn you when you try to operate on unexisting component. + - **Managing focus**: the view guarantees that only one component at a time is active. The active component is enabled with a dedicated attribute (we'll see that later) and all the events will be forwarded to it. The view keeps track of all the previous active component, so if the current active component loses focus, the previous active one is active if there's no other component to active. + - **Providing an API to operate on components**: Once components are mounted into the view, they must be accessible to the outside, but in a safe way. That's possible thanks to the bridge methods the view exposes. Since each component must be uniquely identified to be accessed, you'll have to define some IDs for your components. +- **Model**: The model is a structure you'll define for your application to implement the **Update routine**. +- **Subscription** or *Sub*: A subscription is a ruleset which tells the **application** to forward events to other components even if they're not active, based on some rules. We'll talk about subscription in advanced concepts. +- **Port**: A port is an event listener which will use a trait called `Poll` to fetch for incoming events. A port defines both the trait to call and an interval which must elapse between each call. The events are then forwarded to the subscribed components. The input listener is a port, but you may also implement for example an HTTP client, which fetches for some data. We'll see ports in advanced concepts anyway, since they're kinda uncommon to be used. +- **Event Listener**: It is a thread which polls ports to read for incoming events. The events are then reported to the **Application**. +- **Application**: The application is a super wrapper around the *View*, the *Subscriptions* and the *Event Listener*. It exposes a bridge to the view, some shorthands to the *subscriptions*; but is main function, though, is called `tick()`. As we'll see later, tick is where all the framework magic happens. +- **Update routine**: The update routine is a function, which must be implemented by the **Model** and is part of the *Update trait*. This function is as simple as important. It takes as parameter a mutable ref to the *Model*, a mutable ref to the *View* and the incoming **Message**. Based on the value of the *Message*, it provoke a certain behaviour on the Model or on the view. It is just a *match case* if you ask and it can return a *Message*, which will cause the routine to be called recursively by the application. Later, when we'll see the example you'll see how this is just cool. + +--- + +## MockComponent Vs. Component + +We've already roughly said what these two entities are, but now it's time to see them in practice. +The first thing we should remind, is that both of them are **Traits** and that by design a *Component* is also a *MockComponent*. +Let's see their definition in details: + +### The Mock Component + +The mock component is meant to be *generic* (but not too much) and *re-usable*, but at the same time with *one responsibility*. +For instance: + +- ✅ A Label which shows a single line of text makes a good mock component. +- ✅ An Input component like `` in HTML is a good mock component. Even if it can handle many input types, it still has one responsibility, is generic and is re-usable. +- ❌ An input which can handle both text, radio buttons and checks is a bad mock component. It is too generic. +- ❌ An input which takes the remote address for a server is a bad mock component. It is not generic. + +These are only guidelines, but just to give you the idea of what a mock component is. + +A mock component also handles **States** and **Props**, which are totally user-defined based on your needs. Sometimes you may even have component which don't handle any state (e.g. a label). + +In practice a mock component is a trait, with these methods to be implmented: + +```rust +pub trait MockComponent { + fn view(&mut self, frame: &mut Frame, area: Rect); + fn query(&self, attr: Attribute) -> Option; + fn attr(&mut self, attr: Attribute, value: AttrValue); + fn state(&self) -> State; + fn perform(&mut self, cmd: Cmd) -> CmdResult; +} +``` + +the trait requires you to implement: + +- *view*: a method which renders the component in the provided area. You must use `ratatui` widgets to render your component based on its properties and states. +- *query*: returns the value for a certain attribute in the component properties. +- *attr*: assign a certain attribute to the component properties. +- *state*: get the current component state. If has no state will return `State::None`. +- *perform*: Performs the provided **command** on the component. This method is called by the **Component** as we'll see later. The command should change the component states. Once the action has been processed, it must return the `CmdResult` to the **Component**. + +### The Component + +So, apparently the mock component defines everything we need handle properties, states and rendering. So why we're not done yet and we need a component trait too? + +1. MockComponent must be **generic**: mock components are distribuited in library (e.g. `tui-realm-stdlib`) and because of that, they cannot consume `Event` or produce `Message`. +2. Because of point 1, we need an entity which produces `Msg` and consume `Event`. These two entities are totally or partially user-defined, which means, they are different for each realm application. This means the component must fit to the application. +3. **It's impossible to fit a component to everybody's needs**: I tried to in tui-realm 0.x, but it was just impossible. At a certain point I just started to add properties among other properties, but eventually I ended up re-implementing stdlib components from scratch just to have some different logics. Mock Components are good because they're generic, but not too much; they must behave as dummies to us. Components are exactly what we want for the application. We want an input text, but we want that when we type 'a' it changes color. You can do it with component, you can't do it with mocks. Oh, and I almost forgot the worst thing about generalizing mocks: **keybindings**. + +Said so, what is a component? + +A component is an application specific unique implementation of a mock. Let's think for example of a form and let's say the first field is an input text which takes the username. If we think about it in HTML, it will be for sure a `` right? And so it's for many other components in your web page. So the input text will be the `MockComponent` in tui-realm. But *THAT* username input field, will be your **username input text**. The `UsernameInput` will wrapp a `Input` mock component, but based on incoming events it will operate differently on the mock and will produce different **Messages** if compared for instance to a `EmailInput`. + +So, let me state the most important thing you must keep in mind from now on: **Components are unique ❗** in your application. You **should never use the same Component more than once**. + +Let's see what a component is in practice now: + +```rust +pub trait Component: MockComponent +where + Msg: PartialEq, + UserEvent: Eq + PartialEq + Clone + PartialOrd, +{ + fn on(&mut self, ev: Event) -> Option; +} +``` + +Quite simple uh? Yep, it was my intention to make them the lighter as possible, since you'll have to implement one for each component in your view. As you can also notice, a Component requires to impl a `MockComponent` so in practice we'll also have something like: + +```rust +pub struct UsernameInput { + component: Input, // Where input implements `MockComponent` +} + +impl Component for UsernameInput { ... } +``` + +Another thing you may have noticed and that may frighten some of you are the two generic types that Component takes. +Let's see what these two types are: + +- `Msg`: defines the type of the **message** your application will handle in the **Update routine**. Indeed, in tui-realm the message are not defined in the library, but are defined by the user. We'll see this in details later in "the making of the first application". The only requirements for Message, is that it must implement `PartialEq`, since you must be able to match it in the **Update**. +- `UserEvent`: The user event defines a custom event your application can handle. As we said before tui-realm usually will send events concerning user input or terminal events, plus a special event called `Tick` (but we'll talk about it later). In addition to these though, we've seen there are other special entities called `Port`, which may return events from other source. Since tui-realm needs to know what these events are, you need to provide the type your ports will produce. + + If we give a look to the `Event` enum, everything will become clear. + + ```rust + pub enum Event + where + UserEvent: Eq + PartialEq + Clone + PartialOrd, + { + /// A keyboard event + Keyboard(KeyEvent), + /// This event is raised after the terminal window is resized + WindowResize(u16, u16), + /// A ui tick event (should be configurable) + Tick, + /// Unhandled event; Empty event + None, + /// User event; won't be used by standard library or by default input event listener; + /// but can be used in user defined ports + User(UserEvent), + } + ``` + + As you can see there is a special variant for `Event` called `User` which takes a special type `UserEvent`, which can be indeed used to use user-defined events. + + > ❗If you don't have any `UserEvent` in your application, you can declare events passing `Event`, which is an empty enum + +### Properties Vs. States + +All components are described by properties and quite often by states as well. But what is the difference between them? + +Basically **Properties** describe how the component is rendered and how it should behave. + +For example, properties are **styles**, **color** or some properties such as "should this list scroll?". +Properties are always present in a component. + +States, on the other hand, are optional and *usually* are used only by components which the user can interact with. +The state won't describe styles or how a component behaves, but the current state of a component. The state, also will usually change after the user performs a certain **Command**. + +Let's see for example how to distinguish properties from states on a component and let's say this component is a *Checkbox*: + +- The checkbox foreground and background are **Properties** (doesn't change on interaction) +- The checkbox options are **Properties** +- The current selected options are **States**. (they change on user interaction) +- The current highlighted item is a **State**. + +### Events Vs. Commands + +We've almost seen all of the aspects behind components, but we still need to talk about an important concept, which is the difference between `Event` and `Cmd`. + +If we give a look to the **Component** trait, we'll see that the method `on()` has the following signature: + +```rust +fn on(&mut self, ev: Event) -> Option; +``` + +and we know that the `Component::on()` will call the `perform()` method of its **MockComponent**, in order to update its states. The perform method has this signature instead: + +```rust +fn perform(&mut self, cmd: Cmd) -> CmdResult; +``` + +As you can see, the **Component** consumes an `Event` and produces a `Msg`, while the mock, which is called by the component, consumes a `Cmd` and produces a `CmdResult`. + +If we give a look to the two type declarations, we'll see there is a difference in terms of scope, let's give a look: + +```rust +pub enum Event +where + UserEvent: Eq + PartialEq + Clone + PartialOrd, +{ + /// A keyboard event + Keyboard(KeyEvent), + /// This event is raised after the terminal window is resized + WindowResize(u16, u16), + /// A ui tick event (should be configurable) + Tick, + /// Unhandled event; Empty event + None, + /// User event; won't be used by standard library or by default input event listener; + /// but can be used in user defined ports + User(UserEvent), +} + +pub enum Cmd { + /// Describes a "user" typed a character + Type(char), + /// Describes a "cursor" movement, or a movement of another kind + Move(Direction), + /// An expansion of `Move` which defines the scroll. The step should be defined in props, if any. + Scroll(Direction), + /// User submit field + Submit, + /// User "deleted" something + Delete, + /// User toggled something + Toggle, + /// User changed something + Change, + /// A user defined amount of time has passed and the component should be updated + Tick, + /// A user defined command type. You won't find these kind of Command in the stdlib, but you can use them in your own components. + Custom(&'static str), + /// `None` won't do anything + None, +} +``` + +For some aspects, they both look similiar, but something immediately appears clear: + +- Event is strictly bounded to the "hardware", it takes key event, terminal events or event from other sources. +- Cmd is completely independent from the hardware and terminal, and it's all about UI logic. We still have `KeyEvent`, but we've also got `Type`, `Move`, `Submit`, custom events (but not with generics) and etc. + +The reason behind this, is quite simple: **MockComponent** must be application-independent. You can create your components library and distribuite it on Github, or wherever you want, and it still must be able to work. If they took events as parameters, this couldn't be possible, since event takes in a type, which is application-dependent. + +And there's also another reason: let's imagine we have a component with a list you can scroll on and view different elements. You can scroll up/down with keys. If I wanted to create a library of components and we had events only, it wouldn't be possible to use different keybindings. Think about, with mock components I expect that in perform(), when we receive a `Cmd::Scroll(Direction::Up)` the list scrolls up, then I can implement my `Component` which will send a `Cmd::Scroll(Direction::Up)` when `W` is typed and another component which will send the same event when `` is pressed. Thanks to this mechanism, tui-realm mock components are also totally independent from key-bindings, which in tui-realm 0.x, was just a hassle. + +So whenever you implement a MockComponent, you must keep in mind that you should make it application-independent, so you must define its **Command API** and define what kind of **CmdResult** it'll produce. Then, your components must generate on whatever kind of events the `Cmd` accepted by the API, and handle the `CmdResult`, and finally, based on the value of the `CmdResult` return a certain kind of **Message** based on your application. + +We're then, finally starting to define the lifecycle of the tui-realm. This segment of the cycle, is described as `Event -> (Cmd -> CmdResult) -> Msg`. + +--- + +## Application, Model and View + +Now that we have defined what Components are, we can finally start talking about how all these components can be put together to create an application. + +In order to put everything together, we'll use three different entities, we've already briefly seen before, which are: + +- The **Application** +- The **Model** +- The **View** + +First, starting from components, the first thing we need to talk about, is the **View**. + +### The View + +The view is basically a box for all the components. All the components which are part of the same "view" (in terms of UI) must be *mounted* in the same **View**. +Each component in the view, **Must** be identified uniquely by an *identifier*, where the identifier is a type you must define (you can use an enum, you can use a String, we'll see that later). +Once a component is mounted, it won't be directly usable anymore. The view will store it as a generic `Component` and will expose a bridge to operate on all the components in the view, querying them with their identifier. + +The component will be part of the view, until you unmount the component. Once the component is unmounted, it won't be usable anymore and it'll be destroyed. + +The view is not just a list of components though, it also plays a fundamental role in the UI development, indeed, it will handle focus. Let's talk about it in the next chapter + +#### Focus + +Whenever you interact with components in a UI, there must always be a way to determine which component will handle the interaction. If I press a key, the View must be able whether to type a character in an input field or into another and this is resolved through **focus**. +Focus is just a state the view tracks. At any time, the view must know which component is currently *active* and what to do, in case that component is unmounted. + +In tui-realm, I decided to define the following rules, when working with focus: + +1. Only one component at a time can have focus +2. All events will be forwarded to the component that currently owns focus. +3. A componet to become active, must get focus via the `active()` method. +4. If a component gets focus, then its `Attribute::Focus` property becomes `AttrValue::Flag(true)` +5. If a component loses focus, then its `Attribute::Focus` property becomes `AttrValue::Flag(false)` +6. Each time a component gets focus, the previous active component, is tracked into a `Stack` (called *focus stack*) holding all the previous components owning focus. +7. If a component owning focus, is **unmounted**, the first component in the **Focus stack** becomes active +8. If a component owning focus, gets disabled via the `blur()` method, the first component in the **Focus stack** becomes active, but the *blurred* component, is not pushed into the **Focus stack**. + +Follow the following table to understand how focus works: + +| Action | Focus | Focus Stack | Components | +|----------|-------|-------------|------------| +| Active A | A | | A, B, C | +| Active B | B | A | A, B, C | +| Active C | C | B, A | A, B, C | +| Blur C | B | A | A, B, C | +| Active C | C | B, A | A, B, C | +| Active A | A | C, B | A, B, C | +| Umount A | C | B | B, C | +| Mount D | C | B | B, C, D | +| Umount B | C | | C, D | + +### Model + +The model is a struct which is totally defined by the developer implementing a tui-realm application. Its purpose is basically to update its states, perform some actions or update the view, after the components return messages. +This is done through the **Update routine**, which is defined in the **Update** trait. We'll soon see this in details, when we'll talk about the *application*, but for now, what we need to know, is what the update routine does: + +first of all your *model* must implement the **Update** trait: + +```rust +pub trait Update +where + ComponentId: Eq + PartialEq + Clone + Hash, + Msg: PartialEq, + UserEvent: Eq + PartialEq + Clone + PartialOrd, +{ + + /// update the current state handling a message from the view. + /// This function may return a Message, + /// so this function has to be intended to be call recursively if necessary + fn update( + &mut self, + view: &mut View, + msg: Option, + ) -> Option; +} +``` + +Here finally we can see almost everything put together: we have the view and we have all the 3 different custom types, defining how components are identified in the view (*ComponentId*), the *Msg* and the *UserEvent* for the *Event* type. +The update method, receives a mutable reference to the model, a mutable reference to the view and the incoming message from the component, which processed a certain type of event. +Inside the update, we'll match the msg, to perform certain operation on the model or on the view and we'll return `None` or another message, if necessary. As we'll see, if we return `Some(Msg)`, the *Application*, will re-call the routine passing as argument the last generated message. + +### The Application + +Finally we're ready to talk about the core struct of tui-realm, the **Application**. Let's see which tasks it takes care of: + +- It contains the view and exposes a bridge to it: the application contains the view itself, and provides a way to operate on it, as usual using the component identifiers. +- It handles subscriptions: as we've already seen before, subscriptions are special rules which tells the application to forward events to other components if some clauses are satisfied. +- It reads incoming events from **Ports** + +indeed as we can see, the application is a container for all these entities: + +```rust +pub struct Application +where + ComponentId: Eq + PartialEq + Clone + Hash, + Msg: PartialEq, + UserEvent: Eq + PartialEq + Clone + PartialOrd + Send + 'static, +{ + listener: EventListener, + subs: Vec>, + view: View, +} +``` + +so the application will be the sandbox for the all the entities a tui-realm app needs (and that's why is called *Application*). + +But the coolest thing here, is that all the application can be run, using a single method! This method is called `tick()` and as we'll see in the next chapter it performs all what is necessary to complete a single cycle of the application lifecycle: + +```rust +pub fn tick(&mut self, strategy: PollStrategy) -> ApplicationResult> { + // Poll event listener + let events = self.poll(strategy)?; + // Forward to active element + let mut messages: Vec = events + .iter() + .map(|x| self.forward_to_active_component(x.clone())) + .flatten() + .collect(); + // Forward to subscriptions and extend vector + messages.extend(self.forward_to_subscriptions(events)); + Ok(messages) +} +``` + +As we can quickly see, the tick method has the following workflow: + +1. The event listener is fetched according to the provided `PollStrategy` + + > ❗The poll strategy tells how to poll the event listener. You can fetch One event for cycle, or up to `n` or for a maximum amount of time + +2. All the incoming events are immediately forwarded to the current *active* component in the *view*, which may return some *messages* +3. All the incoming events are sent to all the components subscribed to that event, which satisfied the clauses described in the subscription. They, as usual, will may return some *messages* +4. The messages are returned + +Along to the tick() routine, the application provides many other functionalities, but we'll see later in the example and don't forget to checkout the documentation on rust docs. + +--- + +## Lifecycle (or "tick") + +We're finally ready to put it all together to see the entire lifecycle of the application. +Once the application is set up, the cycle of our application will be the following one: + +![lifecycle](/docs/images/lifecycle.png) + +in the image, we can see there are all the entities we've talked about earlier, which are connected through two kind of arrows, the *black* arrows defines the flow you have to implement, while the *red* arrows, follows what is already implemented and implicitly called by the application. + +So the tui-realm lifecycle consists in: + +1. the `tick()` routine is called on **Application** + 1. Ports are polled for incoming events + 2. event is forwarded to active component in the view + 3. subscriptions are queried to know whether the event should be forwarded to other components + 4. incoming messages are collected +2. Messages are returned to the caller +3. the `update()` routine is called on **Model** providing each message from component +4. The model gets updated thanks to the `update()` method +5. The `view()` function is called to render the UI + +This simple 4 steps cycle is called **Tick**, because it defines the interval between each UI refresh in fact. +Now that we know how a tui-realm application works, let's see how to implement one. + +--- + +## Our first application + +We're finally ready to set up a realm tui-realm application. In this example we're going to start with simple very simple. +The application we're going to implement is really simple, we've got two **counters**, one will track when an alphabetic character is pressed by the user and the other when a digit is pressed by the user. Both of them will track events, only when active. The active component will switch between the two counters pressing ``, while pressing `` the application will terminate. + +> ❗ Want to see something more complex? Check out [tuifeed](https://github.com/veeso/tuifeed) + +### Let's implement the Counter + +So we've said we have two Counters, one tracking alphabetic characters and one digits, so we've found a potential mock component: the **Counter**. The counter will just have a state keeping track of "times" as a number and will increment each time a certain command will be sent. +Said so, let's implement the counter: + +```rust +struct Counter { + props: Props, + states: OwnStates, +} + +impl Default for Counter { + fn default() -> Self { + Self { + props: Props::default(), + states: OwnStates::default(), + } + } +} +``` + +so the counter, as all components must have the `props` which defines its properties and in this case the counter is a stateful component, so, we need to declare its states: + +```rust +struct OwnStates { + counter: isize, +} + +impl Default for OwnStates { + fn default() -> Self { + Self { counter: 0 } + } +} + +impl OwnStates { + fn incr(&mut self) { + self.counter += 1; + } +} +``` + +Then, we'll implement an easy-to-use constructor for our mock component: + +```rust +impl Counter { + pub fn label(mut self, label: S) -> Self + where + S: AsRef, + { + self.attr( + Attribute::Title, + AttrValue::Title((label.as_ref().to_string(), Alignment::Center)), + ); + self + } + + pub fn value(mut self, n: isize) -> Self { + self.attr(Attribute::Value, AttrValue::Number(n)); + self + } + + pub fn alignment(mut self, a: Alignment) -> Self { + self.attr(Attribute::TextAlign, AttrValue::Alignment(a)); + self + } + + pub fn foreground(mut self, c: Color) -> Self { + self.attr(Attribute::Foreground, AttrValue::Color(c)); + self + } + + pub fn background(mut self, c: Color) -> Self { + self.attr(Attribute::Background, AttrValue::Color(c)); + self + } + + pub fn modifiers(mut self, m: TextModifiers) -> Self { + self.attr(Attribute::TextProps, AttrValue::TextModifiers(m)); + self + } + + pub fn borders(mut self, b: Borders) -> Self { + self.attr(Attribute::Borders, AttrValue::Borders(b)); + self + } +} +``` + +finally we can implement `MockComponent` for `Counter` + +```rust +impl MockComponent for Counter { + fn view(&mut self, frame: &mut Frame, area: Rect) { + // Check if visible + if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { + // Get properties + let text = self.states.counter.to_string(); + let alignment = self + .props + .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) + .unwrap_alignment(); + let foreground = self + .props + .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) + .unwrap_color(); + let background = self + .props + .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) + .unwrap_color(); + let modifiers = self + .props + .get_or( + Attribute::TextProps, + AttrValue::TextModifiers(TextModifiers::empty()), + ) + .unwrap_text_modifiers(); + let title = self + .props + .get_or( + Attribute::Title, + AttrValue::Title((String::default(), Alignment::Center)), + ) + .unwrap_title(); + let borders = self + .props + .get_or(Attribute::Borders, AttrValue::Borders(Borders::default())) + .unwrap_borders(); + let focus = self + .props + .get_or(Attribute::Focus, AttrValue::Flag(false)) + .unwrap_flag(); + frame.render_widget( + Paragraph::new(text) + .block(get_block(borders, title, focus)) + .style( + Style::default() + .fg(foreground) + .bg(background) + .add_modifier(modifiers), + ) + .alignment(alignment), + area, + ); + } + } + + fn query(&self, attr: Attribute) -> Option { + self.props.get(attr) + } + + fn attr(&mut self, attr: Attribute, value: AttrValue) { + self.props.set(attr, value); + } + + fn state(&self) -> State { + State::One(StateValue::Isize(self.states.counter)) + } + + fn perform(&mut self, cmd: Cmd) -> CmdResult { + match cmd { + Cmd::Submit => { + self.states.incr(); + CmdResult::Changed(self.state()) + } + _ => CmdResult::None, + } + } +} +``` + +so as state, we return the current value for the counter and on perform we handle the `Cmd::Submit` to increment the current value for the counter. As result we return `CmdResult::Changed()` with the state. + +So our Mock component is ready, we can now implement our two components. + +### Let's define the message type + +Before implementing the two `Component` we first need to define the messages our application will handle. +So, in on top of our application we define an enum `Msg`: + +```rust +#[derive(Debug, PartialEq)] +pub enum Msg { + AppClose, + DigitCounterChanged(isize), + DigitCounterBlur, + LetterCounterChanged(isize), + LetterCounterBlur, + /// Used to unwrap on update() + None, +} +``` + +where: + +- `AppClose` will tell to terminate the app +- `DigitCounterChanged` tells the digit counter value has changed +- `DigitCounterBlur` tells that the digit counter shall lose focus +- `LetterCounterChanged` tells the letter counter value has changed +- `LetterCounterBlur` tells that the letter counter shall lose focus + +### Let's define the component identifiers + +We need also to define the ids for our components, that will be used by the view to query mounted components. +So on top of our application, as we did for `Msg`, let's define `Id`: + +```rust +// Let's define the component ids for our application +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub enum Id { + DigitCounter, + LetterCounter, +} +``` + +### Implementing the two counter components + +We'll have two type of counters, so we'll call them `LetterCounter` and `DigitCounter`. Let's implement them! + +First we define the `LetterCounter` with the mock component within. Since we don't need any particular behaviour for the `MockComponent` trait, we can simply derive `MockComponent`, which will implement the default implementation for MockComponent. If you want to read more read see [tuirealm_derive](https://github.com/veeso/tuirealm_derive). + +```rust +#[derive(MockComponent)] +pub struct LetterCounter { + component: Counter, +} +``` + +then we implement the constructor for the counter, that accepts the initial value and construct a `Counter` using the mock component constructor: + +```rust +impl LetterCounter { + pub fn new(initial_value: isize) -> Self { + Self { + component: Counter::default() + .alignment(Alignment::Center) + .background(Color::Reset) + .borders( + Borders::default() + .color(Color::LightGreen) + .modifiers(BorderType::Rounded), + ) + .foreground(Color::LightGreen) + .modifiers(TextModifiers::BOLD) + .value(initial_value) + .label("Letter counter"), + } + } +} +``` + +Finally we implement the `Component` trait for the `LetterCounter`, were we first convert the incoming `Event` to a consumable `Cmd`, then we call `perform()` on the mock to get the `CmdResult` in order to produce a `Msg`. +When event is `Esc` or `Tab` we directly return the `Msg` to close app or to change focus. + +```rust +impl Component for LetterCounter { + fn on(&mut self, ev: Event) -> Option { + // Get command + let cmd = match ev { + Event::Keyboard(KeyEvent { + code: Key::Char(ch), + modifiers: KeyModifiers::NONE, + }) if ch.is_alphabetic() => Cmd::Submit, + Event::Keyboard(KeyEvent { + code: Key::Tab, + modifiers: KeyModifiers::NONE, + }) => return Some(Msg::LetterCounterBlur), // Return focus lost + Event::Keyboard(KeyEvent { + code: Key::Esc, + modifiers: KeyModifiers::NONE, + }) => return Some(Msg::AppClose), + _ => Cmd::None, + }; + // perform + match self.perform(cmd) { + CmdResult::Changed(State::One(StateValue::Isize(c))) => { + Some(Msg::LetterCounterChanged(c)) + } + _ => None, + } + } +} +``` + +We'll do the same for the `DigitCounter`, but on `on()` it will check whether char is a digit, instead of alphabetic. + +### Implementing the model + +Now that we have the components, we're almost done. We can finally implement the `Model`. I made a very simple model for this example: + +```rust +pub struct Model { + /// Application + pub app: Application, + /// Indicates that the application must quit + pub quit: bool, + /// Tells whether to redraw interface + pub redraw: bool, + /// Used to draw to terminal + pub terminal: TerminalBridge, +} +``` + +> ❗ the terminal bridge is a helper struct implemented in tui-realm to interface with ratatui terminal with some helper functions. +> It also is totally backend-independent, so you won't have to know how to setup the terminal for your backend. + +Now, we'll implement the `view()` method, which will render the GUI after updating the model: + +```rust +impl Model { + pub fn view(&mut self, app: &mut Application) { + assert!(self + .terminal + .raw_mut() + .draw(|f| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), // Letter Counter + Constraint::Length(3), // Digit Counter + ] + .as_ref(), + ) + .split(f.size()); + app.view(&Id::LetterCounter, f, chunks[0]); + app.view(&Id::DigitCounter, f, chunks[1]); + }) + .is_ok()); + } +} +``` + +> ❗ If you're not familiar with the `draw()` function, please read the [ratatui](https://ratatui.rs/) documentation. + +and finally we can implement the `Update` trait: + +```rust +impl Update for Model { + fn update(&mut self, msg: Option) -> Option { + if let Some(msg) = msg { + // Set redraw + self.redraw = true; + // Match message + match msg { + Msg::AppClose => { + self.quit = true; // Terminate + None + } + Msg::Clock => None, + Msg::DigitCounterBlur => { + // Give focus to letter counter + assert!(self.app.active(&Id::LetterCounter).is_ok()); + None + } + Msg::DigitCounterChanged(v) => { + // Update label + assert!(self + .app + .attr( + &Id::Label, + Attribute::Text, + AttrValue::String(format!("DigitCounter has now value: {}", v)) + ) + .is_ok()); + None + } + Msg::LetterCounterBlur => { + // Give focus to digit counter + assert!(self.app.active(&Id::DigitCounter).is_ok()); + None + } + Msg::LetterCounterChanged(v) => { + // Update label + assert!(self + .app + .attr( + &Id::Label, + Attribute::Text, + AttrValue::String(format!("LetterCounter has now value: {}", v)) + ) + .is_ok()); + None + } + } + } else { + None + } + } +} +``` + +### Application setup and main loop + +We're almost done, let's just setup the Application in our `main()`: + +```rust +fn init_app() -> Application { + // Setup application + // NOTE: NoUserEvent is a shorthand to tell tui-realm we're not going to use any custom user event + // NOTE: the event listener is configured to use the default crossterm input listener and to raise a Tick event each second + // which we will use to update the clock + let mut app: Application = Application::init( + EventListenerCfg::default() + .default_input_listener(Duration::from_millis(20)) + .poll_timeout(Duration::from_millis(10)) + .tick_interval(Duration::from_secs(1)), + ); +} +``` + +The app requires the configuration for the `EventListener` which will poll `Ports`. We're telling the event listener to use the default input listener for our backend. `default_input_listener` will setup the default input listener for termion/crossterm or the backend you chose. Then we also define the `poll_timeout`, which describes the interval between each poll to the listener thread. + +> ❗ Here we could also define other Ports thanks to the method `port()` or setup the `Tick` producer with `tick_interval()` + +Then we can mount the two components into the view: + +```rust +assert!(app + .mount( + Id::LetterCounter, + Box::new(LetterCounter::new(0)), + Vec::default() + ) + .is_ok()); +assert!(app + .mount( + Id::DigitCounter, + Box::new(DigitCounter::new(5)), + Vec::default() + ) +.is_ok()); +``` + +> ❗ The two empty vectors are the subscriptions related to the component. (In this case none) + +Then we initilize focus: + +```rust +assert!(app.active(&Id::LetterCounter).is_ok()); +``` + +We can now setup the terminal configuration: + +```rust +let _ = model.terminal.enter_alternate_screen(); +let _ = model.terminal.enable_raw_mode(); +``` + +and we can finally implement the **main loop**: + +```rust +while !model.quit { + // Tick + match app.tick(&mut model, PollStrategy::Once) { + Err(err) => { + // Handle error... + } + Ok(messages) if messages.len() > 0 => { + // NOTE: redraw if at least one msg has been processed + model.redraw = true; + for msg in messages.into_iter() { + let mut msg = Some(msg); + while msg.is_some() { + msg = model.update(msg); + } + } + } + _ => {} + } + // Redraw + if model.redraw { + model.view(&mut app); + model.redraw = false; + } +} +``` + +On each cycle we call `tick()` on our application, with strategy `Once` and we ask the model to redraw the view only if at least one message has been processed (otherwise there shouldn't be any change to display). + +Once `quit` becomes true, the application terminates, but don't forget to finalize the terminal: + +```rust +let _ = model.terminal.leave_alternate_screen(); +let _ = model.terminal.disable_raw_mode(); +let _ = model.terminal.clear_screen(); +``` + +--- + +## What's next + +Now you know pretty much how tui-realm works and its essential concepts, but there's still a lot of features to explore, if you want to discover them, you now might be interested in these reads: + +- [Advanced concepts](advanced.md) +- [Migrating tui-realm 0.x to 1.x](migrating-legacy.md) + +# Advanced concepts + +- [Advanced concepts](#advanced-concepts) + - [Introduction](#introduction) + - [Subscriptions](#subscriptions) + - [Handle subscriptions](#handle-subscriptions) + - [Event clauses in details](#event-clauses-in-details) + - [Sub clauses in details](#sub-clauses-in-details) + - [Subscriptions lock](#subscriptions-lock) + - [Tick Event](#tick-event) + - [Ports](#ports) + - [Implementing new components](#implementing-new-components) + - [What the component should look like](#what-the-component-should-look-like) + - [Defining the component properties](#defining-the-component-properties) + - [Defining the component states](#defining-the-component-states) + - [Defining the Cmd API](#defining-the-cmd-api) + - [Rendering the component](#rendering-the-component) + - [Properties Injectors](#properties-injectors) + - [What's next](#whats-next) + +--- + +## Introduction + +This guide will introduce you to all the advanced concepts of tui-realm, that haven't been covered in the [get-started guide](get-started.md). Altough tui-realm is quite simple, it can also get quiet powerful, thanks to all these features that we're gonna cover in this document. + +What you will learn: + +- How to handle subscriptions, making some components to listen to certain events under certain circumstances. +- What is the `Event::Tick` +- How to use custom source for events through `Ports`. +- How to implement new components + +--- + +## Subscriptions + +> A subscription is a ruleset which tells the **application** to forward events to other components even if they're not active, based on some rules. + +As we've already covered in the base concepts of tui-realm, the application takes care of forwarding events from ports to components. +By default events are forwarded only to the current active component, but this can be be quite annoying: + +- First, we may need a component always listening for incoming events. Imagine some loaders polling a remote server. They can't get updated only when they've got focus, they probably needs to be updated each time an event coming from the *Port* is received by the *Event listener*. Without *Subscriptions* this would be impossible. +- Sometimes is just a fact of "it's boring" and scope: in the example I had two counters, and both of them were listening for `` key to quit the application returning a `AppClose` message. But is that their responsiblity to tell whether the application should terminate? I mean, they're just counter, so they shouldn't know whether to close the app right? Besides of that, it's also really annoying to write a case for `` for each component to return `AppClose`. Having an invisible component always listening for `` to return `AppClose` would be much more comfy. + +So what is a subscription actually, and how we can create them? + +The subscription is defined as: + +```rust +pub struct Sub(EventClause, SubClause) +where + UserEvent: Eq + PartialEq + Clone + PartialOrd; +``` + +So it's a tupled structure, which takes an `EventClause` and a `SubClause`, let's dive deeper: + +- An **Event clause** is a match clause the incoming event must satisfy. As we said before the application must know whether to forward a certain *event* to a certain component. So the first thing it must check, is whether it is listening for that kind of event. + + The event clause is declared as follows: + + ```rust + pub enum EventClause + where + UserEvent: Eq + PartialEq + Clone + PartialOrd, + { + /// Forward, no matter what kind of event + Any, + /// Check whether a certain key has been pressed + Keyboard(KeyEvent), + /// Check whether window has been resized + WindowResize, + /// The event will be forwarded on a tick + Tick, + /// Event will be forwarded on this specific user event. + /// The way user event is matched, depends on its partialEq implementation + User(UserEvent), + } + ``` + +- A **Sub clause** is an additional condition that must be satisfied by the component associated to the subscription in order to forward the event: + + ```rust + pub enum SubClause { + /// Always forward event to component + Always, + /// Forward event if target component has provided attribute with the provided value + /// If the attribute doesn't exist on component, result is always `false`. + HasAttrValue(Attribute, AttrValue), + /// Forward event if target component has provided state + HasState(State), + /// Forward event if the inner clause is `false` + Not(Box), + /// Forward event if both the inner clauses are `true` + And(Box, Box), + /// Forward event if at least one of the inner clauses is `true` + Or(Box, Box), + } + ``` + +So when an event is received, if a component, **that is not active** satisfies the event clause and the sub clause, then the event will be forwarded to that component too. + +> ❗ In order to forward an event, both the `EventClause` and the `SubClause` must be satisfied + +Let's see in details how to handle subscriptions and how to use clauses. + +### Handle subscriptions + +You can create subscriptions both on component mounting and whenever you want. + +To subscribe a component on `mount` it will be enough to provide a vector of `Sub` to `mount()`: + +```rust +app.mount( + Id::Clock, + Box::new( + Clock::new(SystemTime::now()) + .alignment(Alignment::Center) + .background(Color::Reset) + .foreground(Color::Cyan) + .modifiers(TextModifiers::BOLD) + ), + vec![Sub::new(SubEventClause::Tick, SubClause::Always)] +); +``` + +or you can create new subscriptions whenever you want: + +```rust +app.subscribe(&Id::Clock, Sub::new(SubEventClause::Tick, SubClause::Always)); +``` + +and if you need to remove a subscription you can unsubscribe simply with: + +```rust +app.unsubscribe(&Id::Clock, SubEventClause::Tick); +``` + +### Event clauses in details + +Event clauses are used to define for which kind of event the subscription should be set. +Once the application checks whether to forward an event, it must check the event clause first and verify whether it satisfies the bounds with the incoming event. The event clauses are: + +- `Any`: the event clause is satisfied, no matter what kind of event is. Everything depends on the result of the `SubClause` then. +- `Keyboard(KeyEvent)`: in order to satisfy the clause, the incoming event must be of type `Keyboard` and the `KeyEvent` must exactly be the same. +- `WindowResize`: in order to satisfy the clause, the incoming event must be of type `WindowResize`, no matter which size the window has. +- `Tick`: in order to satisfy the clause, the incoming event must be of type `Tick`. +- `User(UserEvent)`: in order to be satisfied the incoming event must be of type of `User`. The value of `UserEvent` must match, according on how `PartialEq` is implemented for this type. + +### Sub clauses in details + +Sub clauses are verified once the event clause is satisfied, and they define some clauses that must be satisfied on the **target** component (which is the component associated to the subscription). +In particular sub clauses are: + +- `Always`: the clause is always satisfied +- `HasAttrValue(Id, Attribute, AttrValue)`: the clause is satisfied if the target component (defined in `Id`) has `Attribute` with `AttrValue` in its `Props`. +- `HasState(Id, State)`: the clause is satisfied if the target component (defined in `Id`) has `State` equal to provided state. +- `IsMounted(Id)`: the clause is satisfied if the target component (defines in `Id`) is mounted in the View. + +In addition to these, it is also possible to combine Sub clauses using expressions: + +- `Not(SubClause)`: the clause is satisfied if the inner clause is NOT satisfied (negates the result) +- `And(SubClause, SubClause)`: the clause is satisfied if both clause are satisfied +- `Or(SubClause, SubClause)`: the clause is satisfied if at least one of the two clauses is satisfied. + +Using `And` and `Or` you can create even long expression and keep in mind that they are evaluated recursively, so for example: + +`And(Or(A, And(B, C)), And(D, Or(E, F)))` is evaluated as `(A || (B && C)) && (D && (E || F))` + +### Subscriptions lock + +It is possible to temporarily disable the subscriptions propagation. +To do so, you just need to call `application.lock_subs()`. + +Whenever you want to restore event propagation, just call `application.unlock_subs()`. + +--- + +## Tick Event + +The tick event is a special kind of event, which is raised by the **Application** with a specified interval. +Whenevever initializing the **Applcation** you can specify the tick interval, as in the following example: + +```rust +let mut app: Application = Application::init( + EventListenerCfg::default() + .tick_interval(Duration::from_secs(1)), +); +``` + +with the `tick_interval()` method, we specify the tick interval. +Each time the tick interval elapses, the application runtime will throw a `Event::Tick` which will be forwarded on `tick()` to the +current active component and to all the components subscribed to the `Tick` event. + +The purpose of the tick event is to schedule actions based on a certain interval. + +--- + +## Ports + +Ports are basically **Event producer** which are handled by the application *Event listener*. +Usually a tui-realm application will consume only input events, or the tick event, but what if we need *some more* events? + +We may for example need a worker which fetches a remote server for data. Ports allow you to create automatized workers which will produce the events and if you set up everything correctly, your model and components will be updated. + +Let's see now how to setup a *Port*: + +1. First we need to define the `UserEvent` type for our application: + + ```rust + #[derive(PartialEq, Clone, PartialOrd)] + pub enum UserEvent { + GotData(Data) + // ... other events if you need + } + + impl Eq for UserEvent {} + ``` + +2. Implement the *Port*, that I named `MyHttpClient` + + ```rust + pub struct MyHttpClient { + // ... + } + ``` + + Now we need to implement the `Poll` trait for the *Port*. + The poll trait tells the application event listener how to poll for events on a *port*: + + ```rust + impl Poll for MyHttpClient { + fn poll(&mut self) -> ListenerResult>> { + // ... do something ... + Ok(Some(Event::User(UserEvent::GotData(data)))) + } + } + ``` + +3. Port setup in application + + ```rust + let mut app: Application = Application::init( + EventListenerCfg::default() + .default_input_listener(Duration::from_millis(10)) + .port( + Box::new(MyHttpClient::new(/* ... */)), + Duration::from_millis(100), + ), + ); + ``` + + On the event listener constructor you can define how many ports you want. When you declare a port you need to pass a + box containing the type implementing the *Poll* trait and an interval. + The interval defines the interval between each poll to the port. + +--- + +## Implementing new components + +Implementing new components is actually quite simple in tui-realm, but requires you to have at least little knowledge about **tui-rs widgets**. + +In addition to tui-rs knowledge, you should also have in mind the difference between a *MockComponent* and a *Component*, in order not to implement bad components. + +Said that, let's see how to implement a component. For this example I will implement a simplified version of the `Radio` component of the stdlib. + +### What the component should look like + +The first thing we need to define is what the component should look like. +In this case the component is a box with a list of options within and you can select one, which is the user choice. +The user will be able to move through different choices and to submit one. + +### Defining the component properties + +Once we've defined what the component look like, we can start defining the component properties: + +- `Background(Color)`: will define the background color for the component +- `Borders(Borders)`: will define the borders properties for the component +- `Foreground(Color)`: will define the foreground color for the component +- `Content(Payload(Vec(String)))`: will define the possible options for the radio group +- `Title(Title)`: will define the box title +- `Value(Payload(One(Usize)))`: will work as a prop, but will update the state too, for the current selected option. + +```rust +pub struct Radio { + props: Props, + // ... +} + +impl Radio { + + // Constructors... + + pub fn foreground(mut self, fg: Color) -> Self { + self.attr(Attribute::Foreground, AttrValue::Color(fg)); + self + } + + // ... +} + +impl MockComponent for Radio { + + // ... + + fn query(&self, attr: Attribute) -> Option { + self.props.get(attr) + } + + fn attr(&mut self, attr: Attribute, value: AttrValue) { + match attr { + Attribute::Content => { + // Reset choices + let choices: Vec = value + .unwrap_payload() + .unwrap_vec() + .iter() + .map(|x| x.clone().unwrap_str()) + .collect(); + self.states.set_choices(&choices); + } + Attribute::Value => { + self.states + .select(value.unwrap_payload().unwrap_one().unwrap_usize()); + } + attr => { + self.props.set(attr, value); + } + } + } + + // ... + +} +``` + +### Defining the component states + +Since this component can be interactive and the user must be able to select a certain option, we must implement some states. +The component states must track the current selected item. For practical reasons, we also use the available choices as a state. + +```rust +struct OwnStates { + choice: usize, // Selected option + choices: Vec, // Available choices +} + +impl OwnStates { + + /// Move choice index to next choice + pub fn next_choice(&mut self) { + if self.choice + 1 < self.choices.len() { + self.choice += 1; + } + } + + + /// Move choice index to previous choice + pub fn prev_choice(&mut self) { + if self.choice > 0 { + self.choice -= 1; + } + } + + + /// Set OwnStates choices from a vector of text spans + /// In addition resets current selection and keep index if possible or set it to the first value + /// available + pub fn set_choices(&mut self, spans: &[String]) { + self.choices = spans.to_vec(); + // Keep index if possible + if self.choice >= self.choices.len() { + self.choice = match self.choices.len() { + 0 => 0, + l => l - 1, + }; + } + } + + pub fn select(&mut self, i: usize) { + if i < self.choices.len() { + self.choice = i; + } + } +} +``` + +Then we can define the `state()` method + +```rust +impl MockComponent for Radio { + + // ... + + fn state(&self) -> State { + State::One(StateValue::Usize(self.states.choice)) + } + + // ... + +} +``` + +### Defining the Cmd API + +Once we've defined the component states, we can start thinking of the Command API. The command api defines how the component +behaves in front of incoming commands and what kind of result it should return. + +For this component we'll handle the following commands: + +- When the user moves to the right, the current choice is incremented +- When the user moves to the left, the current choice is decremented +- When the user submits, the current choice is returned + +```rust +impl MockComponent for Radio { + + // ... + + fn perform(&mut self, cmd: Cmd) -> CmdResult { + match cmd { + Cmd::Move(Direction::Right) => { + // Increment choice + self.states.next_choice(); + // Return CmdResult On Change + CmdResult::Changed(self.state()) + } + Cmd::Move(Direction::Left) => { + // Decrement choice + self.states.prev_choice(); + // Return CmdResult On Change + CmdResult::Changed(self.state()) + } + Cmd::Submit => { + // Return Submit + CmdResult::Submit(self.state()) + } + _ => CmdResult::None, + } + } + + // ... + +} +``` + +### Rendering the component + +Finally, we can implement the component `view()` method which will render the component: + +```rust +impl MockComponent for Radio { + fn view(&mut self, render: &mut Frame, area: Rect) { + if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { + // Make choices + let choices: Vec = self + .states + .choices + .iter() + .map(|x| Spans::from(x.clone())) + .collect(); + let foreground = self + .props + .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) + .unwrap_color(); + let background = self + .props + .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) + .unwrap_color(); + let borders = self + .props + .get_or(Attribute::Borders, AttrValue::Borders(Borders::default())) + .unwrap_borders(); + let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title()); + let focus = self + .props + .get_or(Attribute::Focus, AttrValue::Flag(false)) + .unwrap_flag(); + let div = crate::utils::get_block(borders, title, focus, None); + // Make colors + let (bg, fg, block_color): (Color, Color, Color) = match focus { + true => (foreground, background, foreground), + false => (Color::Reset, foreground, Color::Reset), + }; + let radio: Tabs = Tabs::new(choices) + .block(div) + .select(self.states.choice) + .style(Style::default().fg(block_color)) + .highlight_style(Style::default().fg(fg).bg(bg)); + render.render_widget(radio, area); + } + } + + // ... +} +``` + +--- + +## Properties Injectors + +Properties injectors are trait objects, which must implement the `Injector` trait, which can provide some property (defined as a tuple of `Attribute` and `AttrValue`) for components when they're mounted. +The Injector trait is defined as follows: + +```rs +pub trait Injector +where + ComponentId: Eq + PartialEq + Clone + Hash, +{ + fn inject(&self, id: &ComponentId) -> Vec<(Attribute, AttrValue)>; +} +``` + +Then you can add an injector to your application with the `add_injector()` method. + +Whenever you mount a new component into your view, the `inject()` method is called for each injector defined in your application providing as argument the id of the mounted component. + +--- + +## What's next + +If you come from tui-realm 0.x and you want to migrate to tui-realm 1.x, there is a guide that explains +[how to migrate from tui-realm 0.x to 1.x](migrating-legacy.md). +Otherwise, I think you're ready to start implementing you tui-realm application right now 😉. + +If you have any question, feel free to open an issue with the `question` label and I will answer you ASAP 🙂. \ No newline at end of file diff --git a/docs/report.md b/docs/report.md new file mode 100644 index 00000000..70bf544a --- /dev/null +++ b/docs/report.md @@ -0,0 +1,84 @@ +# Report + + +```` +┌────────────────────────────────────────────┐ +│ Ingestion Details: Caffeine (200 mg oral) │ +├────────────────────────────────────────────┤ +│ Started: 17:21 UTC │ +│ Estimated End: 20:21 UTC │ +│ Current Phase: Afterglow (Low Intensity) │ +├────────────────────────────────────────────┤ +│ Progress: ██████████████░░░░░░░░░ (60%) │ +└────────────────────────────────────────────┘ +```` + +``` +┌──────────────────────────────────────────┐ +│ Caffeine (200 mg oral) │ +│ Started: 17:21 UTC, End: ~20:21 UTC │ +│ Phase: Afterglow (Low Intensity) │ +├──────────────────────────────────────────┤ +│ Progress: ██████████░░░░░░░░ (60%) │ +└──────────────────────────────────────────┘ +``` + + +``` +// ... existing code ... +│ ─────── Phase Timeline ─────── │ +│ │ +│ Onset Come-up Peak Comedown Afterglow │ +│ [========>--] [========>--] [========] [========] [========] │ +│ ████████████████████████████████████│ +│ 14:30 15:00 16:30 19:30 21:00 22:00 +``` + + +``` +(.)--O-->(.)--C--> (*)--P--> (.)--CD--> (.)--A--> O + Now Expected Peak +``` + +``` +|----+----+----+----+----+----+----+----+----+----| Timeline +O C P CD A E (Phase Initials) +^ Current Position +``` + +``` +┌─[Timeline]──────────────────────────────────────────────────────────────────┐ +│ [Onset]----[Comeup]----[Peak]==========|--------[Afterglow] │ +│ 17:21 17:26 17:36 18:21 19:21 │ +│ ▲ │ +│ │ Now: 18:36 │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +``` +┌─[Ingestion Progress]──────────────────────────┐ ┌─[Phase Details]───────────┐ +│ Overall: [========================-----] 75% │ │ Current: Peak │ +│ ├─> Up Effects: [===================▨----] 83% │ │ Intensity: █████░░░░░ │ +│ └─> Afterglow: [▨------------------------] 0% │ │ Duration: 45m (15m left) │ +└───────────────────────────────────────────────┘ └───────────────────────────┘ +``` + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Ingestion: Caffeine (10mg, oral) ● Active 2023-10-27 17:21 UTC │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +``` +┌───────────────┬───────┬─────────┬────────┐ +│ Phase │ Prog │ Dur │ Int │ +├───────────────┼───────┼─────────┼────────┤ +│ Onset │██████ │ 5min │(Green) │ +│ Come-up │██████████████████│10min │(Yellow)│ +│ Peak │████████████████████████████████████████████████│45min │(Red) │ +│ Comedown │██████████████████░░░░░░░░░░░░░░░░░░░░░░│39min │(Orange)│ +│ Afterglow │████████████████████████████░░░░░░░░░░░░░░│81min │(Blue) │ +└───────────────┴───────┴─────────┴────────┘ +Total Progress: 60% +``` + diff --git a/docs/tui/ingestion.md b/docs/tui/ingestion.md new file mode 100644 index 00000000..c403577c --- /dev/null +++ b/docs/tui/ingestion.md @@ -0,0 +1,89 @@ +# Ingestion Terminal User Interface + +## List View + +The ingestion list view displays a table of all ingestions with the following columns: + +### Columns + +1. **ID**: Unique identifier for each ingestion +2. **Substance**: Name of the substance +3. **ROA**: Route of administration +4. **Dosage**: Shows both the numerical value and visual classification + - Format: `"100 mg ●●●○○"` + - Filled circles (●) indicate dosage level + - Empty circles (○) indicate remaining levels + - More filled circles = higher dosage classification + - Classification is based on substance-specific dosage ranges + +5. **Phase**: Shows both the current phase and progress + - Phase names: Onset, Comeup, Peak, Comedown, Afterglow + - Progress bar shows total progress through phases: + ``` + Onset: ▰▱▱▱▱ (20% through total duration) + Comeup: ▰▰▱▱▱ (40% through total duration) + Peak: ▰▰▰▱▱ (60% through total duration) + Comedown: ▰▰▰▰▱ (80% through total duration) + Afterglow: ▰▰▰▰▰ (100% through total duration) + ``` + - Color coding: + - Onset: Blue + - Comeup: Green + - Peak: Red + - Comedown: Yellow + - Afterglow: Light gray + - Unknown/Completed: Dark gray + - Progress updates in real-time based on ingestion time and phase durations + +6. **Time**: Human-readable time since ingestion (e.g., "2 hours ago") + +### Visual States + +- **Completed Ingestions**: + - Entire row appears dimmed in gray + - Shows "Completed" in the phase column + - Progress bar shows all filled blocks (▰▰▰▰▰) + - Indicates the ingestion has passed through all phases + +- **Active Ingestions**: + - Normal brightness + - Shows current phase with color-coded progress + - Progress bar updates in real-time + - Reflects the current state of the ingestion + +- **Selected Row**: + - Highlighted with dark gray background + - Bold text + - Indicates the currently focused ingestion + +### Navigation & Controls + +- **Keyboard Controls**: + - `↑/k`: Move selection up + - `↓/j`: Move selection down + - `Enter`: View detailed information about selected ingestion + - `n`: Create new ingestion + - `q`: Return to previous screen + - `r`: Refresh ingestion list + +- **Selection**: + - Use arrow keys or vim-style navigation (j/k) + - Selected ingestion is highlighted + - Selection wraps around at list boundaries + - Used for viewing details or performing actions + +### Empty State + +When no ingestions are present, the view shows: +- "No ingestions found" in gray +- "Press 'n' to create a new ingestion" in dark gray +- Both messages are center-aligned +- Provides clear instruction for adding first ingestion + +### Auto-Updates + +- List refreshes automatically to show: + - Real-time phase progression + - Updated time since ingestion + - Phase transitions + - Completion status changes \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 717426e6..607eac61 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "nightly" -components = ["rustfmt", "rust-src", "rustc-codegen-cranelift-preview"] \ No newline at end of file +profile = "complete" \ No newline at end of file diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index f8ffd98a..50943d36 100755 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1,243 +1,5 @@ -use crate::ingestion::Ingestion; -use crate::ingestion::IngestionDate; -use crate::substance::DosageClassification; -use crate::substance::Dosages; -use crate::substance::Substance; -use crate::substance::route_of_administration::phase::PHASE_ORDER; -use chrono::TimeDelta; -use miette::miette; +pub mod model; +pub mod service; + use serde::Serialize; use std::ops::Add; -use std::ops::Range; - -/// `IngestionAnalysis` is a struct that represents the analysis of an ingestion -/// event. It contains various fields to store information about the ingestion, -/// the substance.rs ingested, the dosage classification, the current phase of -/// the ingestion, the start and end times of the ingestion, the phases of the -/// ingestion, the total duration excluding the afterglow phase, and the -/// progress of the ingestion. -#[derive(Debug, Serialize)] -pub struct IngestionAnalysis -{ - /// The ingestion event associated with this analysis. - /// This field is wrapped in an `Option` and a `Box` to allow for optional - /// ownership. - #[serde(skip_serializing)] - pub(crate) ingestion: Option>, - - /// The substance.rs ingested in this event. - /// This field is wrapped in an `Option` and a `Box` to allow for optional - /// ownership. - #[serde(skip_serializing)] - substance: Option>, - - /// The classification of the dosage for this ingestion. - /// This field is an `Option` to allow for cases where the dosage - /// classification cannot be determined. - pub(crate) dosage_classification: Option, - - /// The current phase of the ingestion, if it can be determined. - /// This field is an `Option` to allow for cases where the current phase - /// cannot be determined. - #[serde(skip_serializing)] - pub(crate) current_phase: - Option, - - /// The start time of the ingestion event (copy of ingestion.ingestion_date) - ingestion_start: IngestionDate, - - /// The end time of the ingestion event. - /// Ingestion end time is a range of ingestion start time and duration of - /// all phases (excluding afterglow). - ingestion_end: IngestionDate, - - /// A vector of `IngestionPhase` structs representing the different phases - /// of the ingestion event. - pub(crate) phases: Vec, -} - - -#[derive(Debug, Clone, Serialize)] -pub struct IngestionPhase -{ - pub(crate) class: crate::substance::route_of_administration::phase::PhaseClassification, - pub(crate) duration_range: Range, - pub(crate) prev: Option>, - pub(crate) next: Option>, -} - -impl IngestionAnalysis -{ - pub async fn analyze(ingestion: Ingestion, substance: Substance) -> miette::Result - { - let roa = substance - .routes_of_administration - .get(&ingestion.route) - .ok_or_else(|| miette!("Failed to find route of administration for substance.rs"))? - .clone(); - - let mut phases = Vec::new(); - let mut total_start_time: Option = None; - let mut total_end_time: Option = None; - let mut total_end_time_excl_afterglow: Option = None; - - let mut current_start_range = ingestion.ingestion_date; - let mut prev_phase: Option = None; - - for &phase_type in PHASE_ORDER.iter() - { - if let Some(phase) = roa - .phases - .iter() - .find(|(p, _)| **p == phase_type) - .map(|(_, v)| v) - { - let start_time_range = current_start_range; - let end_time_range = start_time_range - .add(TimeDelta::from_std(phase.start.to_std().unwrap()).unwrap()); - - total_start_time = - Some(total_start_time.map_or(start_time_range, |s| s.min(start_time_range))); - total_end_time = - Some(total_end_time.map_or(end_time_range, |e| e.max(end_time_range))); - - if phase_type != crate::substance::route_of_administration::phase::PhaseClassification::Afterglow - { - total_end_time_excl_afterglow = Some( - total_end_time_excl_afterglow - .map_or(end_time_range, |e| e.max(end_time_range)), - ); - } - - let new_phase = IngestionPhase { - class: phase_type, - duration_range: start_time_range..end_time_range, - prev: prev_phase.clone().map(Box::new), - next: None, - }; - - if let Some(prev) = prev_phase.as_mut() - { - prev.next = Some(Box::new(new_phase.clone())); - } - - phases.push(new_phase.clone()); - prev_phase = Some(new_phase); - current_start_range = end_time_range; - } - } - - let total_range = total_start_time - .zip(total_end_time) - .map(|(start, end)| start..end) - .ok_or_else(|| miette!("Could not compute total duration"))?; - - let current_date = chrono::Local::now(); - let current_phase = phases - .iter() - .find(|phase| { - current_date >= phase.duration_range.start - && current_date < phase.duration_range.end - }) - .map(|phase| phase.class); - - let dosage_classification = classify_dosage(ingestion.dosage.clone(), &roa.dosages); - - Ok(Self { - ingestion: Some(Box::new(ingestion)), - substance: Some(Box::new(substance)), - dosage_classification, - current_phase, - ingestion_start: total_range.start, - ingestion_end: total_range.end, - phases, - }) - } - - /// The progress of the ingestion event, represented as a float between 0.0 - /// and 1.0. This value represents the fraction of the total duration - /// (excluding the afterglow phase) that has elapsed. - pub fn progress(&self) -> f64 - { - let now = chrono::Local::now(); - let total_duration = self.ingestion_end - self.ingestion_start; - let elapsed_time = if now < self.ingestion_start - { - chrono::Duration::zero() - } - else - { - (now - self.ingestion_start).min(total_duration) - }; - - (elapsed_time.num_seconds() as f64 / total_duration.num_seconds() as f64).clamp(0.0, 1.0) - } -} - - -pub(super) fn classify_dosage( - dosage: crate::substance::dosage::Dosage, - dosages: &Dosages, -) -> Option -{ - dosages - .iter() - .find(|(_, range)| range.contains(&dosage)) - .map(|(classification, _)| *classification) - .or_else(|| { - dosages - .iter() - .filter_map(|(_classification, range)| { - match (range.start.as_ref(), range.end.as_ref()) - { - | (Some(start), _) if &dosage >= start => Some(DosageClassification::Heavy), - | (_, Some(end)) if &dosage <= end => Some(DosageClassification::Threshold), - | _ => None, - } - }) - .next() - }) -} - -#[cfg(test)] -mod tests -{ - use super::*; - use crate::DATABASE_CONNECTION; - use crate::migrate_database; - use crate::substance::dosage::Dosage; - - use crate::substance::DosageClassification; - use rstest::rstest; - use std::str::FromStr; - - // #[rstest] - // #[case("10mg", DosageClassification::Threshold)] - // #[case("100mg", DosageClassification::Medium)] - // #[case("1000mg", DosageClassification::Heavy)] - // async fn should_classify_dosage( - // #[case] dosage_str: &str, - // #[case] expected: DosageClassification, - // ) - // { - // let db = &DATABASE_CONNECTION; - // migrate_database(db).await.unwrap(); - // let caffeine = - // crate::substance::repository::get_substance("caffeine", db) - // .await - // .unwrap(); - // let oral_caffeine_roa = caffeine - // .unwrap().routes_of_administration - // .get(& - // crate::substance::route_of_administration::RouteOfAdministrationClassification::Oral) - // .unwrap() - // .clone() - // .dosages; - // - // let dosage_instance = Dosage::from_str(dosage_str).unwrap(); - // let classification = classify_dosage(dosage_instance, - // &oral_caffeine_roa).unwrap(); - // - // assert_eq!(classification, expected); - // } -} diff --git a/src/analyzer/model.rs b/src/analyzer/model.rs new file mode 100644 index 00000000..c72c09b3 --- /dev/null +++ b/src/analyzer/model.rs @@ -0,0 +1,66 @@ +use crate::ingestion::model::IngestionDate; +use crate::substance::DosageClassification; +use std::ops::Range; + +/// `IngestionAnalysis` is a struct that represents the analysis of an ingestion +/// event. It contains various fields to store information about the ingestion, +/// the substance.rs ingested, the dosage classification, the current phase of +/// the ingestion, the start and end times of the ingestion, the phases of the +/// ingestion, the total duration excluding the afterglow phase, and the +/// progress of the ingestion. +#[derive(Debug, Serialize, Clone)] +pub struct IngestionAnalysis +{ + /// The ingestion event associated with this analysis. + /// This field is wrapped in an `Option` and a `Box` to allow for optional + /// ownership. + #[serde(skip_serializing)] + pub ingestion: Option>, + + /// The substance.rs ingested in this event. + /// This field is wrapped in an `Option` and a `Box` to allow for optional + /// ownership. + #[serde(skip_serializing)] + pub substance: Option>, + + /// The classification of the dosage for this ingestion. + /// This field is an `Option` to allow for cases where the dosage + /// classification cannot be determined. + pub dosage_classification: Option, + + /// The current phase of the ingestion, if it can be determined. + /// This field is an `Option` to allow for cases where the current phase + /// cannot be determined. + #[serde(skip_serializing)] + pub current_phase: + Option, + + /// The start time of the ingestion event (copy of ingestion.ingestion_date) + pub ingestion_start: IngestionDate, + + /// The end time of the ingestion event. + /// Ingestion end time is a range of ingestion start time and duration of + /// all phases (excluding afterglow). + pub ingestion_end: IngestionDate, + + /// A vector of `IngestionPhase` structs representing the different phases + /// of the ingestion event. + pub phases: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IngestionPhase +{ + pub(crate) class: crate::substance::route_of_administration::phase::PhaseClassification, + pub(crate) duration_range: Range, + pub(crate) prev: Option>, + pub(crate) next: Option>, +} + +/// !TODO +/// `JournalAnalysis` is a struct that represents the analytics of a complete +/// user's ingestion history it contains a various aspects of information, such +/// as classification by psychoactive groups, peeking into recommended dosages +/// and history of usage, pattern recognition and statically defined rule engine +/// to inform user again stupid decisions they are about to make. +pub struct JournalAnalysis {} diff --git a/src/analyzer/service.rs b/src/analyzer/service.rs new file mode 100644 index 00000000..298d66f3 --- /dev/null +++ b/src/analyzer/service.rs @@ -0,0 +1,118 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::analyzer::model::IngestionPhase; +use crate::ingestion::model::Ingestion; +use crate::ingestion::model::IngestionDate; +use crate::substance::route_of_administration::dosage; +use crate::substance::route_of_administration::phase::PHASE_ORDER; +use crate::substance::Substance; +use chrono::TimeDelta; +use miette::miette; +use std::ops::Add; + +impl IngestionAnalysis +{ + pub async fn analyze(ingestion: Ingestion, substance: Substance) -> miette::Result + { + let roa = substance + .routes_of_administration + .get(&ingestion.route) + .ok_or_else(|| miette!("Failed to find route of administration for substance.rs"))? + .clone(); + + let mut phases = Vec::new(); + let mut total_start_time: Option = None; + let mut total_end_time: Option = None; + let mut total_end_time_excl_afterglow: Option = None; + + let mut current_start_range = ingestion.ingestion_date; + let mut prev_phase: Option = None; + + for &phase_type in PHASE_ORDER.iter() + { + if let Some(phase) = roa + .phases + .iter() + .find(|(p, _)| **p == phase_type) + .map(|(_, v)| v) + { + let start_time_range = current_start_range; + let end_time_range = start_time_range + .add(TimeDelta::from_std(phase.start.to_std().unwrap()).unwrap()); + + total_start_time = + Some(total_start_time.map_or(start_time_range, |s| s.min(start_time_range))); + total_end_time = + Some(total_end_time.map_or(end_time_range, |e| e.max(end_time_range))); + + if phase_type != crate::substance::route_of_administration::phase::PhaseClassification::Afterglow + { + total_end_time_excl_afterglow = Some( + total_end_time_excl_afterglow + .map_or(end_time_range, |e| e.max(end_time_range)), + ); + } + + let new_phase = IngestionPhase { + class: phase_type, + duration_range: start_time_range..end_time_range, + prev: prev_phase.clone().map(Box::new), + next: None, + }; + + if let Some(prev) = prev_phase.as_mut() + { + prev.next = Some(Box::new(new_phase.clone())); + } + + phases.push(new_phase.clone()); + prev_phase = Some(new_phase); + current_start_range = end_time_range; + } + } + + let total_range = total_start_time + .zip(total_end_time) + .map(|(start, end)| start..end) + .ok_or_else(|| miette!("Could not compute total duration"))?; + + let current_date = chrono::Local::now(); + let current_phase = phases + .iter() + .find(|phase| { + current_date >= phase.duration_range.start + && current_date < phase.duration_range.end + }) + .map(|phase| phase.class); + + let dosage_classification = dosage::classify_dosage(ingestion.dosage.clone(), &roa.dosages); + + Ok(Self { + ingestion: Some(Box::new(ingestion)), + substance: Some(Box::new(substance)), + dosage_classification, + current_phase, + ingestion_start: total_range.start, + ingestion_end: total_range.end, + phases, + }) + } + + /// The progress of the ingestion event, represented as a float between 0.0 + /// and 1.0. This value represents the fraction of the total duration + /// (excluding the afterglow phase) that has elapsed. + pub fn progress(&self) -> f64 + { + let now = chrono::Local::now(); + let total_duration = self.ingestion_end - self.ingestion_start; + let elapsed_time = if now < self.ingestion_start + { + chrono::Duration::zero() + } + else + { + (now - self.ingestion_start).min(total_duration) + }; + + (elapsed_time.num_seconds() as f64 / total_duration.num_seconds() as f64).clamp(0.0, 1.0) + } +} diff --git a/src/cli/analyze.rs b/src/cli/analyze.rs index 34ef7369..b017065a 100644 --- a/src/cli/analyze.rs +++ b/src/cli/analyze.rs @@ -1,14 +1,13 @@ -use crate::analyzer::IngestionAnalysis; -use crate::analyzer::IngestionPhase; -use crate::cli::OutputFormat; -use crate::formatter::Formatter; -use crate::ingestion::Ingestion; -use crate::substance::DosageClassification; -use crate::substance::dosage::Dosage; -use crate::substance::route_of_administration::RouteOfAdministrationClassification; +use crate::analyzer::model::IngestionAnalysis; +use crate::analyzer::model::IngestionPhase; +use crate::core::CommandHandler; +use crate::cli::formatter::Formatter; +use crate::ingestion::model::Ingestion; +use crate::substance::route_of_administration::dosage::Dosage; use crate::substance::route_of_administration::phase::PhaseClassification; +use crate::substance::route_of_administration::RouteOfAdministrationClassification; +use crate::substance::DosageClassification; use crate::utils::AppContext; -use crate::utils::CommandHandler; use async_trait::async_trait; use chrono::DateTime; use chrono::Local; @@ -19,9 +18,9 @@ use owo_colors::OwoColorize; use sea_orm::EntityTrait; use std::borrow::Cow; use std::str::FromStr; +use tabled::settings::Style; use tabled::Table; use tabled::Tabled; -use tabled::settings::Style; fn display_date(date: &DateTime) -> String { HumanTime::from(*date).to_string() } @@ -197,12 +196,15 @@ impl CommandHandler for AnalyzeIngestion { let ingestion: Ingestion = match self.ingestion_id { - | Some(..) => crate::orm::ingestion::Entity::find_by_id(self.ingestion_id.unwrap()) - .one(ctx.database_connection) - .await - .into_diagnostic()? - .unwrap_or_else(|| panic!("Ingestion not found")) - .into(), + | Some(..) => + { + crate::database::entities::ingestion::Entity::find_by_id(self.ingestion_id.unwrap()) + .one(ctx.database_connection) + .await + .into_diagnostic()? + .unwrap_or_else(|| panic!("Ingestion not found")) + .into() + } | None => Ingestion { id: Default::default(), dosage: self.dosage.clone().expect("Dosage not provided"), @@ -222,7 +224,7 @@ impl CommandHandler for AnalyzeIngestion { let substance = substance.unwrap(); let analysis: AnalyzerReportViewModel = - crate::analyzer::IngestionAnalysis::analyze(ingestion, substance) + IngestionAnalysis::analyze(ingestion, substance) .await? .into(); println!("{}", analysis.format(ctx.stdout_format)); diff --git a/src/formatter.rs b/src/cli/formatter.rs similarity index 100% rename from src/formatter.rs rename to src/cli/formatter.rs diff --git a/src/cli/ingestion.rs b/src/cli/ingestion.rs index 87f4efbd..73154f3e 100644 --- a/src/cli/ingestion.rs +++ b/src/cli/ingestion.rs @@ -1,15 +1,15 @@ use crate::cli::OutputFormat; -use crate::formatter::Formatter; -use crate::formatter::FormatterVector; -use crate::migration::async_trait::async_trait; -use crate::orm::ingestion; -use crate::orm::ingestion::Entity as Ingestion; -use crate::orm::ingestion::Model; -use crate::substance::dosage::Dosage; +use crate::core::CommandHandler; +use async_trait::async_trait; +use crate::database::entities::ingestion; +use crate::database::entities::ingestion::Entity as Ingestion; +use crate::database::entities::ingestion::Model; +use crate::cli::formatter::Formatter; +use crate::cli::formatter::FormatterVector; +use crate::substance::route_of_administration::dosage::Dosage; use crate::substance::route_of_administration::RouteOfAdministrationClassification; -use crate::utils::AppContext; -use crate::utils::CommandHandler; use crate::utils::parse_date_string; +use crate::utils::AppContext; use chrono::DateTime; use chrono::Local; use chrono::TimeZone; @@ -17,8 +17,8 @@ use chrono_humanize::HumanTime; use clap::Parser; use clap::Subcommand; use log::info; -use miette::IntoDiagnostic; use miette::miette; +use miette::IntoDiagnostic; use sea_orm::ActiveModelTrait; use sea_orm::ActiveValue; use sea_orm::EntityTrait; @@ -28,8 +28,8 @@ use serde::Deserialize; use serde::Serialize; use std::str::FromStr; use tabled::Tabled; -use tracing::Level; use tracing::event; +use tracing::Level; use typed_builder::TypedBuilder; /** @@ -361,9 +361,9 @@ impl From for IngestionViewModel } } -impl From for IngestionViewModel +impl From for IngestionViewModel { - fn from(model: crate::ingestion::Ingestion) -> Self + fn from(model: crate::ingestion::model::Ingestion) -> Self { let dosage = model.dosage; Self::builder() diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6b496f0e..cb8e139b 100755 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,20 +3,19 @@ use clap::ColorChoice; use clap::CommandFactory; use clap::Parser; use clap::Subcommand; -use clap_complete::Shell; use ingestion::IngestionCommand; use miette::IntoDiagnostic; use sea_orm::prelude::async_trait::async_trait; -use std::fs; -use std::fs::create_dir_all; use substance::SubstanceCommand; +use crate::core::CommandHandler; use crate::utils::AppContext; -use crate::utils::CommandHandler; use analyze::AnalyzeIngestion; mod analyze; mod ingestion; +mod stats; pub mod substance; +pub mod formatter; fn is_interactive() -> bool { atty::is(Stream::Stdout) } @@ -96,6 +95,7 @@ impl CommandHandler for ApplicationCommands | ApplicationCommands::Substance(cmd) => cmd.handle(ctx).await, | ApplicationCommands::Completions(cmd) => cmd.handle(ctx).await, | ApplicationCommands::Analyzer(cmd) => cmd.handle(ctx).await, + | ApplicationCommands::Stats(cmd) => cmd.handle(ctx).await, } } } @@ -112,6 +112,7 @@ pub enum ApplicationCommands Analyzer(AnalyzeIngestion), /// Generate shell completions Completions(GenerateCompletion), + Stats(stats::GetStatistics), } /// 🧬 Intelligent dosage tracker application with purpose to monitor diff --git a/src/cli/stats.rs b/src/cli/stats.rs new file mode 100644 index 00000000..a5dc539e --- /dev/null +++ b/src/cli/stats.rs @@ -0,0 +1,215 @@ +use crate::cli::OutputFormat; +use crate::core::CommandHandler; +use crate::database::entities::ingestion; +use crate::cli::formatter::Formatter; +use crate::cli::formatter::FormatterVector; +use crate::substance::route_of_administration::dosage::Dosage; +use crate::utils::AppContext; +use async_trait::async_trait; +use chrono::Days; +use chrono::NaiveDate; +use chrono::Utc; +use clap::Parser; +use hashbrown::HashMap; +use miette::IntoDiagnostic; +use miette::Result; +use owo_colors::OwoColorize; +use ratatui::widgets::Chart; +use sea_orm::ColumnTrait; +use sea_orm::DatabaseConnection; +use sea_orm::EntityTrait; +use sea_orm::PaginatorTrait; +use sea_orm::QueryFilter; +use sea_orm::QuerySelect; +use serde::Serialize; +use tabled::Tabled; + +async fn get_period_statistics( + connection: &DatabaseConnection, + rolling_window: i32, +) -> Result> +{ + #[derive(Debug, sea_orm::FromQueryResult)] + struct RawStats + { + substance_name: String, + total_dosage: f64, + min_dosage: f64, + max_dosage: f64, + count: i64, + } + + let rolling_window_start_date = + chrono::Local::now().date_naive() - Days::new(rolling_window as u64); + + let results = ingestion::Entity::find() + .select_only() + .column(ingestion::Column::SubstanceName) + .column_as( + sea_orm::prelude::Expr::col(ingestion::Column::Dosage).sum(), + "total_dosage", + ) + .column_as( + sea_orm::prelude::Expr::col(ingestion::Column::Dosage).min(), + "min_dosage", + ) + .column_as( + sea_orm::prelude::Expr::col(ingestion::Column::Dosage).max(), + "max_dosage", + ) + .column_as( + sea_orm::prelude::Expr::col(ingestion::Column::Id).count(), + "count", + ) + .filter( + ingestion::Column::IngestedAt + .gte(rolling_window_start_date.and_hms_opt(0, 0, 0).unwrap()), + ) + .group_by(ingestion::Column::SubstanceName) + .into_model::() + .all(connection) + .await + .into_diagnostic()?; + + let period_stats: Vec<_> = results + .into_iter() + .map(|stats| { + let days = rolling_window as f64; + PeriodStatistics { + substance_name: stats.substance_name, + daily_dosage: Dosage::from_base_units(stats.total_dosage / days), + sum_dosage: Dosage::from_base_units(stats.total_dosage), + min_dosage: Dosage::from_base_units(stats.min_dosage), + max_dosage: Dosage::from_base_units(stats.max_dosage), + count: stats.count as i32, + } + }) + .collect(); + + Ok(period_stats) +} + +/// Fetch ingestion counts for the last 30 days and render a histogram +async fn ingestion_histogram(connection: &DatabaseConnection) -> Result<()> +{ + #[derive(Debug, sea_orm::FromQueryResult)] + struct DailyIngestion + { + ingestion_date: NaiveDate, + ingestion_count: i64, + } + + let today = Utc::now().date_naive(); + let thirty_days_ago = today - chrono::Duration::days(30); + + let results = ingestion::Entity::find() + .select_only() + .column_as( + sea_orm::prelude::Expr::cust("DATE(ingested_at)"), + "ingestion_date", + ) + .column_as( + sea_orm::prelude::Expr::col(ingestion::Column::Id).count(), + "ingestion_count", + ) + .filter(ingestion::Column::IngestedAt.gte(thirty_days_ago.and_hms_opt(0, 0, 0).unwrap())) + .group_by(sea_orm::prelude::Expr::cust("DATE(ingested_at)")) + .into_model::() + .all(connection) + .await + .into_diagnostic()?; + + let mut ingestion_data: HashMap = HashMap::new(); + for entry in results + { + ingestion_data.insert(entry.ingestion_date, entry.ingestion_count); + } + + // let histogram_data: Vec<(f32, f32)> = (0..=30) + // .map(|offset| { + // let date = thirty_days_ago + chrono::Duration::days(offset); + // let count = ingestion_data.get(&date).cloned().unwrap_or(0) as f32; + // (offset as f32, count) + // }) + // .collect(); + // + // Chart::new(80, 20, 0.0, 30.0) + // .lineplot(&Shape::Bars(&histogram_data)) + // .display(); + + Ok(()) +} + +/// Get statistics of logged ingestions +#[derive(Parser, Debug)] +#[command(version, about = "Get statistics of logged ingestions", long_about)] +pub struct GetStatistics +{ + /// Size of the rolling window in days + #[arg(short, long, default_value = "30")] + pub rolling_window: i32, +} + +#[derive(Parser, Debug, Tabled, Serialize)] +struct PeriodStatistics +{ + #[tabled(rename = "Substance Name")] + substance_name: String, + #[tabled(rename = "AVG (per day)")] + #[tabled(display_with = "Dosage::to_string")] + daily_dosage: Dosage, + #[tabled(rename = "SUM")] + #[tabled(display_with = "Dosage::to_string")] + sum_dosage: Dosage, + #[tabled(rename = "MIN")] + #[tabled(display_with = "Dosage::to_string")] + min_dosage: Dosage, + #[tabled(rename = "MAX")] + #[tabled(display_with = "Dosage::to_string")] + max_dosage: Dosage, + #[tabled(rename = "COUNT")] + count: i32, +} + +#[async_trait] +impl CommandHandler for GetStatistics +{ + async fn handle<'a>(&self, context: AppContext<'a>) -> Result<()> + { + let connection: &DatabaseConnection = context.database_connection; + + println!("\n{}", "Statistics Overview".bold().underline().blue()); + + // println!( + // "{}: {}", + // "Total Ingestions".green(), + // ingestion_count(&connection)?.to_string().yellow() + // ); + + let period_stats = get_period_statistics(&connection, self.rolling_window).await?; + + println!( + "{}: {}", + "Total Unique Substances".green(), + period_stats.len().to_string().yellow() + ); + + println!( + "\n{}\n{}", + format!("Substance Statistics (Last {} Days)", self.rolling_window) + .bold() + .underline() + .blue(), + FormatterVector::new(period_stats).format(OutputFormat::Pretty) + ); + + println!("\n{}", "Ingestion Histogram".bold().underline().blue()); + ingestion_histogram(connection).await?; + + println!("\n{}", "End of Statistics Report".dimmed()); + + Ok(()) + } +} + +impl Formatter for PeriodStatistics {} diff --git a/src/cli/substance.rs b/src/cli/substance.rs index c02f2386..4ef78b1d 100644 --- a/src/cli/substance.rs +++ b/src/cli/substance.rs @@ -1,8 +1,9 @@ -use crate::formatter::Formatter; -use crate::migration::async_trait::async_trait; +use async_trait::async_trait; +use crate::core::CommandHandler; +use crate::cli::formatter::Formatter; +use crate::substance::error::SubstanceError; use crate::substance::SubstanceTable; use crate::utils::AppContext; -use crate::utils::CommandHandler; use clap::Args; use clap::Parser; use clap::Subcommand; @@ -107,10 +108,9 @@ impl CommandHandler for GetSubstance let substance: Substance = crate::substance::repository::get_substance(&self.name, ctx.database_connection) .await? - .unwrap() + .unwrap_or_else(|| panic!("{}", SubstanceError::NotFound)) .into(); - println!("{}", serde_json::to_string_pretty(&substance).unwrap()); Ok(substance) diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 00000000..0de5b65d --- /dev/null +++ b/src/core/config.rs @@ -0,0 +1,42 @@ +use lazy_static::lazy_static; +use std::env::temp_dir; +use std::path::Path; +use std::path::PathBuf; + +pub const NAME: &str = env!("CARGO_PKG_NAME"); +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +lazy_static::lazy_static! { + pub static ref CONFIG: Config = Config::default(); + /// Returns the path to the project's data directory. + pub static ref DATA_DIR: Box = directories::ProjectDirs::from("com", "keinsell", NAME).unwrap_or_else(|| panic!("project data directory not found")).data_dir().into(); + /// Returns the path to the project's cache directory. + pub static ref CACHE_DIR: Box = directories::ProjectDirs::from("com", "keinsell", NAME).unwrap_or_else(|| panic!("project data directory not found")).cache_dir().into(); + /// Returns the path to the project's config directory. + pub static ref CONFIG_DIR: Box = directories::ProjectDirs::from("com", "keinsell", NAME).unwrap_or_else(|| panic!("project data directory not found")).config_dir().into(); +} + + +// TODO(NEU-1): Implement +#[derive(Debug, Clone)] +pub struct Config +{ + pub sqlite_path: PathBuf, +} + +impl Default for Config +{ + fn default() -> Self + { + let mut journal_path = DATA_DIR.join("journal.db").clone(); + + if cfg!(test) || cfg!(debug_assertions) + { + journal_path = temp_dir().join("neuronek.sqlite"); + } + + Config { + sqlite_path: journal_path, + } + } +} diff --git a/src/core/error_handling.rs b/src/core/error_handling.rs new file mode 100644 index 00000000..1b2044ac --- /dev/null +++ b/src/core/error_handling.rs @@ -0,0 +1,12 @@ +/// Initialize diagnostic panic hook which would provide additional context and +/// error location when application have debugging assertions in build or setup +/// human-friendly report with guide how to report issue when application was +/// built in release mode and panicked. */ +pub fn setup_diagnostics() +{ + miette::set_panic_hook(); + #[cfg(not(debug_assertions))] + { + human_panic::setup_panic!(); + } +} diff --git a/src/core/foundation.rs b/src/core/foundation.rs new file mode 100644 index 00000000..ea5da368 --- /dev/null +++ b/src/core/foundation.rs @@ -0,0 +1,5 @@ +#[async_trait::async_trait] +pub trait QueryHandler +{ + async fn query(&self) -> miette::Result; +} diff --git a/src/core/logging.rs b/src/core/logging.rs new file mode 100644 index 00000000..ac456f4b --- /dev/null +++ b/src/core/logging.rs @@ -0,0 +1,13 @@ +// TODO: Implement logging +pub fn setup_logger() +{ + let log_file_path = std::env::current_dir().unwrap().join("debug.log"); + let _ = std::fs::remove_file(&log_file_path); + + use tracing_subscriber::fmt; + use tracing_subscriber::prelude::*; + + let file_layer = fmt::layer().with_writer(std::fs::File::create(log_file_path).unwrap()); + + tracing_subscriber::registry().with(file_layer).init(); +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 00000000..eac782f5 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,14 @@ +use crate::utils::AppContext; + +pub mod config; +pub(crate) mod error_handling; +mod foundation; +pub(crate) mod logging; + +pub use foundation::QueryHandler; + +#[async_trait::async_trait] +pub trait CommandHandler +{ + async fn handle<'a>(&self, ctx: AppContext<'a>) -> miette::Result; +} diff --git a/src/migration/README.md b/src/database/README.md similarity index 100% rename from src/migration/README.md rename to src/database/README.md diff --git a/src/orm/ingestion.rs b/src/database/entities/ingestion.rs similarity index 90% rename from src/orm/ingestion.rs rename to src/database/entities/ingestion.rs index 71c21dde..5430d384 100755 --- a/src/orm/ingestion.rs +++ b/src/database/entities/ingestion.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 use sea_orm::entity::prelude::*; use serde::Deserialize; diff --git a/src/orm/mod.rs b/src/database/entities/mod.rs similarity index 76% rename from src/orm/mod.rs rename to src/database/entities/mod.rs index 88ef39ae..928657db 100755 --- a/src/orm/mod.rs +++ b/src/database/entities/mod.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 pub mod prelude; diff --git a/src/database/entities/prelude.rs b/src/database/entities/prelude.rs new file mode 100755 index 00000000..f3ccc67c --- /dev/null +++ b/src/database/entities/prelude.rs @@ -0,0 +1,3 @@ +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 + +pub use super::ingestion::Entity as Ingestion; diff --git a/src/orm/substance.rs b/src/database/entities/substance.rs similarity index 95% rename from src/orm/substance.rs rename to src/database/entities/substance.rs index 43040030..94b13cb0 100755 --- a/src/orm/substance.rs +++ b/src/database/entities/substance.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 use sea_orm::entity::prelude::*; use serde::Deserialize; diff --git a/src/orm/substance_route_of_administration.rs b/src/database/entities/substance_route_of_administration.rs similarity index 96% rename from src/orm/substance_route_of_administration.rs rename to src/database/entities/substance_route_of_administration.rs index b4a9ae5f..b1055655 100755 --- a/src/orm/substance_route_of_administration.rs +++ b/src/database/entities/substance_route_of_administration.rs @@ -1,5 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 - +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 use sea_orm::entity::prelude::*; use serde::Deserialize; diff --git a/src/orm/substance_route_of_administration_dosage.rs b/src/database/entities/substance_route_of_administration_dosage.rs similarity index 95% rename from src/orm/substance_route_of_administration_dosage.rs rename to src/database/entities/substance_route_of_administration_dosage.rs index 99f360ff..4a745919 100755 --- a/src/orm/substance_route_of_administration_dosage.rs +++ b/src/database/entities/substance_route_of_administration_dosage.rs @@ -1,5 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 - +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 use sea_orm::entity::prelude::*; use serde::Deserialize; diff --git a/src/orm/substance_route_of_administration_phase.rs b/src/database/entities/substance_route_of_administration_phase.rs similarity index 95% rename from src/orm/substance_route_of_administration_phase.rs rename to src/database/entities/substance_route_of_administration_phase.rs index 39b262c4..82b08e46 100755 --- a/src/orm/substance_route_of_administration_phase.rs +++ b/src/database/entities/substance_route_of_administration_phase.rs @@ -1,5 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 - +//! `SeaORM` Entity, @generated by sea-entities-codegen 1.1.3 use sea_orm::entity::prelude::*; use serde::Deserialize; diff --git a/src/migration/justfile b/src/database/justfile similarity index 100% rename from src/migration/justfile rename to src/database/justfile diff --git a/src/migration/migrations/20250101000001_add_ingestion_table.sql b/src/database/migrations/20250101000001_add_ingestion_table.sql similarity index 80% rename from src/migration/migrations/20250101000001_add_ingestion_table.sql rename to src/database/migrations/20250101000001_add_ingestion_table.sql index 59a8dfdf..768e9415 100755 --- a/src/migration/migrations/20250101000001_add_ingestion_table.sql +++ b/src/database/migrations/20250101000001_add_ingestion_table.sql @@ -12,7 +12,17 @@ CREATE TABLE `ingestion` -- Create "seaql_migrations" table CREATE TABLE IF NOT EXISTS `seaql_migrations` ( - `version` varchar NOT NULL, - `applied_at` bigint NOT NULL, - PRIMARY KEY (`version`) -); + `version` + varchar + NOT + NULL, + `applied_at` + bigint + NOT + NULL, + PRIMARY + KEY +( + `version` +) + ); diff --git a/src/migration/migrations/20250101000002_import_substance.sql b/src/database/migrations/20250101000002_import_substance.sql similarity index 99% rename from src/migration/migrations/20250101000002_import_substance.sql rename to src/database/migrations/20250101000002_import_substance.sql index 99d5e387..453508f0 100755 --- a/src/migration/migrations/20250101000002_import_substance.sql +++ b/src/database/migrations/20250101000002_import_substance.sql @@ -5,17 +5,17 @@ CREATE TABLE `substance` `name` text NOT NULL, `common_names` text NOT NULL, `brand_names` text NOT NULL, - `substitutive_name` text NULL, + `substitutive_name` text NULL, `systematic_name` text NOT NULL, `pubchem_cid` integer NOT NULL, - `unii` text NULL, - `cas_number` text NULL, + `unii` text NULL, + `cas_number` text NULL, `inchi_key` text NOT NULL, `smiles` text NOT NULL, - `psychonautwiki_url` text NULL, + `psychonautwiki_url` text NULL, `psychoactive_class` text NOT NULL, - `chemical_class` text NULL, - `description` text NULL, + `chemical_class` text NULL, + `description` text NULL, PRIMARY KEY (`id`) ); -- Create index "substance_id_key" to table: "substance" @@ -155,7 +155,8 @@ CREATE TABLE `substance_route_of_administration_dosage` -- Create index "route_of_administration_dosage_intensivity_routeOfAdministrationId_key" to table: "substance_route_of_administration_dosage" CREATE UNIQUE INDEX `route_of_administration_dosage_intensivity_routeOfAdministrationId_key` ON `substance_route_of_administration_dosage` (`intensity`, `routeOfAdministrationId`); -- Disable the enforcement of foreign-keys constraints -PRAGMA foreign_keys = off; +PRAGMA +foreign_keys = off; -- Create "new_substance_route_of_administration_phase" table CREATE TABLE `new_substance_route_of_administration_phase` ( @@ -13654,21 +13655,65 @@ INSERT INTO substance_route_of_administration_dosage VALUES ('3b54f9aead9bf6a954607ca9314b57461b19ec95a0681499d5acad2547d11122d5cc9e6374c3df018a6c11964f3742d6bdeab47a4051ea354872f3beacee5a10', 'heavy', 15.0, 0.0, 'mg', '319bdcc6327775a15fbce77e9fc1a3bb22a2a62acca3fe71f00b57c5d1094607047975ae4e88c548a8d6711be13d82c27bb45dba92e061df92feb834aeb3a8b7'); -PRAGMA foreign_keys = on; +PRAGMA +foreign_keys = on; -- Create "atlas_schema_revisions" table CREATE TABLE IF NOT EXISTS `atlas_schema_revisions` ( - `version` text NOT NULL, - `description` text NOT NULL, - `type` integer NOT NULL DEFAULT 2, - `applied` integer NOT NULL DEFAULT 0, - `total` integer NOT NULL DEFAULT 0, - `executed_at` datetime NOT NULL, - `execution_time` integer NOT NULL, - `error` text NULL, - `error_stmt` text NULL, - `hash` text NOT NULL, - `partial_hashes` json NULL, - `operator_version` text NOT NULL, - PRIMARY KEY (`version`) -); + `version` + text + NOT + NULL, + `description` + text + NOT + NULL, + `type` + integer + NOT + NULL + DEFAULT + 2, + `applied` + integer + NOT + NULL + DEFAULT + 0, + `total` + integer + NOT + NULL + DEFAULT + 0, + `executed_at` + datetime + NOT + NULL, + `execution_time` + integer + NOT + NULL, + `error` + text + NULL, + `error_stmt` + text + NULL, + `hash` + text + NOT + NULL, + `partial_hashes` + json + NULL, + `operator_version` + text + NOT + NULL, + PRIMARY + KEY +( + `version` +) + ); diff --git a/src/migration/migrations/20250101235153_drop_unrelated_data.sql b/src/database/migrations/20250101235153_drop_unrelated_data.sql similarity index 77% rename from src/migration/migrations/20250101235153_drop_unrelated_data.sql rename to src/database/migrations/20250101235153_drop_unrelated_data.sql index 30ffce96..156d7f1d 100755 --- a/src/migration/migrations/20250101235153_drop_unrelated_data.sql +++ b/src/database/migrations/20250101235153_drop_unrelated_data.sql @@ -1,5 +1,6 @@ alter table substance - drop brand_names; +drop +brand_names; drop table substance_interactions; drop table effect; drop table psychoactive_class; @@ -12,14 +13,20 @@ drop index substance_smiles_key; drop index substance_systematic_name_key; drop index substance_substitutive_name_key; alter table substance - drop systematic_name; +drop +systematic_name; alter table substance - drop substitutive_name; +drop +substitutive_name; alter table substance - drop inchi_key; +drop +inchi_key; alter table substance - drop unii; +drop +unii; alter table substance - drop cas_number; +drop +cas_number; alter table substance - drop smiles; \ No newline at end of file +drop +smiles; \ No newline at end of file diff --git a/src/migration/migrations/20250104060831_update_dosage_bounds.sql b/src/database/migrations/20250104060831_update_dosage_bounds.sql similarity index 100% rename from src/migration/migrations/20250104060831_update_dosage_bounds.sql rename to src/database/migrations/20250104060831_update_dosage_bounds.sql diff --git a/src/database/migrations/20250108183655_update_route_of_administration_classification_values.sql b/src/database/migrations/20250108183655_update_route_of_administration_classification_values.sql new file mode 100755 index 00000000..3ff07dff --- /dev/null +++ b/src/database/migrations/20250108183655_update_route_of_administration_classification_values.sql @@ -0,0 +1,32 @@ +-- Fix all 'route_of_administration' classifications in the 'ingestion' table + +UPDATE ingestion +SET route_of_administration = 'buccal' +WHERE route_of_administration = '"buccal"'; +UPDATE ingestion +SET route_of_administration = 'inhaled' +WHERE route_of_administration = '"inhaled"'; +UPDATE ingestion +SET route_of_administration = 'insufflated' +WHERE route_of_administration = '"insufflated"'; +UPDATE ingestion +SET route_of_administration = 'intramuscular' +WHERE route_of_administration = '"intramuscular"'; +UPDATE ingestion +SET route_of_administration = 'intravenous' +WHERE route_of_administration = '"intravenous"'; +UPDATE ingestion +SET route_of_administration = 'oral' +WHERE route_of_administration = '"oral"'; +UPDATE ingestion +SET route_of_administration = 'rectal' +WHERE route_of_administration = '"rectal"'; +UPDATE ingestion +SET route_of_administration = 'smoked' +WHERE route_of_administration = '"smoked"'; +UPDATE ingestion +SET route_of_administration = 'sublingual' +WHERE route_of_administration = '"sublingual"'; +UPDATE ingestion +SET route_of_administration = 'transdermal' +WHERE route_of_administration = '"transdermal"'; \ No newline at end of file diff --git a/src/migration/migrations/atlas.sum b/src/database/migrations/atlas.sum similarity index 100% rename from src/migration/migrations/atlas.sum rename to src/database/migrations/atlas.sum diff --git a/src/migration/mod.rs b/src/database/migrator.rs old mode 100755 new mode 100644 similarity index 98% rename from src/migration/mod.rs rename to src/database/migrator.rs index 47ca2f8c..d6f16e40 --- a/src/migration/mod.rs +++ b/src/database/migrator.rs @@ -4,7 +4,7 @@ pub use sea_orm_migration::prelude::*; use rust_embed::Embed; #[derive(Embed)] -#[folder = "src/migration/migrations"] +#[folder = "src/database/migrations"] pub struct Migrations; diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100755 index 00000000..98bead55 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,5 @@ +pub mod migrator; +pub mod entities; + +pub use migrator::Migrator; +pub use entities::prelude::*; \ No newline at end of file diff --git a/src/migration/schema.sql b/src/database/schema.sql similarity index 94% rename from src/migration/schema.sql rename to src/database/schema.sql index 9ac7e3b1..c56e05f7 100755 --- a/src/migration/schema.sql +++ b/src/database/schema.sql @@ -8,10 +8,10 @@ CREATE TABLE `atlas_schema_revisions` `total` integer NOT NULL DEFAULT 0, `executed_at` datetime NOT NULL, `execution_time` integer NOT NULL, - `error` text NULL, - `error_stmt` text NULL, + `error` text NULL, + `error_stmt` text NULL, `hash` text NOT NULL, - `partial_hashes` json NULL, + `partial_hashes` json NULL, `operator_version` text NOT NULL, PRIMARY KEY (`version`) ); @@ -40,10 +40,10 @@ CREATE TABLE `substance` `name` text NOT NULL, `common_names` text NOT NULL, `pubchem_cid` integer NOT NULL, - `psychonautwiki_url` text NULL, + `psychonautwiki_url` text NULL, `psychoactive_class` text NOT NULL, - `chemical_class` text NULL, - `description` text NULL, + `chemical_class` text NULL, + `description` text NULL, PRIMARY KEY (`id`) ); -- Create index "substance_id_key" to table: "substance" diff --git a/src/ingestion/command.rs b/src/ingestion/command.rs new file mode 100644 index 00000000..f34e4195 --- /dev/null +++ b/src/ingestion/command.rs @@ -0,0 +1,76 @@ +use crate::substance::route_of_administration::dosage::Dosage; +use crate::substance::route_of_administration::RouteOfAdministrationClassification; +use chrono::DateTime; +use chrono::Local; +use chrono_english::Dialect; +use clap::Parser; +use clap::Subcommand; +use miette::IntoDiagnostic; +use std::str::FromStr; + +/** +# Log Ingestion + +The `Log Ingestion` feature is the core functionality of neuronek, enabling users to record +information about any substances they consume. +This feature is designed for tracking supplements, medications, nootropics, +or any psychoactive substances in a structured and organized way. + +By logging ingestion, users can provide details such as the substance.rs name, dosage, and the time of ingestion. +This data is stored in a low-level database that serves as the foundation for further features, +such as journaling, analytics, or integrations with external tools. +While power users may prefer to work directly with this raw data, +many user-friendly abstractions are planned to make this process seamless, +such as simplified commands (e.g., `neuronek a coffee`) for quicker entries. + +Logging ingestion's not only serves the purpose of record-keeping +but also helps users build a personalized database of their consumption habits. +This database can be used to analyze trends over time, +providing insights into the long-term effects of different substances on physical and mental well-being. +*/ +#[derive(Parser, Debug)] +#[command( + version, + about = "Create a new ingestion record", + long_about, + aliases = vec!["create", "add", "make", "new", "mk"] +)] +pub struct LogIngestion +{ + /// Name of substance.rs that is being ingested, e.g. "Paracetamol" + #[arg(value_name = "SUBSTANCE", required = true)] + pub substance_name: String, + /// Dosage of given substance.rs provided as string with unit (e.g., 10 mg) + #[arg( + value_name = "DOSAGE", + required = true, + value_parser = Dosage::from_str + )] + pub dosage: Dosage, + /// Date of ingestion, by default current date is used if not provided. + /// + /// Date can be provided as timestamp and in human-readable format such as + /// "today 10:00", "yesterday 13:00", "monday 15:34" which will be later + /// parsed into proper timestamp. + #[arg( + short='t', + long="date", + default_value = "now", + value_parser=parse_date_string + )] + pub ingestion_date: DateTime, + /// Route of administration related to given ingestion (defaults to "oral") + #[arg(short = 'r', long = "roa", default_value = "oral", value_enum)] + pub route_of_administration: RouteOfAdministrationClassification, +} + +fn parse_date_string(humanized_input: &str) -> miette::Result> +{ + chrono_english::parse_date_string(humanized_input, Local::now(), Dialect::Us).into_diagnostic() +} + +#[derive(Debug, Subcommand)] +pub enum Commands +{ + Log(LogIngestion), +} diff --git a/src/ingestion/mod.rs b/src/ingestion/mod.rs index a2b94233..04741ed4 100755 --- a/src/ingestion/mod.rs +++ b/src/ingestion/mod.rs @@ -1,39 +1,5 @@ -use crate::orm::ingestion::Model; -use crate::substance::dosage::Dosage; -use crate::substance::route_of_administration::RouteOfAdministrationClassification; -use chrono::DateTime; -use chrono::Local; -use chrono::TimeZone; -use core::convert::From; -use serde::Deserialize; -use serde::Serialize; -use std::fmt::Debug; +pub(super) mod command; +pub mod model; +pub(super) mod query; -pub type IngestionDate = DateTime; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Ingestion -{ - pub id: Option, - pub substance: String, - pub dosage: Dosage, - pub route: RouteOfAdministrationClassification, - pub ingestion_date: IngestionDate, -} - -impl From for Ingestion -{ - fn from(value: Model) -> Self - { - Ingestion { - id: Some(value.id), - substance: value.substance_name, - dosage: Dosage::from_base_units(value.dosage as f64), - ingestion_date: Local.from_utc_datetime(&value.ingested_at), - route: value - .route_of_administration - .parse() - .unwrap_or(RouteOfAdministrationClassification::Oral), - } - } -} +pub use query::ListIngestion as ListIngestions; diff --git a/src/ingestion/model.rs b/src/ingestion/model.rs new file mode 100644 index 00000000..1b51b3ff --- /dev/null +++ b/src/ingestion/model.rs @@ -0,0 +1,35 @@ +use crate::database::entities::ingestion::Model; +use crate::substance::route_of_administration::dosage::Dosage; +use crate::substance::route_of_administration::RouteOfAdministrationClassification; +use chrono::DateTime; +use chrono::Local; +use chrono::TimeZone; + +pub type IngestionDate = DateTime; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ingestion +{ + pub id: Option, + pub substance: String, + pub dosage: Dosage, + pub route: RouteOfAdministrationClassification, + pub ingestion_date: IngestionDate, +} + +impl From for Ingestion +{ + fn from(value: Model) -> Self + { + Ingestion { + id: Some(value.id), + substance: value.substance_name, + dosage: Dosage::from_base_units(value.dosage as f64), + ingestion_date: Local.from_utc_datetime(&value.ingested_at), + route: value + .route_of_administration + .parse() + .unwrap_or(RouteOfAdministrationClassification::Oral), + } + } +} diff --git a/src/ingestion/query.rs b/src/ingestion/query.rs new file mode 100644 index 00000000..304d097b --- /dev/null +++ b/src/ingestion/query.rs @@ -0,0 +1,47 @@ +use crate::database::entities::ingestion; +use crate::ingestion::model::Ingestion; +use crate::utils::DATABASE_CONNECTION; +use async_trait::async_trait; +use clap::Parser; +use miette::IntoDiagnostic; +use sea_orm::prelude::async_trait; +use sea_orm::prelude::*; +use sea_orm::EntityTrait; +use sea_orm::QueryOrder; +use sea_orm::QuerySelect; +use sea_orm_migration::IntoSchemaManagerConnection; +use typed_builder::TypedBuilder; + +#[derive(Parser, Debug, Copy, Clone, Serialize, Deserialize, TypedBuilder)] +#[command(version, about = "Query ingestions", long_about, aliases = vec!["ls", "get"])] +pub struct ListIngestion +{ + /// Defines the amount of ingestion to display + #[arg(short = 'l', long, default_value_t = 10)] + pub limit: u64, +} + +impl std::default::Default for ListIngestion +{ + fn default() -> Self { Self::builder().limit(100).build() } +} + + +#[async_trait] +impl crate::core::QueryHandler> for ListIngestion +{ + async fn query(&self) -> miette::Result> + { + let ingestions = crate::database::entities::prelude::Ingestion::find() + .order_by_desc(ingestion::Column::IngestedAt) + .limit(Some(self.limit)) + .all(&DATABASE_CONNECTION.into_schema_manager_connection()) + .await + .into_diagnostic()? + .iter() + .map(|i| Ingestion::from(i.clone())) + .collect(); + + Ok(ingestions) + } +} diff --git a/src/journal/mod.rs b/src/journal/mod.rs index f235f0e6..dc8e7a44 100644 --- a/src/journal/mod.rs +++ b/src/journal/mod.rs @@ -2,7 +2,14 @@ use crate::substance::route_of_administration::phase::PhaseClassification; use chrono::DateTime; use chrono::Local; -struct JournalEvent +/// !todo Journal is about combining user's inputs against their neurochemical, +/// user is able to take "checkpoints" on their timeline and note how they feel +/// at that time. This functionality fundamentally allow for correlation of +/// ingestion with real-life mood and subjective experience and with enough data +/// collected sufficient probability could be established so user will be +/// informed what had potentially good subjective impact on them and what had +/// potentially bad. +struct Journal { id: Option, from: DateTime, diff --git a/src/main.rs b/src/main.rs index 271effbf..00e605f0 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,40 @@ #![allow(unused_imports)] -extern crate chrono; -extern crate chrono_english; -extern crate date_time_parser; #[macro_use] extern crate log; -use prelude::*; +#[macro_use] extern crate serde_derive; +use self::core::error_handling::setup_diagnostics; +use self::core::logging::setup_logger; use crate::cli::Cli; +use crate::utils::migrate_database; use crate::utils::AppContext; -use crate::utils::CommandHandler; use crate::utils::DATABASE_CONNECTION; -use crate::utils::migrate_database; -use crate::utils::setup_diagnostics; -use crate::utils::setup_logger; + use atty::Stream; use clap::Parser; +use core::CommandHandler; +use miette::Result; use std::env; mod analyzer; mod cli; -pub mod formatter; -mod ingestion; +mod core; +mod database; +pub mod ingestion; mod journal; -mod migration; -pub mod orm; mod prelude; mod substance; mod tui; mod utils; #[async_std::main] -async fn main() -> miette::Result<()> +async fn main() -> Result<()> { setup_diagnostics(); setup_logger(); migrate_database(&DATABASE_CONNECTION) .await - .expect("Database migration failed"); + .expect("Database database failed"); // TODO: Perform a check of completion scripts existence and update them or // install them https://askubuntu.com/a/1188315 @@ -47,22 +45,17 @@ async fn main() -> miette::Result<()> let no_args_provided = env::args().len() == 1; let is_interactive_terminal = atty::is(Stream::Stdout); - // By default, application should use TUI if no arguments are provided - // and the output is a terminal, otherwise it should use CLI. - if no_args_provided && is_interactive_terminal && cfg!(feature = "experimental-tui") + if no_args_provided && is_interactive_terminal { - tui::tui()?; - Ok(()) + return tui::run().await.map_err(|e| miette::miette!(e.to_string())); } - else - { - let cli = Cli::parse(); - let context = AppContext { - database_connection: &DATABASE_CONNECTION, - stdout_format: cli.format, - }; + let cli = Cli::parse(); - cli.command.handle(context).await - } + let context = AppContext { + database_connection: &DATABASE_CONNECTION, + stdout_format: cli.format, + }; + + cli.command.handle(context).await } diff --git a/src/migration/migrations/20250108183655_update_route_of_administration_classification_values.sql b/src/migration/migrations/20250108183655_update_route_of_administration_classification_values.sql deleted file mode 100755 index 95c47c29..00000000 --- a/src/migration/migrations/20250108183655_update_route_of_administration_classification_values.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Fix all 'route_of_administration' classifications in the 'ingestion' table - -UPDATE ingestion SET route_of_administration = 'buccal' WHERE route_of_administration = '"buccal"'; -UPDATE ingestion SET route_of_administration = 'inhaled' WHERE route_of_administration = '"inhaled"'; -UPDATE ingestion SET route_of_administration = 'insufflated' WHERE route_of_administration = '"insufflated"'; -UPDATE ingestion SET route_of_administration = 'intramuscular' WHERE route_of_administration = '"intramuscular"'; -UPDATE ingestion SET route_of_administration = 'intravenous' WHERE route_of_administration = '"intravenous"'; -UPDATE ingestion SET route_of_administration = 'oral' WHERE route_of_administration = '"oral"'; -UPDATE ingestion SET route_of_administration = 'rectal' WHERE route_of_administration = '"rectal"'; -UPDATE ingestion SET route_of_administration = 'smoked' WHERE route_of_administration = '"smoked"'; -UPDATE ingestion SET route_of_administration = 'sublingual' WHERE route_of_administration = '"sublingual"'; -UPDATE ingestion SET route_of_administration = 'transdermal' WHERE route_of_administration = '"transdermal"'; \ No newline at end of file diff --git a/src/orm/prelude.rs b/src/orm/prelude.rs deleted file mode 100755 index 3612467b..00000000 --- a/src/orm/prelude.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.3 - -pub use super::ingestion::Entity as Ingestion; diff --git a/src/prelude.rs b/src/prelude.rs index b429e28a..c4338401 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -2,7 +2,6 @@ extern crate chrono; extern crate chrono_english; extern crate date_time_parser; +extern crate hashbrown; extern crate predicates; - -use predicates::prelude::*; -use rayon::prelude::*; +extern crate serde_json; diff --git a/src/substance/error.rs b/src/substance/error.rs new file mode 100644 index 00000000..2bbfd06b --- /dev/null +++ b/src/substance/error.rs @@ -0,0 +1,12 @@ +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Error, Diagnostic, Debug, PartialEq, Clone)] + +pub enum SubstanceError +{ + #[error("error with disk cache")] + DiskError, + #[error("substance not found")] + NotFound, +} diff --git a/src/substance/mod.rs b/src/substance/mod.rs index 0ec0039a..b0e2d063 100755 --- a/src/substance/mod.rs +++ b/src/substance/mod.rs @@ -1,27 +1,21 @@ pub mod route_of_administration; -use crate::utils::CommandHandler; +use crate::core::CommandHandler; use clap::Parser; use clap::Subcommand; -pub mod dosage; +pub mod error; pub mod repository; use crate::substance::route_of_administration::RouteOfAdministrationClassification; -use dosage::Dosage; use hashbrown::HashMap; use iso8601_duration::Duration; +use route_of_administration::dosage::Dosage; use serde::Deserialize; use serde::Serialize; use std::fmt; use std::ops::Range; use std::str::FromStr; -use tabled::settings::Alignment; -use tabled::settings::Modify; -use tabled::settings::Panel; -use tabled::settings::Style; -use tabled::settings::Width; -use tabled::settings::object::Columns; -use tabled::settings::object::Rows; + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Eq, Hash)] pub enum DosageClassification @@ -118,8 +112,8 @@ pub struct RouteOfAdministration pub phases: Phases, } -use crate::formatter::Formatter; +use crate::cli::formatter::Formatter; use route_of_administration::phase::Phases; use tabled::Tabled; -pub(crate) type SubstanceTable = crate::orm::substance::Model; +pub(crate) type SubstanceTable = crate::database::entities::substance::Model; diff --git a/src/substance/repository.rs b/src/substance/repository.rs index db48f32c..ee577908 100755 --- a/src/substance/repository.rs +++ b/src/substance/repository.rs @@ -1,32 +1,54 @@ -use crate::orm; -use crate::orm::substance; +use crate::database::entities; +use crate::database::entities::substance; +use crate::substance::error::SubstanceError; +use crate::substance::route_of_administration::dosage::Dosage; +use crate::substance::route_of_administration::phase::PhaseClassification; +use crate::substance::route_of_administration::RouteOfAdministrationClassification; use crate::substance::DosageClassification; use crate::substance::DosageRange; use crate::substance::DurationRange; use crate::substance::RouteOfAdministration; use crate::substance::RoutesOfAdministration; use crate::substance::Substance; -use crate::substance::dosage::Dosage; -use crate::substance::route_of_administration::RouteOfAdministrationClassification; -use crate::substance::route_of_administration::phase::PhaseClassification; -use futures::StreamExt; +use cached::proc_macro::io_cached; use futures::stream::FuturesUnordered; +use futures::StreamExt; use iso8601_duration::Duration; -use miette::IntoDiagnostic; use miette::miette; +use miette::IntoDiagnostic; use sea_orm::ColumnTrait; use sea_orm::EntityTrait; use sea_orm::ModelTrait; use sea_orm::QueryFilter; use std::str::FromStr; +#[io_cached( + disk = true, + sync_to_disk_on_cache_change = true, + map_error = r##"|e| SubstanceError::DiskError"##, + time = 2592000000 +)] +async fn enrich_substance_name_query(name: &str) -> Result +{ + Ok(pubchem::Compound::with_name(name) + .title() + .into_diagnostic() + .unwrap_or(name.to_string())) +} + pub async fn get_substance( name: &str, db: &sea_orm::DatabaseConnection, ) -> miette::Result> { + let substance_name = enrich_substance_name_query(name).await?; + let db_substance = substance::Entity::find() - .filter(substance::Column::Name.contains(name.to_lowercase())) + .filter( + substance::Column::Name + .eq(substance_name.to_lowercase()) + .or(substance::Column::CommonNames.contains(name.to_lowercase())), + ) .one(db) .await .into_diagnostic()?; @@ -38,7 +60,7 @@ pub async fn get_substance( }; let routes_of_administration = db_substance - .find_related(orm::substance_route_of_administration::Entity) + .find_related(entities::substance_route_of_administration::Entity) .all(db) .await .into_diagnostic()?; @@ -61,7 +83,7 @@ pub async fn get_substance( }; let dosages = route - .find_related(orm::substance_route_of_administration_dosage::Entity) + .find_related(entities::substance_route_of_administration_dosage::Entity) .all(&db) .await .into_diagnostic()?; @@ -94,7 +116,7 @@ pub async fn get_substance( } let phases = route - .find_related(orm::substance_route_of_administration_phase::Entity) + .find_related(entities::substance_route_of_administration_phase::Entity) .all(&db) .await .into_diagnostic()?; diff --git a/src/substance/dosage.rs b/src/substance/route_of_administration/dosage.rs similarity index 60% rename from src/substance/dosage.rs rename to src/substance/route_of_administration/dosage.rs index a502a281..95ebf7e0 100755 --- a/src/substance/dosage.rs +++ b/src/substance/route_of_administration/dosage.rs @@ -1,3 +1,5 @@ +use crate::substance::DosageClassification; +use crate::substance::Dosages; use delegate::delegate; use derivative::Derivative; use float_pretty_print::PrettyPrintFloat; @@ -7,7 +9,7 @@ use serde::Deserialize; use serde::Serialize; use std::fmt; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Derivative, Eq, PartialOrd)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Derivative, Eq, PartialOrd, Copy)] pub struct Dosage(Mass); impl std::str::FromStr for Dosage @@ -48,6 +50,22 @@ impl Dosage } } +impl TryInto for Option +{ + type Error = sea_orm::error::DbErr; + + fn try_into(self) -> Result + { + self.map(Dosage::from_base_units) + .ok_or_else(|| sea_orm::error::DbErr::Custom("Dosage is NULL".to_string())) + } +} + +impl Default for Dosage +{ + fn default() -> Self { Dosage(Mass::from_base_units(0.0)) } +} + #[cfg(test)] mod tests @@ -79,3 +97,24 @@ mod tests assert_eq!(dosage.to_string(), "100 mg"); } } + +pub(crate) fn classify_dosage(dosage: Dosage, dosages: &Dosages) -> Option +{ + dosages + .iter() + .find(|(_, range)| range.contains(&dosage)) + .map(|(classification, _)| *classification) + .or_else(|| { + dosages + .iter() + .filter_map(|(_classification, range)| { + match (range.start.as_ref(), range.end.as_ref()) + { + | (Some(start), _) if &dosage >= start => Some(DosageClassification::Heavy), + | (_, Some(end)) if &dosage <= end => Some(DosageClassification::Threshold), + | _ => None, + } + }) + .next() + }) +} diff --git a/src/substance/route_of_administration/mod.rs b/src/substance/route_of_administration/mod.rs index 88189a7d..c879027f 100755 --- a/src/substance/route_of_administration/mod.rs +++ b/src/substance/route_of_administration/mod.rs @@ -1,4 +1,6 @@ +pub mod dosage; pub mod phase; + use serde::Deserialize; use serde::Serialize; use std::fmt; diff --git a/src/substance/route_of_administration/phase.rs b/src/substance/route_of_administration/phase.rs index d172af81..17177839 100755 --- a/src/substance/route_of_administration/phase.rs +++ b/src/substance/route_of_administration/phase.rs @@ -22,6 +22,7 @@ pub enum PhaseClassification Peak, Comedown, Afterglow, + Unknown, } impl FromStr for PhaseClassification @@ -57,8 +58,14 @@ impl fmt::Display for PhaseClassification | PhaseClassification::Peak => write!(f, "Peak"), | PhaseClassification::Comedown => write!(f, "Comedown"), | PhaseClassification::Afterglow => write!(f, "Afterglow"), + | PhaseClassification::Unknown => write!(f, "Unknown"), } } } +impl Default for PhaseClassification +{ + fn default() -> Self { Self::Unknown } +} + pub type Phases = HashMap; diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 00000000..0a182633 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,687 @@ +use crate::tui::core::Renderable; +use crate::tui::events::AppEvent; +use crate::tui::events::AppMessage; +use crate::tui::events::EventHandler; +use crate::tui::events::Screen; +use crate::tui::layout::footer::Footer; +use crate::tui::layout::footer::StatusBarMsg; +use crate::tui::layout::header::Header; +use crate::tui::layout::header::Message; +use crate::tui::layout::help::Help; +use crate::tui::theme::Theme; +use crate::tui::views::ingestion::create_ingestion::CreateIngestionState; +use crate::tui::views::ingestion::get_ingestion::IngestionViewState; +use crate::tui::views::ingestion::list_ingestion::IngestionListState; +use crate::tui::views::loading::LoadingScreen; +use crate::tui::views::Welcome; +use crate::tui::widgets::EventHandler as WidgetEventHandler; +use crate::tui::widgets::Focusable; +use crate::tui::widgets::Navigable; +use crate::tui::widgets::Stateful; +use async_std::task; +use async_std::task::JoinHandle; +use crossterm::event as crossterm_event; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use crossterm::execute; +use crossterm::terminal::disable_raw_mode; +use crossterm::terminal::enable_raw_mode; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; +use futures::executor::block_on; +use futures::future::Future; +use futures::future::FutureExt; +use miette::IntoDiagnostic; +use miette::Result; +use ratatui::prelude::*; +use ratatui::widgets::block::Title; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::Gauge; +use ratatui::widgets::Paragraph; +use std::collections::HashMap; +use std::io::stdout; +use std::io::Stdout; +use std::time::Duration; +use std::time::Instant; +use tracing::debug; +use tracing::error; + +pub struct Application +{ + terminal: Terminal>, + event_handler: EventHandler, + current_screen: Screen, + last_tick: Instant, + ingestion_details: IngestionViewState, + ingestion_list: IngestionListState, + create_ingestion: CreateIngestionState, + header: Header, + status_bar: Footer, + help_page: Help, + show_help: bool, + target_screen: Option, + loading_screen: Option, + background_tasks: HashMap>, bool)>, + data_cache: HashMap, +} + +impl Application +{ + pub async fn new() -> Result + { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend).into_diagnostic()?; + + let mut app = Self { + terminal, + event_handler: EventHandler::new(), + current_screen: Screen::Welcome, + last_tick: Instant::now(), + ingestion_details: IngestionViewState::new(), + ingestion_list: IngestionListState::new(), + create_ingestion: CreateIngestionState::new(), + header: Header::new(Screen::Welcome), + status_bar: Footer::new(), + help_page: Help::new(), + show_help: false, + target_screen: None, + loading_screen: None, + background_tasks: HashMap::new(), + data_cache: HashMap::new(), + }; + + app.update_screen(app.current_screen).await?; + Ok(app) + } + + fn should_refresh_data(&self, key: &str) -> bool + { + if let Some(last_update) = self.data_cache.get(key) + { + last_update.elapsed() > Duration::from_secs(30) // Cache for 30 seconds + } + else + { + true + } + } + + pub async fn update_screen(&mut self, screen: Screen) -> Result<()> + { + match screen + { + | Screen::ListIngestions => + { + if !self.should_refresh_data("ingestion_list") + { + self.current_screen = Screen::ListIngestions; + self.header + .update(Message::SetScreen(Screen::ListIngestions))?; + return Ok(()); + } + + self.target_screen = Some(Screen::ListIngestions); + self.current_screen = Screen::Loading; + self.loading_screen = Some(LoadingScreen::new("ingestion list")); + self.header.update(Message::SetScreen(Screen::Loading))?; + + // Update in place + self.ingestion_list.update().await?; + self.data_cache + .insert("ingestion_list".to_string(), Instant::now()); + self.current_screen = Screen::ListIngestions; + self.loading_screen = None; + self.header + .update(Message::SetScreen(Screen::ListIngestions))?; + } + | Screen::ViewIngestion => + { + if let Some(ingestion) = self.ingestion_list.selected_ingestion() + { + if let Some(id) = ingestion.id + { + let cache_key = format!("ingestion_details_{}", id); + + if !self.should_refresh_data(&cache_key) + { + self.current_screen = Screen::ViewIngestion; + self.header + .update(Message::SetScreen(Screen::ViewIngestion))?; + return Ok(()); + } + + self.current_screen = Screen::Loading; + self.loading_screen = Some(LoadingScreen::new("ingestion details")); + self.header.update(Message::SetScreen(Screen::Loading))?; + + // Update in place + self.ingestion_details + .load_ingestion(id.to_string()) + .await?; + self.data_cache.insert(cache_key, Instant::now()); + self.current_screen = Screen::ViewIngestion; + self.loading_screen = None; + self.header + .update(Message::SetScreen(Screen::ViewIngestion))?; + } + } + } + | _ => + { + self.current_screen = screen; + self.loading_screen = None; + self.header.update(Message::SetScreen(screen))?; + } + } + + self.status_bar.update(StatusBarMsg::UpdateScreen(screen)) + } + + fn centered_rect(width: u16, height: u16, area: Rect) -> Rect + { + let top_pad = (area.height - height) / 2; + let left_pad = (area.width - width) / 2; + Rect::new(area.left() + left_pad, area.top() + top_pad, width, height) + } + + pub fn render(&mut self) -> Result<()> + { + let area = self.terminal.size().into_diagnostic()?; + let current_screen = self.current_screen; + let show_help = self.show_help; + let should_show_size_warning = area.width < 80; + + self.terminal + .draw(|frame| { + if should_show_size_warning + { + let msg_area = Self::centered_rect(60, 5, frame.size()); + frame.render_widget(Clear, frame.size()); + frame.render_widget( + Paragraph::new( + "Please increase your terminal size to at least 80 characters wide to \ + view the Neuronek TUI", + ) + .style(Style::default()) + .alignment(Alignment::Center), + msg_area, + ); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + + let _ = self.header.render(chunks[0], frame); + + if current_screen == Screen::Loading + { + if let Some(loading_screen) = &self.loading_screen + { + let _ = loading_screen.render(chunks[1], frame); + } + } + else + { + match current_screen + { + | Screen::Welcome => + { + let block = Block::default() + .title("Home") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let home_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + Welcome::default().render(home_area, frame).unwrap(); + } + | Screen::CreateIngestion => + { + let block = Block::default() + .title("New Ingestion") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let create_ingestion_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + self.create_ingestion + .render(create_ingestion_area, frame) + .unwrap(); + } + | Screen::ViewIngestion => + { + let block = Block::default() + .title("Ingestion Details") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let ingestion_details_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + self.ingestion_details.view(frame, ingestion_details_area); + } + | Screen::Settings => + { + let block = Block::default() + .title("Settings") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let settings_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + + let settings_text = vec![ + Line::from(vec![Span::styled( + "Settings", + Style::default() + .fg(Theme::MAUVE) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from("No settings available yet."), + ]; + + let settings = Paragraph::new(settings_text) + .style(Style::default()) + .alignment(Alignment::Center) + .block(Block::default()); + + frame.render_widget(settings, settings_area); + } + | Screen::Help => + {} + | Screen::ListIngestions => + { + let block = Block::default() + .title("Ingestions") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let ingestion_list_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + let _ = self.ingestion_list.view(frame, ingestion_list_area); + } + | Screen::Loading => + { + // This case is handled above + } + } + } + + let _ = self.status_bar.render(chunks[2], frame); + + if show_help + { + let _ = self.help_page.render(area, frame); + } + }) + .map_err(|e| miette::miette!("Failed to draw terminal: {}", e))?; + + Ok(()) + } + + async fn check_background_tasks(&mut self) -> Result<()> + { + let task_keys: Vec = self.background_tasks.keys().cloned().collect(); + + for key in task_keys + { + if let Some((task, completed)) = self.background_tasks.get_mut(&key) + { + if *completed + { + continue; // Skip already completed tasks + } + + if let Some(result) = task.now_or_never() + { + match result + { + | Ok(_) => + { + self.data_cache.insert(key.clone(), Instant::now()); + *completed = true; + + if let Some(target) = self.target_screen.take() + { + self.current_screen = target; + self.loading_screen = None; + self.header.update(Message::SetScreen(target))?; + } + } + | Err(e) => + { + error!("Background task failed: {}", e); + self.status_bar.update(StatusBarMsg::Error(format!( + "Failed to load data: {}", + e + )))?; + *completed = true; + } + } + } + } + } + + // Clean up completed tasks + self.background_tasks + .retain(|_, (_, completed)| !*completed); + + Ok(()) + } + + pub async fn run(&mut self) -> Result<()> + { + enable_raw_mode().into_diagnostic()?; + execute!(stdout(), EnterAlternateScreen).into_diagnostic()?; + + loop + { + self.render()?; + self.check_background_tasks().await?; + + if crossterm_event::poll(Duration::from_millis(250)).into_diagnostic()? + { + let event = crossterm_event::read().into_diagnostic()?; + + // Handle quit key, but not when editing in the create ingestion form + if let Event::Key(key_event) = event + { + if key_event.code == KeyCode::Char('q') + { + match self.current_screen + { + | Screen::CreateIngestion => + { + // Only quit if we're not in edit mode + if let Some(msg) = self + .create_ingestion + .handle_event(AppEvent::Key(key_event))? + { + self.update(msg).await?; + } + continue; + } + | _ => break, + } + } + } + if let Event::Key(KeyEvent { + code: KeyCode::Char('?'), + .. + }) = event + { + self.show_help = !self.show_help; + if self.show_help + { + self.help_page.set_focus(true); + } + else + { + self.help_page.set_focus(false); + } + continue; + } + + if self.show_help + { + if let Event::Key(key) = event + { + match key.code + { + | KeyCode::Esc | KeyCode::Char('?') => + { + self.show_help = false; + self.help_page.set_focus(false); + } + | _ => + { + let _ = self.help_page.handle_event(event); + } + } + } + continue; + } + + match event + { + | Event::Key(key) => + { + // Handle numeric input in create ingestion form first + if self.current_screen == Screen::CreateIngestion + { + if let Some(msg) = + self.create_ingestion.handle_event(AppEvent::Key(key))? + { + self.update(msg).await?; + } + continue; + } + + // Then check if the header wants to handle this event + if let Some(msg) = self.header.handle_event(event)? + { + match msg + { + | Message::SetScreen(screen) => + { + self.update_screen(screen).await?; + } + | Message::Noop => + {} + } + continue; + } + + // Then handle screen-specific events + match self.current_screen + { + | Screen::ListIngestions => match key.code + { + | KeyCode::Char('j') | KeyCode::Down => + { + self.ingestion_list.next(); + } + | KeyCode::Char('k') | KeyCode::Up => + { + self.ingestion_list.previous(); + } + | KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => + { + if let Some(ingestion) = + self.ingestion_list.selected_ingestion() + { + if let Some(id) = ingestion.id + { + block_on(async { + self.ingestion_details + .load_ingestion(id.to_string()) + .await + .map_err(|e| { + miette::miette!( + "Failed to load ingestion: {}", + e + ) + }) + })?; + self.update_screen(Screen::ViewIngestion).await?; + } + } + } + | KeyCode::Char('n') => + { + self.update_screen(Screen::CreateIngestion).await?; + } + | _ => + {} + | KeyCode::Char('j') | KeyCode::Down => + { + self.ingestion_list.next(); + } + | KeyCode::Char('k') | KeyCode::Up => + { + self.ingestion_list.previous(); + } + | KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => + { + if let Some(ingestion) = + self.ingestion_list.selected_ingestion() + { + if let Some(id) = ingestion.id + { + self.ingestion_details + .load_ingestion(id.to_string()) + .await + .map_err(|e| { + miette::miette!( + "Failed to load ingestion: {}", + e + ) + })?; + self.update_screen(Screen::ViewIngestion).await?; + } + } + } + | KeyCode::Char('n') => + { + self.update_screen(Screen::CreateIngestion).await?; + } + | _ => + {} + }, + | Screen::ViewIngestion => match key.code + { + | KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => + { + self.update_screen(Screen::ListIngestions).await?; + } + | _ => + {} + }, + | Screen::CreateIngestion => + { + // Handle create ingestion form events + if let Some(msg) = + self.create_ingestion.handle_event(AppEvent::Key(key))? + { + self.update(msg).await?; + } + } + | Screen::ViewIngestion => match key.code + { + | KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => + { + self.update_screen(Screen::ListIngestions).await?; + } + | _ => + {} + }, + | Screen::CreateIngestion => + { + // Handle create ingestion form events + if let Some(msg) = + self.create_ingestion.handle_event(AppEvent::Key(key))? + { + self.update(msg).await?; + } + } + | _ => + {} + } + } + | _ => + {} + } + } + + if self.last_tick.elapsed() >= Duration::from_secs(1) + { + self.event_handler.push(AppEvent::Tick); + self.last_tick = Instant::now(); + } + } + + disable_raw_mode().into_diagnostic()?; + execute!(stdout(), LeaveAlternateScreen).into_diagnostic()?; + + Ok(()) + } + + pub async fn update(&mut self, message: AppMessage) -> miette::Result<()> + { + match message + { + | AppMessage::Quit => + {} + | AppMessage::NavigateToPage(screen) => + { + // After creating an ingestion, we want to refresh the list + if screen == Screen::ListIngestions + && self.current_screen == Screen::CreateIngestion + { + // Force a refresh by removing from cache + self.data_cache.remove("ingestion_list"); + } + self.update_screen(screen).await?; + } + | AppMessage::SelectNext => match self.current_screen + { + | Screen::ListIngestions => self.ingestion_list.next(), + | _ => + {} + }, + | AppMessage::SelectPrevious => match self.current_screen + { + | Screen::ListIngestions => self.ingestion_list.previous(), + | _ => + {} + }, + | AppMessage::SelectNext => match self.current_screen + { + | Screen::ListIngestions => self.ingestion_list.next(), + | _ => + {} + }, + | AppMessage::SelectPrevious => match self.current_screen + { + | Screen::ListIngestions => self.ingestion_list.previous(), + | _ => + {} + }, + | AppMessage::LoadData => + { + // Force a refresh by removing from cache + self.data_cache.remove("ingestion_list"); + self.update_screen(Screen::ListIngestions).await?; + } + | AppMessage::CreateIngestion => + { + self.update_screen(Screen::CreateIngestion).await?; + } + | AppMessage::CreateIngestion => + { + self.update_screen(Screen::CreateIngestion).await?; + } + | _ => + {} + } + + Ok(()) + } +} diff --git a/src/tui/core.rs b/src/tui/core.rs new file mode 100644 index 00000000..d1898533 --- /dev/null +++ b/src/tui/core.rs @@ -0,0 +1,68 @@ +pub use miette::Result; +use ratatui::backend::CrosstermBackend; +pub use ratatui::prelude::*; +use ratatui::Terminal; +/** + * This file contains core abstraction layer for the terminal user + * interface. Think about it as micro-framework on top of framework like + * ratatui. + */ +use std::io::Stdout; + +pub type DefaultTerminal = Terminal>; + +pub trait Renderable +{ + /// Renders a UI component within a specified area of the terminal. + /// + /// This trait provides a standardized interface for rendering components in + /// a Ratatui-based TUI. It follows Ratatui's rendering model where + /// components are drawn within constrained areas. + /// + /// # Arguments + /// + /// * `area` - A [`Rect`] defining the drawing area (width, height, and + /// position). + /// - The coordinate system is top-left based (0,0) is the top-left corner + /// - The area is automatically constrained by parent components + /// - See: [`Rect`](https://docs.rs/ratatui/latest/ratatui/layout/struct.Rect.html) + /// + /// * `frame` - A mutable reference to the [`Frame`] used for rendering. + /// - The frame provides access to the terminal buffer + /// - All drawing operations must go through the frame + /// - See: [`Frame`](https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html) + /// + /// # Returns + /// + /// * `Result<()>` - Returns `Ok(())` on success or an error if rendering + /// fails. + /// - Uses [`miette::Result`] for rich error reporting + /// + /// # Implementation Guidelines + /// + /// 1. Components should respect the provided `area` boundaries + /// 2. Use Ratatui's layout system for complex component structures + /// 3. Consider using [`Buffer`] directly for performance-critical rendering + /// 4. Handle terminal resizing gracefully + /// + /// # Example + /// + /// ```rust + /// impl Renderable for MyComponent + /// { + /// fn render(&self, area: Rect, frame: &mut Frame) -> Result<()> + /// { + /// let text = Text::from("Hello Ratatui"); + /// frame.render_widget(text, area); + /// Ok(()) + /// } + /// } + /// ``` + /// + /// See also: + /// - [Ratatui Rendering Model](https://ratatui.rs/concepts/rendering/) + /// - [Widget Implementation Guide](https://ratatui.rs/how-to-guide/implement-a-widget/) + fn render(&self, area: Rect, frame: &mut Frame) -> Result<()>; +} + +pub trait Component: Renderable {} diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 00000000..fabb70f0 --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,73 @@ +use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use derive_more::From; +use std::fmt; +use strum::Display as StdDisplay; + +#[derive(Debug, Clone, From)] +pub enum AppEvent +{ + Key(KeyEvent), + Mouse(MouseEvent), + Tick, + Resize + { + width: u16, + height: u16, + }, +} + +impl fmt::Display for AppEvent +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + match self + { + | AppEvent::Key(k) => write!(f, "Key: {:?}", k), + | AppEvent::Mouse(m) => write!(f, "Mouse: {:?}", m), + | AppEvent::Tick => write!(f, "Tick"), + | AppEvent::Resize { width, height } => write!(f, "Resize: {}x{}", width, height), + } + } +} + +#[derive(Debug, Clone, StdDisplay)] +pub enum AppMessage +{ + Quit, + NavigateToPage(Screen), + SelectNext, + SelectPrevious, + LoadData, + Refresh, + ListIngestions, + CreateIngestion, +} + +#[derive(Debug, Clone, Copy, StdDisplay, PartialEq, Eq, Default)] +pub enum Screen +{ + #[default] + Welcome, + ListIngestions, + Loading, + CreateIngestion, + ViewIngestion, + Settings, + Help, +} + +#[derive(Debug, Clone)] +pub struct EventHandler +{ + pub events: Vec, +} + +impl EventHandler +{ + pub fn new() -> Self { Self { events: Vec::new() } } + + pub fn push(&mut self, event: AppEvent) { self.events.push(event); } + + pub fn clear(&mut self) { self.events.clear(); } +} diff --git a/src/tui/layout/footer.rs b/src/tui/layout/footer.rs new file mode 100644 index 00000000..e2f65e31 --- /dev/null +++ b/src/tui/layout/footer.rs @@ -0,0 +1,356 @@ +use crate::tui::core::Renderable; +use crate::tui::events::Screen; +use crate::tui::theme::Theme; +use crate::tui::widgets::EventHandler; +use crate::tui::widgets::Focusable; +use crate::tui::widgets::Stateful; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use miette::Result; +use ratatui::layout::Flex; +use ratatui::prelude::*; +use ratatui::text::Line; +use ratatui::text::Span; + +#[derive(Debug, Clone)] +pub enum StatusBarMsg +{ + /// Update the current screen + UpdateScreen(Screen), + + /// Error message + Error(String), + + /// No operation + Noop, +} + +#[derive(Debug, Default, Clone)] +pub struct StatusBarSection<'a> +{ + pre_separator: Option>, + content: Line<'a>, + post_separator: Option>, +} + +impl<'a> StatusBarSection<'a> +{ + pub fn pre_separator(mut self, separator: impl Into>) -> Self + { + self.pre_separator = Some(separator.into()); + self + } + + pub fn content(mut self, content: impl Into>) -> Self + { + self.content = content.into(); + self + } + + pub fn post_separator(mut self, separator: impl Into>) -> Self + { + self.post_separator = Some(separator.into()); + self + } +} + +impl<'a> From> for StatusBarSection<'a> +{ + fn from(line: Line<'a>) -> Self + { + StatusBarSection { + pre_separator: None, + content: line, + post_separator: None, + } + } +} + +impl<'a> From> for StatusBarSection<'a> +{ + fn from(span: Span<'a>) -> Self + { + StatusBarSection { + pre_separator: None, + content: span.into(), + post_separator: None, + } + } +} + +impl<'a> From<&'a str> for StatusBarSection<'a> +{ + fn from(s: &'a str) -> Self + { + StatusBarSection { + pre_separator: None, + content: s.into(), + post_separator: None, + } + } +} + +#[derive(Debug)] +pub struct Footer +{ + /// Current screen being displayed + current_screen: Screen, + + /// Whether the widget is currently focused + focused: bool, + + /// Sections of the status bar + sections: Vec>, + + /// Layout flex mode + flex: Flex, + + /// Spacing between sections + spacing: u16, + + /// Error message + error_message: Option, +} + +impl Footer +{ + /// Create a new status bar widget + pub fn new() -> Self + { + let mut footer = Self { + current_screen: Screen::ListIngestions, + focused: false, + sections: vec![StatusBarSection::default(); 2], + flex: Flex::SpaceBetween, + spacing: 1, + error_message: None, + }; + footer.update_sections(); + footer + } + + /// Get help text based on current screen + fn get_help_text(&self) -> Line<'static> + { + let help_spans = match self.current_screen + { + | Screen::Welcome => vec![ + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("q", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Quit ", Style::default().fg(Theme::TEXT)), + ], + | Screen::ListIngestions => vec![ + Span::styled(" ", Style::default()), + Span::styled("n", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" New ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("l/Enter", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Details ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("q", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Quit ", Style::default().fg(Theme::TEXT)), + ], + | Screen::CreateIngestion => vec![ + Span::styled(" ", Style::default()), + Span::styled("↑/↓/Tab", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Navigate ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("Enter", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Edit ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("Ctrl+H", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + ], + | Screen::ViewIngestion => vec![ + Span::styled(" ", Style::default()), + Span::styled("h", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Back ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("Esc", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Close ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + ], + | Screen::Settings => vec![ + Span::styled(" ", Style::default()), + Span::styled("h", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Back ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("Esc", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Close ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + ], + | Screen::Help => vec![ + Span::styled(" ", Style::default()), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Close Help ", Style::default().fg(Theme::TEXT)), + ], + | Screen::Loading => vec![ + Span::styled(" ", Style::default()), + Span::styled("q", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Quit ", Style::default().fg(Theme::TEXT)), + ], + }; + + Line::from(help_spans) + } + + /// Get screen name + fn get_screen_name(&self) -> &'static str + { + match self.current_screen + { + | Screen::Welcome => "Welcome", + | Screen::ListIngestions => "Ingestions", + | Screen::CreateIngestion => "Create Ingestion", + | Screen::ViewIngestion => "Ingestion", + | Screen::Settings => "Settings", + | Screen::Help => "Help", + | Screen::Loading => "Loading...", + } + } + + /// Update sections based on current state + fn update_sections(&mut self) + { + let help_text = self.get_help_text(); + let screen_name = self.get_screen_name(); + + self.sections[0] = help_text.into(); + self.sections[1] = Span::styled(screen_name, Style::default().fg(Theme::SUBTEXT0)).into(); + } +} + +impl Renderable for Footer +{ + fn render(&self, area: Rect, frame: &mut Frame) -> Result<()> + { + if area.is_empty() + { + return Ok(()); + } + + let layout = Layout::horizontal( + self.sections + .iter() + .map(|s| Constraint::Length(u16::try_from(s.content.width()).unwrap_or(0))), + ) + .flex(self.flex) + .spacing(self.spacing); + + let areas = layout.split(area); + let areas = areas.as_ref(); + + for (i, section) in self.sections.iter().enumerate() + { + if let Some(rect) = areas.get(i) + { + frame.buffer_mut().set_line( + rect.x, + rect.y, + §ion.content, + u16::try_from(section.content.width()).unwrap_or(0), + ); + } + } + + Ok(()) + } +} + +impl EventHandler for Footer +{ + type Message = StatusBarMsg; + + fn handle_event(&mut self, event: Event) -> Result> + { + if !self.focused + { + return Ok(None); + } + + match event + { + | Event::Key(KeyEvent { + code: KeyCode::Char('h'), + .. + }) => match self.current_screen + { + | Screen::ViewIngestion => + { + Ok(Some(StatusBarMsg::UpdateScreen(Screen::ListIngestions))) + } + | Screen::Settings => Ok(Some(StatusBarMsg::UpdateScreen(Screen::ListIngestions))), + | _ => Ok(Some(StatusBarMsg::Noop)), + }, + | Event::Key(KeyEvent { + code: KeyCode::Char('n'), + .. + }) => match self.current_screen + { + | Screen::ListIngestions => + { + Ok(Some(StatusBarMsg::UpdateScreen(Screen::CreateIngestion))) + } + | _ => Ok(Some(StatusBarMsg::Noop)), + }, + | Event::Key(KeyEvent { + code: KeyCode::Char('l') | KeyCode::Enter, + .. + }) => match self.current_screen + { + | Screen::ListIngestions => + { + Ok(Some(StatusBarMsg::UpdateScreen(Screen::ViewIngestion))) + } + | _ => Ok(Some(StatusBarMsg::Noop)), + }, + | _ => Ok(Some(StatusBarMsg::Noop)), + } + } +} + +impl Stateful for Footer +{ + type Message = StatusBarMsg; + + fn update(&mut self, msg: Self::Message) -> Result<()> + { + match msg + { + | StatusBarMsg::UpdateScreen(screen) => + { + self.current_screen = screen; + self.update_sections(); + Ok(()) + } + | StatusBarMsg::Error(error_msg) => + { + self.error_message = Some(error_msg); + Ok(()) + } + | StatusBarMsg::Noop => Ok(()), + } + } +} + +impl Focusable for Footer +{ + fn is_focused(&self) -> bool { self.focused } + + fn set_focus(&mut self, focused: bool) { self.focused = focused; } +} + +impl Default for Footer +{ + fn default() -> Self { Self::new() } +} diff --git a/src/tui/layout/header.rs b/src/tui/layout/header.rs new file mode 100644 index 00000000..a5e89895 --- /dev/null +++ b/src/tui/layout/header.rs @@ -0,0 +1,202 @@ +use crate::core::config::VERSION; +use crate::tui::core::Renderable; +use crate::tui::events::Screen; +use crate::tui::widgets::EventHandler; +use crate::tui::widgets::Focusable; +use crate::tui::widgets::Stateful; +use crate::tui::Theme; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::layout::Alignment; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::prelude::Direction; +use ratatui::prelude::Line; +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +#[derive(Debug, Clone)] +pub enum Message +{ + SetScreen(Screen), + Noop, +} + +pub struct Header +{ + current_screen: Screen, + focused: bool, +} + +impl Header +{ + pub fn new(current_screen: Screen) -> Self + { + Self { + current_screen, + focused: false, + } + } +} + +impl Renderable for Header +{ + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> + { + let app_version = VERSION; + let header_height = 3; + let header_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: header_height, + }; + + let header_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER)) + .style(Style::default().bg(Theme::SURFACE0)); + + frame.render_widget(header_block.clone(), header_area); + let inner_area = header_block.inner(header_area); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(20)]) + .split(inner_area); + + let nav_items = Line::from(vec![ + Span::raw(" "), + if matches!(self.current_screen, Screen::Welcome) + { + Span::styled("Home", Style::default().fg(Theme::BASE).bg(Theme::TEXT)) + } + else + { + Span::styled("1", Style::default().fg(Theme::OVERLAY0)) + }, + if !matches!(self.current_screen, Screen::Welcome) + { + Span::styled(" Home", Style::default().fg(Theme::TEXT)) + } + else + { + Span::raw("") + }, + Span::raw(" "), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::raw(" "), + if matches!(self.current_screen, Screen::ListIngestions) + { + Span::styled( + "Ingestions", + Style::default().fg(Theme::BASE).bg(Theme::TEXT), + ) + } + else + { + Span::styled("2", Style::default().fg(Theme::OVERLAY0)) + }, + if !matches!(self.current_screen, Screen::ListIngestions) + { + Span::styled(" Ingestions", Style::default().fg(Theme::TEXT)) + } + else + { + Span::raw("") + }, + Span::raw(" "), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::raw(" "), + if matches!(self.current_screen, Screen::Settings) + { + Span::styled("Settings", Style::default().fg(Theme::BASE).bg(Theme::TEXT)) + } + else + { + Span::styled("0", Style::default().fg(Theme::OVERLAY0)) + }, + if !matches!(self.current_screen, Screen::Settings) + { + Span::styled(" Settings", Style::default().fg(Theme::TEXT)) + } + else + { + Span::raw("") + }, + ]); + + let nav = Paragraph::new(nav_items) + .style(Style::default().fg(Theme::TEXT)) + .alignment(Alignment::Left); + frame.render_widget(nav, chunks[0]); + + let version = Paragraph::new(format!("v{}", app_version)) + .style(Style::default().fg(Theme::SUBTEXT0)) + .alignment(Alignment::Right); + frame.render_widget(version, chunks[1]); + + Ok(()) + } +} + +impl EventHandler for Header +{ + type Message = Message; + + fn handle_event(&mut self, event: Event) -> miette::Result> + { + if let Event::Key(KeyEvent { code, .. }) = event + { + match code + { + | KeyCode::Char('1') => + { + return Ok(Some(Message::SetScreen(Screen::Welcome))); + } + | KeyCode::Char('2') => + { + return Ok(Some(Message::SetScreen(Screen::ListIngestions))); + } + | KeyCode::Char('0') => + { + return Ok(Some(Message::SetScreen(Screen::Settings))); + } + | _ => + {} + } + } + Ok(None) + } +} + +impl Stateful for Header +{ + type Message = Message; + + fn update(&mut self, msg: Self::Message) -> miette::Result<()> + { + match msg + { + | Message::SetScreen(screen) => + { + self.current_screen = screen; + } + | Message::Noop => + {} + } + Ok(()) + } +} + +impl Focusable for Header +{ + fn is_focused(&self) -> bool { self.focused } + fn set_focus(&mut self, focused: bool) { self.focused = focused; } +} diff --git a/src/tui/layout/help.rs b/src/tui/layout/help.rs new file mode 100644 index 00000000..84638e72 --- /dev/null +++ b/src/tui/layout/help.rs @@ -0,0 +1,372 @@ +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use miette::Result; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Scrollbar; +use ratatui::widgets::ScrollbarOrientation; +use ratatui::widgets::ScrollbarState; +use ratatui::widgets::Wrap; + +use crate::tui::core::Renderable; +use crate::tui::theme::Theme; +use crate::tui::widgets::EventHandler; +use crate::tui::widgets::Focusable; +use crate::tui::widgets::Stateful; + +fn fancy_help_content() -> Text<'static> +{ + let mut text = Text::default(); + + text.extend(vec![ + Line::from(vec![Span::styled( + "DISCLAIMER", + Style::default().fg(Theme::RED).add_modifier(Modifier::BOLD), + )]) + .alignment(Alignment::Center), + Line::default(), + Line::from( + "This application is a toy—built for playing with datasets and research related to \ + neuroscience and pharmacology found in the wild. You should not threat it as holy \ + grail of knowledge (because it's not).", + ), + Line::default(), + Line::from(vec![Span::from( + "Any information here might be wrong, misleading, or outright dangerous.", + )]), + Line::default(), + Line::from(vec![Span::from( + "Maintainers aren't responsible for harm caused by \"this app\" as at the end that's \ + you who's pulling the strings, use for your own benefit if you see any or just do \ + not touch it.", + )]), + Line::default(), + Line::from(vec![Span::from( + "Proceed with caution or abandon hope entirely. (as maintainers did, for this whole \ + development process)", + )]), + Line::default(), + Line::default(), + Line::from(vec![ + Span::styled( + "This application is still in development, so expect bugs and crashes. \n", + Style::default().fg(Theme::YELLOW), + ), + Span::styled( + "It's actually my favourite excuse for not-resolving issues and building record \ + backlog...", + Style::default() + .fg(Theme::YELLOW) + .add_modifier(Modifier::ITALIC), + ), + ]) + .alignment(Alignment::Center), + Line::default(), + Line::from("━".repeat(50)).alignment(Alignment::Center), + Line::default(), + Line::from(vec![Span::styled( + "CREATE INGESTION FORM", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Theme::BLUE)), + Span::from("Quick Navigation:"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Tab/↓: Next field • Shift+Tab/↑: Previous field"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Enter: Edit field or activate button"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Ctrl+S: Quick save • Esc: Quick cancel"), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Theme::BLUE)), + Span::from("Smart Input:"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("Substance: Any name (e.g., 'Caffeine', 'Vitamin C')"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("Dosage: Natural input (e.g., '10mg', '0.5g', '100mcg')"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("Route: Scrollable dropdown (↑/↓ to select)"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("Date: 'now' or natural (e.g., 'today 10am', 'yesterday 2pm')"), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Theme::BLUE)), + Span::from("Quick Tips:"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Enter confirms each field and moves to next"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Use arrow keys to navigate the form quickly"), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::from("- Tab through fields to reach Save/Cancel buttons"), + ]), + Line::default(), + Line::from("━".repeat(50)), + Line::default(), + Line::from(vec![Span::styled( + "NAVIGATION", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled("j / ↓", Style::default().fg(Theme::BLUE)), + Span::styled(" .......... ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Move down"), + ]), + Line::from(vec![ + Span::styled("k / ↑", Style::default().fg(Theme::BLUE)), + Span::styled(" .......... ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Move up"), + ]), + Line::from(vec![ + Span::styled("l / → / Enter", Style::default().fg(Theme::BLUE)), + Span::styled(" .. ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Select / View details"), + ]), + Line::from(vec![ + Span::styled("h / ←", Style::default().fg(Theme::BLUE)), + Span::styled(" .......... ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Go back"), + ]), + Line::from(vec![ + Span::styled("?", Style::default().fg(Theme::BLUE)), + Span::styled(" .............. ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Toggle help page"), + ]), + Line::from(vec![ + Span::styled("q", Style::default().fg(Theme::BLUE)), + Span::styled(" .............. ", Style::default().fg(Theme::OVERLAY0)), + Span::from("Quit application"), + ]), + Line::default(), + Line::from("━".repeat(50)).alignment(Alignment::Center), + Line::default(), + Line::default(), + Line::from(vec![ + Span::styled( + "\"If you think this app will help you in life, well,\n", + Style::default().fg(Theme::GREEN), + ), + Span::styled( + " you're an optimist. That, or you'll abandon it when it crashes\n", + Style::default().fg(Theme::GREEN), + ), + Span::styled( + " after 10 minutes. Either way, life is fleeting.\"\n", + Style::default().fg(Theme::GREEN), + ), + Span::styled( + " ~ One and only maintainer", + Style::default() + .fg(Theme::GREEN) + .add_modifier(Modifier::ITALIC), + ), + ]), + ]); + + text +} + +pub enum HelpMessage +{ + ScrollUp, + ScrollDown, + PageUp, + PageDown, + Noop, +} + +pub struct Help +{ + scroll: u16, + max_scroll: u16, + focused: bool, +} + +impl Help +{ + pub fn new() -> Self + { + Self { + scroll: 0, + max_scroll: 0, + focused: false, + } + } + + fn scroll_up(&mut self) + { + if self.scroll > 0 + { + self.scroll -= 1; + } + } + + fn scroll_down(&mut self) + { + if self.scroll < self.max_scroll + { + self.scroll += 1; + } + } + + fn page_up(&mut self) + { + if self.scroll > 10 + { + self.scroll -= 10; + } + else + { + self.scroll = 0; + } + } + + fn page_down(&mut self) + { + if self.scroll + 10 < self.max_scroll + { + self.scroll += 10; + } + else + { + self.scroll = self.max_scroll; + } + } +} + +impl Renderable for Help +{ + fn render(&self, area: Rect, frame: &mut Frame) -> Result<()> + { + let popup_area = centered_rect(50, 70, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let inner_area = block.inner(popup_area); + + frame.render_widget(block, popup_area); + + let content_area = Rect { + x: inner_area.x + 2, + y: inner_area.y + 1, + width: inner_area.width.saturating_sub(4), + height: inner_area.height.saturating_sub(2), + }; + + let text = fancy_help_content(); + let scroll_state = + ScrollbarState::new(text.height() as usize).position(self.scroll as usize); + + let paragraph = Paragraph::new(text) + .style(Style::default().bg(Theme::SURFACE0)) + .scroll((self.scroll, 0)) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, content_area); + + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .style(Style::default().fg(Theme::OVERLAY0)), + popup_area, + &mut scroll_state.clone(), + ); + + Ok(()) + } +} + +impl Focusable for Help +{ + fn is_focused(&self) -> bool { self.focused } + + fn set_focus(&mut self, focused: bool) { self.focused = focused; } +} + +impl EventHandler for Help +{ + type Message = (); + + fn handle_event(&mut self, event: Event) -> Result> + { + match event + { + | Event::Key(KeyEvent { code, .. }) => match code + { + | KeyCode::Char('j') | KeyCode::Down => + { + self.scroll = self.scroll.saturating_add(1); + } + | KeyCode::Char('k') | KeyCode::Up => + { + self.scroll = self.scroll.saturating_sub(1); + } + | _ => + {} + }, + | _ => + {} + } + Ok(None) + } +} + +impl Stateful for Help +{ + type Message = (); + + fn update(&mut self, _message: Self::Message) -> Result<()> { Ok(()) } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect +{ + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/tui/layout/mod.rs b/src/tui/layout/mod.rs new file mode 100644 index 00000000..ce76148c --- /dev/null +++ b/src/tui/layout/mod.rs @@ -0,0 +1,3 @@ +pub mod footer; +pub(crate) mod header; +pub mod help; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a7bca086..0c992fe6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,118 +1,28 @@ -use crate::orm::ingestion; -use crate::orm::prelude::Ingestion; -use crate::utils::DATABASE_CONNECTION; -use crossterm::event::Event; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::{self}; -use crossterm::execute; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; -use crossterm::terminal::disable_raw_mode; -use crossterm::terminal::enable_raw_mode; -use futures::executor::block_on; -use miette::IntoDiagnostic; -use miette::Result; -use ratatui::Frame; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::prelude::Alignment; -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::widgets::Block; -use ratatui::widgets::Borders; -use ratatui::widgets::List; -use ratatui::widgets::ListItem; -use ratatui::widgets::block::Title; -use sea_orm::EntityTrait; -use sea_orm::QueryOrder; -use std::io; -use std::io::Stdout; - -pub(super) fn header() -> Block<'static> -{ - let app_name = env!("CARGO_PKG_NAME"); - let app_version = env!("CARGO_PKG_VERSION"); - - Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(Color::White).bg(Color::Blue)) - .title(Title::from(format!("{}-v{}", app_name, app_version)).alignment(Alignment::Left)) -} - -pub(super) fn footer() -> Block<'static> -{ - Block::default() - .title("Controls: [q]uit") - .borders(Borders::NONE) - .style(Style::default().fg(Color::Blue)) -} - -fn render_main_content(frame: &mut Frame, area: Rect) +pub mod app; +pub mod core; +pub mod events; +pub mod layout; +pub mod theme; +pub mod views; +pub mod widgets; + +// Re-export commonly used types +pub use app::Application; +pub use core::Component; +pub use core::Renderable; +pub use theme::Theme; + +pub mod prelude { - frame.render_widget( - Block::default().title("Main Content").borders(Borders::ALL), - area, - ); + pub use super::core::Component; + pub use super::core::Renderable; } -fn render(frame: &mut Frame) -{ - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(frame.size()); - - let body_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(20), Constraint::Min(1)].as_ref()) - .split(layout[1]); - - frame.render_widget(header(), layout[0]); - frame.render_widget(footer(), layout[2]); - render_main_content(frame, body_layout[0]); -} - -pub(super) fn application_loop(terminal: &mut Terminal>) -> Result<()> -{ - loop - { - terminal.draw(render).into_diagnostic()?; - match event::read().into_diagnostic()? - { - | Event::Key(KeyEvent { code, .. }) => match code - { - | KeyCode::Char('q') | KeyCode::Esc => break Ok(()), - | _ => - {} - }, - | _ => - {} - } - } -} +use miette::Result; -pub fn tui() -> Result<()> +pub(super) async fn run() -> Result<()> { - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen).into_diagnostic()?; - enable_raw_mode().into_diagnostic()?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).into_diagnostic()?; - let result = application_loop(&mut terminal); - disable_raw_mode().into_diagnostic()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen).into_diagnostic()?; - terminal.show_cursor().into_diagnostic()?; - result + let mut app = Application::new().await?; + app.run().await?; + Ok(()) } diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 00000000..b99fe2cc --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,62 @@ +use ratatui::style::Color; + +// Catppuccin Mocha Theme Colors +pub struct Theme; + +impl Theme +{ + // Base Colors + pub const BASE: Color = Color::Rgb(30, 30, 46); // #1E1E2E + pub const MANTLE: Color = Color::Rgb(24, 24, 37); // #181825 + pub const CRUST: Color = Color::Rgb(17, 17, 27); // #11111B + + // Surface Colors + pub const SURFACE0: Color = Color::Rgb(49, 50, 68); // #313244 + pub const SURFACE1: Color = Color::Rgb(69, 71, 90); // #45475A + pub const SURFACE2: Color = Color::Rgb(88, 91, 112); // #585B70 + + // Text Colors + pub const TEXT: Color = Color::Rgb(205, 214, 244); // #CDD6F4 + pub const SUBTEXT0: Color = Color::Rgb(166, 173, 200); // #A6ADC8 + pub const SUBTEXT1: Color = Color::Rgb(186, 194, 222); // #BAC2DE + pub const OVERLAY0: Color = Color::Rgb(108, 112, 134); // #6C7086 + pub const OVERLAY1: Color = Color::Rgb(127, 132, 156); // #7F849C + pub const OVERLAY2: Color = Color::Rgb(147, 153, 178); // #939AB7 + + // Accent Colors + pub const BLUE: Color = Color::Rgb(137, 180, 250); // #89B4FA + pub const LAVENDER: Color = Color::Rgb(180, 190, 254); // #B4BEFE + pub const SAPPHIRE: Color = Color::Rgb(116, 199, 236); // #74C7EC + pub const SKY: Color = Color::Rgb(137, 220, 235); // #89DCEB + pub const TEAL: Color = Color::Rgb(148, 226, 213); // #94E2D5 + pub const GREEN: Color = Color::Rgb(166, 227, 161); // #A6E3A1 + pub const YELLOW: Color = Color::Rgb(249, 226, 175); // #F9E2AF + pub const PEACH: Color = Color::Rgb(250, 179, 135); // #FAB387 + pub const MAROON: Color = Color::Rgb(235, 160, 172); // #EBA0AC + pub const RED: Color = Color::Rgb(243, 139, 168); // #F38BA8 + pub const MAUVE: Color = Color::Rgb(203, 166, 247); // #CBA6F7 + pub const PINK: Color = Color::Rgb(245, 194, 231); // #F5C2E7 + pub const FLAMINGO: Color = Color::Rgb(242, 205, 205); // #F2CDCD + pub const ROSEWATER: Color = Color::Rgb(245, 224, 220); // #F5E0DC + + // UI States + pub const SELECTED_BG: Color = Self::SURFACE1; + pub const SELECTED_FG: Color = Self::TEXT; + pub const HEADER_FG: Color = Self::MAUVE; + pub const BORDER: Color = Self::SURFACE0; + pub const SCROLLBAR_THUMB: Color = Self::SURFACE2; + pub const SCROLLBAR_TRACK: Color = Self::SURFACE0; + + // Status Colors + pub const SUCCESS: Color = Self::GREEN; + pub const WARNING: Color = Self::YELLOW; + pub const ERROR: Color = Self::RED; + pub const INFO: Color = Self::BLUE; + + // Time-based Colors + pub const PAST: Color = Self::GREEN; + pub const FUTURE: Color = Self::BLUE; + pub const INACTIVE: Color = Self::OVERLAY0; + + pub const ACTIVE: Color = Color::Cyan; +} diff --git a/src/tui/views/ingestion/create_ingestion.rs b/src/tui/views/ingestion/create_ingestion.rs new file mode 100644 index 00000000..b959ba79 --- /dev/null +++ b/src/tui/views/ingestion/create_ingestion.rs @@ -0,0 +1,755 @@ +use crate::ingestion::command::LogIngestion; +use crate::substance::route_of_administration::dosage::Dosage; +use crate::substance::route_of_administration::RouteOfAdministrationClassification; +use crate::tui::core::Component; +use crate::tui::core::Renderable; +use crate::tui::events::AppEvent; +use crate::tui::events::AppMessage; +use crate::tui::events::Screen; +use crate::tui::theme::Theme; +use chrono::DateTime; +use chrono::Local; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use futures::executor::block_on; +use miette::Result; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::ListState; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use ratatui_textarea::TextArea; +use regex::Regex; +use sea_orm::EntityTrait; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EditTarget +{ + SubstanceName, + Dosage, + RouteOfAdministration, + Date, + SaveButton, + CancelButton, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FormMode +{ + View, + Edit(EditTarget), +} + +#[derive(Clone)] +pub struct FormData +{ + pub substance_name: String, + pub dosage_text: String, + pub parsed_dosage: Option, + pub date_text: String, + pub parsed_date: Option>, + pub route_of_administration: RouteOfAdministrationClassification, +} + +impl Default for FormData +{ + fn default() -> Self + { + Self { + substance_name: String::new(), + dosage_text: String::new(), + parsed_dosage: None, + date_text: String::from("now"), + parsed_date: Some(Local::now()), + route_of_administration: RouteOfAdministrationClassification::Oral, + } + } +} + +#[derive(Clone)] +pub struct CreateIngestionState +{ + mode: FormMode, + form_data: FormData, + highlighted_field: EditTarget, + edit_buffer: TextArea<'static>, + route_dropdown_visible: bool, + route_list_state: ListState, + error_message: Option, + field_in_error: Option, + show_help: bool, +} + +const ROUTES: &[RouteOfAdministrationClassification] = &[ + RouteOfAdministrationClassification::Oral, + RouteOfAdministrationClassification::Inhaled, + RouteOfAdministrationClassification::Insufflated, + RouteOfAdministrationClassification::Sublingual, + RouteOfAdministrationClassification::Buccal, + RouteOfAdministrationClassification::Rectal, + RouteOfAdministrationClassification::Smoked, + RouteOfAdministrationClassification::Transdermal, + RouteOfAdministrationClassification::Intravenous, + RouteOfAdministrationClassification::Intramuscular, +]; + +impl Default for CreateIngestionState +{ + fn default() -> Self + { + let mut route_list_state = ListState::default(); + route_list_state.select(Some(0)); + + Self { + mode: FormMode::View, + form_data: FormData::default(), + highlighted_field: EditTarget::SubstanceName, + edit_buffer: TextArea::default(), + route_dropdown_visible: false, + route_list_state, + error_message: None, + field_in_error: None, + show_help: false, + } + } +} + +fn preprocess_dosage_input(input: &str) -> String +{ + let re = Regex::new(r"^(\d+(?:\.\d+)?)([a-zA-Z]+)$").unwrap(); + if let Some(cap) = re.captures(input.trim()) + { + return format!("{} {}", &cap[1], &cap[2]); + } + input.to_owned() +} + +impl CreateIngestionState +{ + pub fn new() -> Self { Self::default() } + + fn try_save_ingestion(&mut self) -> Result> + { + if let Some(ingestion) = self.try_create_ingestion() + { + if let Err(e) = block_on(async { + let active_model = crate::database::entities::ingestion::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + substance_name: sea_orm::ActiveValue::Set( + ingestion.substance_name.to_lowercase(), + ), + route_of_administration: sea_orm::ActiveValue::Set( + serde_json::to_value(&ingestion.route_of_administration) + .unwrap() + .as_str() + .unwrap() + .to_string(), + ), + dosage: sea_orm::ActiveValue::Set(ingestion.dosage.as_base_units() as f32), + ingested_at: sea_orm::ActiveValue::Set(ingestion.ingestion_date.naive_utc()), + updated_at: sea_orm::ActiveValue::Set(chrono::Local::now().naive_utc()), + created_at: sea_orm::ActiveValue::Set(chrono::Local::now().naive_utc()), + }; + crate::database::entities::ingestion::Entity::insert(active_model) + .exec(&*crate::utils::DATABASE_CONNECTION) + .await + }) + { + self.error_message = Some(format!("Failed to save ingestion: {}", e)); + return Ok(None); + } + return Ok(Some(AppMessage::NavigateToPage(Screen::ListIngestions))); + } + Ok(None) + } + + pub fn handle_event(&mut self, event: AppEvent) -> Result> + { + match event + { + | AppEvent::Key(key_event) => + { + if key_event.modifiers == KeyModifiers::CONTROL + && key_event.code == KeyCode::Char('h') + { + self.show_help = !self.show_help; + return Ok(None); + } + + if self.show_help + { + if key_event.code == KeyCode::Esc + { + self.show_help = false; + } + return Ok(None); + } + + match self.mode + { + | FormMode::View => match key_event.code + { + | KeyCode::Esc => + { + return Ok(Some(AppMessage::NavigateToPage(Screen::ListIngestions))); + } + | KeyCode::Enter => match self.highlighted_field + { + | EditTarget::SaveButton => + { + return self.try_save_ingestion(); + } + | EditTarget::CancelButton => + { + return Ok(Some(AppMessage::NavigateToPage( + Screen::ListIngestions, + ))); + } + | _ => + { + self.mode = FormMode::Edit(self.highlighted_field); + match self.highlighted_field + { + | EditTarget::SubstanceName => + { + self.edit_buffer = TextArea::default(); + self.edit_buffer.insert_str(&self.form_data.substance_name); + } + | EditTarget::Dosage => + { + self.edit_buffer = TextArea::default(); + self.edit_buffer.insert_str(&self.form_data.dosage_text); + } + | EditTarget::RouteOfAdministration => + { + self.route_dropdown_visible = true; + if let Some(idx) = ROUTES.iter().position(|&r| { + r == self.form_data.route_of_administration + }) + { + self.route_list_state.select(Some(idx)); + } + } + | EditTarget::Date => + { + self.edit_buffer = TextArea::default(); + self.edit_buffer.insert_str(&self.form_data.date_text); + } + | _ => + {} + } + } + }, + | KeyCode::Down | KeyCode::Tab => + { + self.next_field(); + } + | KeyCode::Up | KeyCode::BackTab => + { + self.previous_field(); + } + | KeyCode::Char('s') if key_event.modifiers == KeyModifiers::CONTROL => + { + return self.try_save_ingestion(); + } + | _ => + {} + }, + | FormMode::Edit(target) => + { + if self.route_dropdown_visible + { + match key_event.code + { + | KeyCode::Esc => + { + self.route_dropdown_visible = false; + self.mode = FormMode::View; + } + | KeyCode::Enter => + { + if let Some(idx) = self.route_list_state.selected() + { + self.form_data.route_of_administration = ROUTES[idx]; + } + self.route_dropdown_visible = false; + self.mode = FormMode::View; + self.next_field(); + } + | KeyCode::Up => + { + if let Some(idx) = self.route_list_state.selected() + { + if idx > 0 + { + self.route_list_state.select(Some(idx - 1)); + } + } + } + | KeyCode::Down => + { + if let Some(idx) = self.route_list_state.selected() + { + if idx < ROUTES.len() - 1 + { + self.route_list_state.select(Some(idx + 1)); + } + } + } + | _ => + {} + } + } + else + { + match key_event.code + { + | KeyCode::Esc => + { + self.mode = FormMode::View; + } + | KeyCode::Enter => + { + let input = self.edit_buffer.lines().join(""); + match target + { + | EditTarget::SubstanceName => + { + if !input.trim().is_empty() + { + self.form_data.substance_name = input; + self.mode = FormMode::View; + self.field_in_error = None; + self.next_field(); + } + else + { + self.error_message = Some( + "Substance name cannot be empty".to_string(), + ); + self.field_in_error = + Some(EditTarget::SubstanceName); + } + } + | EditTarget::Dosage => + { + let processed = preprocess_dosage_input(&input); + match Dosage::from_str(&processed) + { + | Ok(dosage) => + { + self.form_data.dosage_text = input; + self.form_data.parsed_dosage = Some(dosage); + self.mode = FormMode::View; + self.field_in_error = None; + self.next_field(); + } + | Err(_) => + { + self.error_message = + Some("Invalid dosage format".to_string()); + self.field_in_error = Some(EditTarget::Dosage); + } + } + } + | EditTarget::Date => + { + if input == "now" + { + self.form_data.date_text = input; + self.form_data.parsed_date = Some(Local::now()); + self.mode = FormMode::View; + self.field_in_error = None; + self.next_field(); + } + else + { + match chrono_english::parse_date_string( + &input, + Local::now(), + chrono_english::Dialect::Us, + ) + { + | Ok(date) => + { + self.form_data.date_text = input; + self.form_data.parsed_date = Some(date); + self.mode = FormMode::View; + self.field_in_error = None; + self.next_field(); + } + | Err(_) => + { + self.error_message = + Some("Invalid date format".to_string()); + self.field_in_error = + Some(EditTarget::Date); + } + } + } + } + | _ => + {} + } + } + | KeyCode::Char(c) => + { + self.edit_buffer.insert_char(c); + } + | KeyCode::Backspace => + { + self.edit_buffer.delete_char(); + } + | _ => + {} + } + } + } + } + } + | _ => + {} + } + Ok(None) + } + + fn try_create_ingestion(&self) -> Option + { + if self.form_data.substance_name.trim().is_empty() + { + return None; + } + + let dosage = self.form_data.parsed_dosage.clone()?; + let date = self.form_data.parsed_date?; + + Some(LogIngestion { + substance_name: self.form_data.substance_name.clone(), + dosage, + ingestion_date: date, + route_of_administration: self.form_data.route_of_administration, + }) + } + + fn next_field(&mut self) + { + self.highlighted_field = match self.highlighted_field + { + | EditTarget::SubstanceName => EditTarget::Dosage, + | EditTarget::Dosage => EditTarget::RouteOfAdministration, + | EditTarget::RouteOfAdministration => EditTarget::Date, + | EditTarget::Date => EditTarget::SaveButton, + | EditTarget::SaveButton => EditTarget::CancelButton, + | EditTarget::CancelButton => EditTarget::SubstanceName, + }; + } + + fn previous_field(&mut self) + { + self.highlighted_field = match self.highlighted_field + { + | EditTarget::SubstanceName => EditTarget::CancelButton, + | EditTarget::Dosage => EditTarget::SubstanceName, + | EditTarget::RouteOfAdministration => EditTarget::Dosage, + | EditTarget::Date => EditTarget::RouteOfAdministration, + | EditTarget::SaveButton => EditTarget::Date, + | EditTarget::CancelButton => EditTarget::SaveButton, + }; + } +} + +impl Component for CreateIngestionState {} + +impl Renderable for CreateIngestionState +{ + fn render(&self, area: Rect, frame: &mut Frame) -> Result<()> + { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(10), // Form + Constraint::Length(3), // Action buttons + Constraint::Length(1), // Error message + ]) + .split(area); + + let form_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Substance + Constraint::Length(3), // Dosage + Constraint::Length(3), // ROA + Constraint::Length(3), // Date + ]) + .margin(1) + .split(chunks[0]); + + self.render_field( + frame, + form_layout[0], + EditTarget::SubstanceName, + "Substance", + &self.form_data.substance_name, + "Enter substance name", + ); + + self.render_field( + frame, + form_layout[1], + EditTarget::Dosage, + "Dosage", + if self.form_data.dosage_text.is_empty() + { + "Enter amount (e.g., 10mg, 0.5g)" + } + else + { + &self.form_data.dosage_text + }, + "Enter dosage with units", + ); + + self.render_field( + frame, + form_layout[2], + EditTarget::RouteOfAdministration, + "Route", + &self.form_data.route_of_administration.to_string(), + "Select administration route", + ); + + self.render_field( + frame, + form_layout[3], + EditTarget::Date, + "When", + &self.form_data.date_text, + "'now' or time (e.g., today 2pm)", + ); + + self.render_action_buttons(frame, chunks[1]); + + if let Some(error) = &self.error_message + { + frame.render_widget( + Paragraph::new(error.as_str()) + .style(Style::default().fg(Theme::ERROR)) + .alignment(ratatui::layout::Alignment::Center), + chunks[2], + ); + } + + if self.route_dropdown_visible + { + let popup_area = centered_rect(30, 50, area); + frame.render_widget(Clear, popup_area); + + let items: Vec = ROUTES + .iter() + .enumerate() + .map(|(i, route)| { + let is_selected = Some(i) == self.route_list_state.selected(); + let style = if is_selected + { + Style::default().fg(Theme::TEXT).bg(Theme::SURFACE1) + } + else + { + Style::default().fg(Theme::TEXT) + }; + ListItem::new(route.to_string()).style(style) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title("Select Route") + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::MAUVE)), + ) + .highlight_style(Style::default().bg(Theme::SURFACE1)); + + frame.render_stateful_widget(list, popup_area, &mut self.route_list_state.clone()); + } + + if let FormMode::Edit(target) = self.mode + { + if !self.route_dropdown_visible + { + let popup_area = centered_rect(40, 20, area); + frame.render_widget(Clear, popup_area); + + let title = match target + { + | EditTarget::SubstanceName => "Edit Substance Name", + | EditTarget::Dosage => "Edit Dosage", + | EditTarget::Date => "Edit Date", + | _ => "", + }; + + let input = Paragraph::new(self.edit_buffer.lines().join("")) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::MAUVE)), + ) + .style(Style::default().fg(Theme::TEXT)); + + frame.render_widget(input, popup_area); + } + } + + Ok(()) + } +} + +impl CreateIngestionState +{ + fn render_field( + &self, + frame: &mut Frame, + area: Rect, + field_id: EditTarget, + label: &str, + value: &str, + hint: &str, + ) + { + let border_color = if Some(field_id) == self.field_in_error + { + Theme::ERROR + } + else if self.highlighted_field == field_id + { + Theme::MAUVE + } + else + { + Theme::BORDER + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + let text = vec![Line::from(vec![ + Span::styled( + format!("{}: ", label), + Style::default().fg( + if self.highlighted_field == field_id + { + Theme::MAUVE + } + else + { + Theme::TEXT + }, + ), + ), + Span::styled( + if value.is_empty() { hint } else { value }, + if value.is_empty() + { + Style::default() + .fg(Theme::OVERLAY0) + .add_modifier(Modifier::ITALIC) + } + else + { + Style::default().fg(Theme::TEXT) + }, + ), + ])]; + + frame.render_widget(Paragraph::new(text), inner_area); + } + + fn render_action_buttons(&self, frame: &mut Frame, area: Rect) + { + let button_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .margin(1) + .split(area); + + let save_block = Block::default() + .title(Line::from(vec![ + Span::styled(" Save ", Style::default().fg(Theme::TEXT)), + Span::styled("(Ctrl+S)", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" ", Style::default()), + ])) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style( + if self.highlighted_field == EditTarget::SaveButton + { + Style::default().fg(Theme::MAUVE) + } + else + { + Style::default().fg(Theme::BORDER) + }, + ); + + let cancel_block = Block::default() + .title(Line::from(vec![ + Span::styled(" Cancel ", Style::default().fg(Theme::TEXT)), + Span::styled("(Esc)", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" ", Style::default()), + ])) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style( + if self.highlighted_field == EditTarget::CancelButton + { + Style::default().fg(Theme::MAUVE) + } + else + { + Style::default().fg(Theme::BORDER) + }, + ); + + frame.render_widget(save_block, button_layout[0]); + frame.render_widget(cancel_block, button_layout[1]); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect +{ + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/tui/views/ingestion/get_ingestion.rs b/src/tui/views/ingestion/get_ingestion.rs new file mode 100644 index 00000000..3fcb729c --- /dev/null +++ b/src/tui/views/ingestion/get_ingestion.rs @@ -0,0 +1,123 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::ingestion::model::Ingestion; +use crate::substance::Substance; +use crate::tui::theme::Theme; +use chrono::Local; +use miette::Result; +use ratatui::layout::Alignment; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use tracing::debug; + +#[derive(Clone)] +pub struct IngestionViewState +{ + ingestion: Option, + analysis: Option, + substance: Option, +} + +impl IngestionViewState +{ + pub fn new() -> Self + { + debug!("Initializing new IngestionDetailsState"); + Self { + ingestion: None, + analysis: None, + substance: None, + } + } + + pub async fn load_ingestion(&mut self, _id: String) -> Result<()> { Ok(()) } + + pub fn view(&self, frame: &mut Frame, area: Rect) + { + let border = Block::default() + .title_alignment(Alignment::Left) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().fg(Theme::BORDER)); + + + frame.render_widget(border.clone(), area); + + let inner_area = border.inner(area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(30), + Constraint::Percentage(40), + ]) + .split(inner_area); + + let wip_message = Paragraph::new(vec![ + Line::from(vec![Span::styled( + "🚧 Work in Progress 🚧", + Style::default() + .fg(Theme::YELLOW) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![Span::styled( + "Ingestion Details View is currently under development", + Style::default().fg(Theme::SUBTEXT0), + )]), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title("Status") + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ); + + frame.render_widget(wip_message, chunks[0]); + + + let features = Paragraph::new(vec![ + Line::from(vec![Span::styled( + "Planned Features:", + Style::default() + .fg(Theme::GREEN) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![Span::styled( + "✓ Detailed Substance Information", + Style::default().fg(Theme::BLUE), + )]), + Line::from(vec![Span::styled( + "✓ Comprehensive Phase Timeline", + Style::default().fg(Theme::BLUE), + )]), + Line::from(vec![Span::styled( + "✓ Dosage and Route Analysis", + Style::default().fg(Theme::BLUE), + )]), + Line::from(vec![Span::styled( + "✓ Progress Tracking", + Style::default().fg(Theme::BLUE), + )]), + ]) + .alignment(Alignment::Left) + .block( + Block::default() + .title("Upcoming Enhancements") + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ); + frame.render_widget(features, chunks[1]); + } +} diff --git a/src/tui/views/ingestion/list_ingestion.rs b/src/tui/views/ingestion/list_ingestion.rs new file mode 100644 index 00000000..5c4876ad --- /dev/null +++ b/src/tui/views/ingestion/list_ingestion.rs @@ -0,0 +1,485 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::core::QueryHandler; +use crate::ingestion::ListIngestions; +use crate::substance::repository; +use crate::substance::route_of_administration::phase::PhaseClassification; +use crate::tui::theme::Theme; +use crate::tui::widgets::dosage::dosage_dots; +use crate::utils::DATABASE_CONNECTION; +use chrono; +use chrono_humanize::HumanTime; +use miette::Result; +use ratatui::prelude::Alignment; +use ratatui::prelude::Color; +use ratatui::prelude::Constraint; +use ratatui::prelude::Direction; +use ratatui::prelude::Frame; +use ratatui::prelude::Layout; +use ratatui::prelude::Rect; +use ratatui::prelude::Style; +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Cell; +use ratatui::widgets::Gauge; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Row; +use ratatui::widgets::Table; +use ratatui::widgets::TableState; +use std::time::Instant; + +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// State for the ingestion list view, containing the list of ingestions and +/// their analyses, along with the current table selection state. +#[derive(Clone)] +pub struct IngestionListState +{ + /// Vector of tuples containing ingestions and their optional analyses + ingestions: Vec<( + crate::ingestion::model::Ingestion, + Option, + )>, + /// Current table selection state + table_state: TableState, + /// Loading state + loading_state: LoadingState, +} + +/// Loading state for the ingestion list +#[derive(Clone)] +struct LoadingState +{ + is_loading: bool, + progress: f64, + message: String, + start_time: Option, +} + +impl LoadingState +{ + fn new() -> Self + { + Self { + is_loading: true, + progress: 0.0, + message: "Preparing to load ingestions...".to_string(), + start_time: Some(Instant::now()), + } + } +} + +impl IngestionListState +{ + /// Creates a new empty ingestion list state + pub fn new() -> Self + { + Self { + ingestions: Vec::new(), + table_state: TableState::default(), + loading_state: LoadingState::new(), + } + } + + /// Updates the ingestion list by fetching current data and analyzing each + /// ingestion + pub async fn update(&mut self) -> Result<()> + { + self.loading_state.message = "Fetching ingestions...".to_string(); + self.loading_state.progress = 0.0; + + let ingestions = ListIngestions::default() + .query() + .await + .map_err(|e| miette::miette!("Failed to fetch ingestions: {}", e))?; + self.loading_state.progress = 0.2; + self.loading_state.message = "Analyzing ingestions...".to_string(); + + self.ingestions = Vec::new(); + let total_ingestions = ingestions.len(); + + for (index, ingestion) in ingestions.into_iter().enumerate() + { + self.loading_state.message = format!( + "Analyzing {} ({}/{})", + ingestion.substance, + index + 1, + total_ingestions + ); + self.loading_state.progress = 0.2 + (0.8 * (index as f64 / total_ingestions as f64)); + + let substance = repository::get_substance(&ingestion.substance, &DATABASE_CONNECTION) + .await + .map_err(|e| miette::miette!("Failed to get substance: {}", e))?; + + let analysis = if let Some(substance_data) = substance + { + IngestionAnalysis::analyze(ingestion.clone(), substance_data) + .await + .map_err(|e| miette::miette!("Failed to analyze ingestion: {}", e))? + .into() + } + else + { + None + }; + + self.ingestions.push((ingestion, analysis)); + } + + if !self.ingestions.is_empty() + { + self.table_state.select(Some(0)); + } + + self.loading_state.is_loading = false; + self.loading_state.start_time = None; + Ok(()) + } + + /// Gets the current spinner frame based on elapsed time + fn get_spinner_frame(&self) -> &'static str + { + if let Some(start_time) = self.loading_state.start_time + { + let elapsed = start_time.elapsed().as_millis() as usize; + let frame_index = (elapsed / 80) % SPINNER_FRAMES.len(); + SPINNER_FRAMES[frame_index] + } + else + { + SPINNER_FRAMES[0] + } + } + + /// Renders the loading screen + fn render_loading_screen(&self, frame: &mut Frame, area: Rect) + { + let loading_area = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(35), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(2), + Constraint::Percentage(35), + ]) + .split(area); + + // Title with spinner + let spinner = self.get_spinner_frame(); + let title = Paragraph::new(format!("{} Loading Ingestions", spinner)) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(title, loading_area[1]); + + // Progress bar + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ) + .gauge_style(Style::default().fg(Color::Yellow)) + .ratio(self.loading_state.progress) + .label(format!("{:.0}%", self.loading_state.progress * 100.0)); + frame.render_widget(gauge, loading_area[2]); + + // Loading message + let message = Paragraph::new(self.loading_state.message.clone()) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(message, loading_area[3]); + } + + /// Renders the ingestion list view + pub fn view(&mut self, frame: &mut Frame, area: Rect) -> Result<()> + { + if self.loading_state.is_loading + { + self.render_loading_screen(frame, area); + return Ok(()); + } + + if self.ingestions.is_empty() + { + return self.render_empty_state(frame, area); + } + + let table_data = self + .ingestions + .iter() + .map(|(ingestion, analysis)| { + let phase = analysis.as_ref().and_then(|a| a.current_phase); + let progress = self.calculate_progress(analysis); + let is_completed = progress >= 1.0; + + let phase_text = self.format_phase_text(phase, is_completed); + let progress_bar = self.create_progress_bar(phase, is_completed); + let phase_style = self.get_phase_style(phase, is_completed); + let row_style = self.get_row_style(is_completed); + + let cells = vec![ + Cell::from(ingestion.id.unwrap_or(0).to_string()).style(row_style), + Cell::from(ingestion.substance.to_string()).style(row_style), + Cell::from(ingestion.route.to_string()).style(row_style), + Cell::from(self.format_dosage(ingestion, analysis)).style(row_style), + Cell::from(Line::from(vec![ + Span::styled(format!("{} ", phase_text), phase_style), + Span::styled(progress_bar, phase_style), + ])), + Cell::from(HumanTime::from(ingestion.ingestion_date).to_string()) + .style(row_style), + ]; + Row::new(cells).bottom_margin(1) + }) + .collect::>(); + + let table_area = self.create_margins(area); + let header = Row::new( + ["ID", "Substance", "ROA", "Dosage", "Phase", "Time"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow))), + ) + .style(Style::default()) + .bottom_margin(1); + + frame.render_stateful_widget( + Table::default() + .rows(table_data) + .header(header) + .widths(&[ + Constraint::Length(5), + Constraint::Length(20), + Constraint::Length(10), + Constraint::Length(25), + Constraint::Length(25), + Constraint::Length(20), + ]) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray), + ), + table_area, + &mut self.table_state, + ); + Ok(()) + } + + /// Renders the empty state message when no ingestions exist + fn render_empty_state(&self, frame: &mut Frame, area: Rect) -> Result<()> + { + let no_ingestions = Paragraph::new(vec![ + Line::from(Span::styled( + "No ingestions found", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "Press 'n' to create a new ingestion", + Style::default().fg(Color::DarkGray), + )), + ]) + .alignment(Alignment::Center); + frame.render_widget(no_ingestions, area); + Ok(()) + } + + /// Creates margins around the table area + fn create_margins(&self, area: Rect) -> Rect + { + let vertical_margins = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Top margin + Constraint::Min(0), // Content + Constraint::Length(1), // Bottom margin + ]) + .split(area)[1]; + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), // Left margin + Constraint::Min(0), // Content + Constraint::Length(2), // Right margin + ]) + .split(vertical_margins)[1] + } + + /// Calculates the progress of an ingestion + fn calculate_progress(&self, analysis: &Option) -> f64 + { + analysis + .as_ref() + .map(|a| { + let now = chrono::Local::now(); + let total_duration = a.ingestion_end - a.ingestion_start; + let elapsed_time = if now < a.ingestion_start + { + chrono::Duration::zero() + } + else if now > a.ingestion_end + { + total_duration + } + else + { + now - a.ingestion_start + }; + (elapsed_time.num_seconds() as f64 / total_duration.num_seconds() as f64) + .clamp(0.0, 1.0) + }) + .unwrap_or(0.0) + } + + /// Formats the phase text based on current state + fn format_phase_text(&self, phase: Option, is_completed: bool) -> String + { + match (phase, is_completed) + { + | (Some(p), _) => format!("{:?}", p), + | (None, true) => "Completed".to_string(), + | (None, false) => "Unknown".to_string(), + } + } + + /// Creates the progress bar based on current phase + fn create_progress_bar(&self, phase: Option, is_completed: bool) + -> String + { + if is_completed + { + return "▰".repeat(5); + } + + let phase_blocks = match phase + { + | Some(PhaseClassification::Onset) => 1, + | Some(PhaseClassification::Comeup) => 2, + | Some(PhaseClassification::Peak) => 3, + | Some(PhaseClassification::Comedown) => 4, + | Some(PhaseClassification::Afterglow) => 5, + | Some(PhaseClassification::Unknown) | None => 0, + }; + + "▰".repeat(phase_blocks) + &"▱".repeat(5 - phase_blocks) + } + + /// Gets the style for the phase text and progress bar + fn get_phase_style(&self, phase: Option, is_completed: bool) -> Style + { + match phase + { + | Some(PhaseClassification::Onset) => Style::default().fg(Theme::BLUE), + | Some(PhaseClassification::Comeup) => Style::default().fg(Theme::GREEN), + | Some(PhaseClassification::Peak) => Style::default().fg(Theme::RED), + | Some(PhaseClassification::Comedown) => Style::default().fg(Theme::YELLOW), + | Some(PhaseClassification::Afterglow) => Style::default().fg(Theme::SUBTEXT0), + | Some(PhaseClassification::Unknown) => Style::default().fg(Theme::OVERLAY0), + | None if is_completed => Style::default() + .fg(Theme::OVERLAY0) + .add_modifier(Modifier::DIM), + | None => Style::default().fg(Theme::OVERLAY0), + } + } + + /// Gets the style for the entire row + fn get_row_style(&self, is_completed: bool) -> Style + { + if is_completed + { + Style::default() + .fg(Theme::OVERLAY0) + .add_modifier(Modifier::DIM) + } + else + { + Style::default() + } + } + + /// Formats the dosage text with classification + fn format_dosage( + &self, + ingestion: &crate::ingestion::model::Ingestion, + analysis: &Option, + ) -> String + { + let dosage_classification = analysis.as_ref().and_then(|a| a.dosage_classification); + match dosage_classification + { + | Some(classification) => + { + format!( + "{} {}", + ingestion.dosage.to_string(), + dosage_dots(classification) + ) + } + | None => ingestion.dosage.to_string(), + } + } + + /// Moves the selection to the next item + pub fn next(&mut self) + { + if !self.ingestions.is_empty() + { + let i = match self.table_state.selected() + { + | Some(i) => + { + if i >= self.ingestions.len() - 1 + { + 0 + } + else + { + i + 1 + } + } + | None => 0, + }; + self.table_state.select(Some(i)); + } + } + + /// Moves the selection to the previous item + pub fn previous(&mut self) + { + if !self.ingestions.is_empty() + { + let i = match self.table_state.selected() + { + | Some(i) => + { + if i == 0 + { + self.ingestions.len() - 1 + } + else + { + i - 1 + } + } + | None => 0, + }; + self.table_state.select(Some(i)); + } + } + + /// Returns the currently selected ingestion + pub fn selected_ingestion(&self) -> Option<&crate::ingestion::model::Ingestion> + { + if self.ingestions.is_empty() + { + return None; + } + self.table_state + .selected() + .and_then(|i| self.ingestions.get(i)) + .map(|(ingestion, _)| ingestion) + } +} diff --git a/src/tui/views/ingestion/mod.rs b/src/tui/views/ingestion/mod.rs new file mode 100644 index 00000000..0cdb095e --- /dev/null +++ b/src/tui/views/ingestion/mod.rs @@ -0,0 +1,3 @@ +pub mod create_ingestion; +pub mod get_ingestion; +pub mod list_ingestion; diff --git a/src/tui/views/loading.rs b/src/tui/views/loading.rs new file mode 100644 index 00000000..c71e0d9d --- /dev/null +++ b/src/tui/views/loading.rs @@ -0,0 +1,129 @@ +use crate::tui::core::Renderable; +use crate::tui::theme::Theme; +use miette::Result; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use std::time::Instant; + +pub struct LoadingScreen +{ + start_time: Instant, + context: String, +} + +impl LoadingScreen +{ + pub fn new(context: impl Into) -> Self + { + Self { + start_time: Instant::now(), + context: context.into(), + } + } + + fn get_random_quote(&self) -> &'static str + { + const QUOTES: &[&str] = &["\"When you see this message, your computer sucks.\" — \ + Incompetent programmer of this app"]; + let index = (self.start_time.elapsed().as_secs() / 5) as usize % QUOTES.len(); + QUOTES[index] + } +} + +impl Renderable for LoadingScreen +{ + fn render(&self, area: Rect, frame: &mut Frame) -> Result<()> + { + let loading_area = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(35), // Top spacing + Constraint::Length(7), // Loading animation + Constraint::Length(2), // Message + Constraint::Length(2), // Quote + Constraint::Percentage(35), // Bottom spacing + ]) + .split(area); + + // Create a spinning ring animation + let time = self.start_time.elapsed().as_millis() as usize; + + // Define the ring characters + let outer_ring = ['◜', '◠', '◝', '◞', '◡', '◟']; + let middle_ring = ['◴', '◷', '◶', '◵']; + let inner_ring = ['◐', '◓', '◑', '◒']; + let center_dots = ['◌', '◍', '◎', '●']; + + // Calculate rotation for each ring + let outer_idx = (time / 100) % outer_ring.len(); + let middle_idx = (time / 150) % middle_ring.len(); + let inner_idx = (time / 200) % inner_ring.len(); + let center_idx = (time / 250) % center_dots.len(); + + // Create the animation frame + let frame_lines = [ + String::from(" "), + format!(" {} ", outer_ring[outer_idx]), + format!( + " {} {} ", + middle_ring[middle_idx], + middle_ring[(middle_idx + 2) % middle_ring.len()] + ), + format!( + " {} {} {} ", + inner_ring[inner_idx], + center_dots[center_idx], + inner_ring[(inner_idx + 2) % inner_ring.len()] + ), + format!( + " {} {} ", + middle_ring[(middle_idx + 1) % middle_ring.len()], + middle_ring[(middle_idx + 3) % middle_ring.len()] + ), + format!(" {} ", outer_ring[(outer_idx + 3) % outer_ring.len()]), + String::from(" "), + ]; + + let colors = [Theme::BLUE, Theme::SAPPHIRE, Theme::LAVENDER, Theme::MAUVE]; + + let animation_text: Vec = frame_lines + .iter() + .enumerate() + .map(|(i, line)| { + Line::from(vec![Span::styled( + line, + Style::default() + .fg(colors[i.min(colors.len() - 1)]) + .add_modifier(Modifier::BOLD), + )]) + }) + .collect(); + + let animation_widget = Paragraph::new(animation_text).alignment(Alignment::Center); + frame.render_widget(animation_widget, loading_area[1]); + + let pulse = (time / 500) % 2 == 0; + let message_style = Style::default().fg(Theme::TEXT).add_modifier( + if pulse + { + Modifier::BOLD + } + else + { + Modifier::empty() + }, + ); + + let message = Paragraph::new(format!("Loading {}", self.context)) + .alignment(Alignment::Center) + .style(message_style); + frame.render_widget(message, loading_area[2]); + + let quote = Paragraph::new(self.get_random_quote()) + .alignment(Alignment::Center) + .style(Style::default().fg(Theme::OVERLAY0).italic()); + frame.render_widget(quote, loading_area[3]); + + Ok(()) + } +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs new file mode 100644 index 00000000..9643e458 --- /dev/null +++ b/src/tui/views/mod.rs @@ -0,0 +1,5 @@ +pub mod ingestion; +pub mod loading; +pub mod welcome; + +pub use welcome::Welcome; diff --git a/src/tui/views/welcome.rs b/src/tui/views/welcome.rs new file mode 100644 index 00000000..c3665bbe --- /dev/null +++ b/src/tui/views/welcome.rs @@ -0,0 +1,100 @@ +use crate::tui::core::Renderable; +use crate::tui::Theme; +use ratatui::layout::Alignment; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::prelude::Line; +use ratatui::prelude::Span; +use ratatui::prelude::Style; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +const LOGO: &str = r#" + ███▄ █ ▓█████ █ ██ ██▀███ ▒█████ ███▄ █ ▓█████ ██ ▄█▀ + ██ ▀█ █ ▓█ ▀ ██ ▓██▒▓██ ▒ ██▒▒██▒ ██▒ ██ ▀█ █ ▓█ ▀ ██▄█▒ +▓██ ▀█ ██▒▒███ ▓██ ▒██░▓██ ░▄█ ▒▒██░ ██▒▓██ ▀█ ██▒▒███ ▓███▄░ +▓██▒ ▐▌██▒▒▓█ ▄ ▓▓█ ░██░▒██▀▀█▄ ▒██ ██░▓██▒ ▐▌██▒▒▓█ ▄ ▓██ █▄ +▒██░ ▓██░░▒████▒▒▒█████▓ ░██▓ ▒██▒░ ████▓▒░▒██░ ▓██░░▒████▒▒██▒ █▄ +░ ▒░ ▒ ▒ ░░ ▒░ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ ░░ ▒░ ░▒ ▒▒ ▓▒ +"#; + +#[derive(Default)] +pub struct Welcome {} + +impl Renderable for Welcome +{ + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> + { + if frame.size().width < 80 + { + let msg = "Please increase your terminal size to at least 80 characters wide to view \ + the Neuronek TUI"; + let msg_area = centered_rect(60, 5, area); + + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(msg) + .style(Style::default()) + .alignment(Alignment::Center), + msg_area, + ); + + return Ok(()); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints( + [ + Constraint::Length(8), + Constraint::Length(1), + Constraint::Length(5), + ] + .as_ref(), + ) + .split(area); + + let logo = Paragraph::new(LOGO) + .style(Style::default().fg(Theme::MAUVE)) + .alignment(Alignment::Center); + + let welcome_text = vec![ + Line::from(vec![ + Span::styled("Press ", Style::default().fg(Theme::TEXT)), + Span::styled("2", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" to manage ingestions", Style::default().fg(Theme::TEXT)), + ]), + Line::from(vec![ + Span::styled("Press ", Style::default().fg(Theme::TEXT)), + Span::styled("0", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" to access settings", Style::default().fg(Theme::TEXT)), + ]), + Line::from(vec![ + Span::styled("Press ", Style::default().fg(Theme::TEXT)), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" for help", Style::default().fg(Theme::TEXT)), + ]), + ]; + + let help = Paragraph::new(welcome_text) + .style(Style::default()) + .alignment(Alignment::Center); + + frame.render_widget(logo, chunks[0]); + frame.render_widget(help, chunks[2]); + + Ok(()) + } +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect +{ + let top_pad = (area.height - height) / 2; + let left_pad = (area.width - width) / 2; + + Rect::new(area.left() + left_pad, area.top() + top_pad, width, height) +} diff --git a/src/tui/widgets/dosage.rs b/src/tui/widgets/dosage.rs new file mode 100644 index 00000000..b43d515e --- /dev/null +++ b/src/tui/widgets/dosage.rs @@ -0,0 +1,15 @@ +use crate::substance::DosageClassification; + +pub fn dosage_dots(dosage_classification: DosageClassification) -> String +{ + let dosage_strength_bar = match dosage_classification + { + | DosageClassification::Threshold => "●○○○○", + | DosageClassification::Light => "●●○○○", + | DosageClassification::Medium => "●●●○○", + | DosageClassification::Strong => "●●●●○", + | DosageClassification::Heavy => "●●●●●", + }; + + dosage_strength_bar.to_string() +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 00000000..9bd8cffe --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1,48 @@ +use crossterm::event::Event; +use miette::Result; +use ratatui::prelude::*; + +use super::core::Renderable; + +pub mod dosage; + +pub trait EventHandler +{ + /// The type of messages this handler produces + type Message; + + /// Handle an event, optionally producing a message + fn handle_event(&mut self, event: Event) -> Result>; +} + +pub trait Stateful +{ + /// The type of messages this widget accepts + type Message; + + /// Update the widget's state based on a message + fn update(&mut self, msg: Self::Message) -> Result<()>; +} + +pub trait Focusable +{ + /// Returns whether the widget is currently focused + fn is_focused(&self) -> bool; + + /// Set the focus state of the widget + fn set_focus(&mut self, focused: bool); +} + +pub trait Navigable +{ + /// Move the selection up + fn select_prev(&mut self) -> Result<()>; + + /// Move the selection down + fn select_next(&mut self) -> Result<()>; + + /// Get the currently selected index + fn selected(&self) -> Option; +} + +pub trait Component: Renderable {} diff --git a/src/utils.rs b/src/utils.rs index f143b432..5d82b49b 100755 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,6 @@ use crate::cli::OutputFormat; -use crate::migration::Migrator; +use crate::core::config::CONFIG; +use crate::database::Migrator; use async_std::task::block_on; use atty::Stream; use chrono::Local; @@ -9,19 +10,20 @@ use log::error; use log::info; use log::warn; use miette::IntoDiagnostic; +use sea_orm::prelude::async_trait; use sea_orm::Database; use sea_orm::DatabaseConnection; -use sea_orm::prelude::async_trait; use sea_orm_migration::IntoSchemaManagerConnection; use sea_orm_migration::MigratorTrait; use std::env::temp_dir; +use std::io::stdout; use std::path::PathBuf; use std::thread::sleep; use tracing::instrument; use tracing_indicatif::IndicatifLayer; -use tracing_subscriber::Layer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::Layer; #[derive(Debug, Clone)] pub struct AppContext<'a> @@ -30,42 +32,10 @@ pub struct AppContext<'a> pub stdout_format: OutputFormat, } -#[async_trait::async_trait] -pub trait CommandHandler -{ - async fn handle<'a>(&self, ctx: AppContext<'a>) -> miette::Result; -} - -#[derive(Debug, Clone)] -pub struct Config -{ - pub journal_path: PathBuf, -} - -impl Default for Config -{ - fn default() -> Self - { - let mut journal_path = directories::ProjectDirs::from("com", "keinsell", "neuronek") - .unwrap() - .data_dir() - .join("journal.db") - .clone(); - - if cfg!(test) || cfg!(debug_assertions) - { - journal_path = temp_dir().join("neuronek.sqlite"); - } - - Config { journal_path } - } -} - lazy_static::lazy_static! { #[derive(Clone, Debug)] pub static ref DATABASE_CONNECTION: DatabaseConnection = { - let config = Config::default(); - let sqlite_path = format!("sqlite://{}", config.journal_path.clone().to_str().unwrap()); + let sqlite_path = format!("sqlite://{}", CONFIG.sqlite_path.clone().to_str().expect("Invalid UTF-8 in path")); debug!("Opening database connection to {}", sqlite_path); @@ -78,7 +48,7 @@ lazy_static::lazy_static! { if error.to_string().contains("unable to open database file") { warn!("Database file not found or inaccessible at {}, attempting to initialize...", sqlite_path); - if let Err(init_error) = initialize_database(&config) { + if let Err(init_error) = initialize_sqlite_by_path(&CONFIG.sqlite_path) { error!("Failed to initialize the database: {}", init_error); panic!("Critical: Unable to initialize the database file at {}. Error: {}", sqlite_path, init_error); } @@ -102,10 +72,9 @@ lazy_static::lazy_static! { }; } -fn initialize_database(config: &Config) -> std::result::Result<(), String> +fn initialize_sqlite_by_path(path: &PathBuf) -> std::result::Result<(), String> { - let journal_path = config.journal_path.clone(); - if let Some(parent_dir) = journal_path.parent() + if let Some(parent_dir) = path.parent() { if !parent_dir.exists() { @@ -115,9 +84,8 @@ fn initialize_database(config: &Config) -> std::result::Result<(), String> } } - std::fs::File::create(&journal_path) - .map_err(|e| format!("Failed to create database file: {}", e))?; - debug!("Created database file at {}", journal_path.display()); + std::fs::File::create(path).map_err(|e| format!("Failed to create database file: {}", e))?; + debug!("Created database file at {}", path.display()); Ok(()) } @@ -144,8 +112,8 @@ pub async fn migrate_database(database_connection: &DatabaseConnection) -> miett if !pending_migrations.is_empty() { - info!("There are {} migration pending.", pending_migrations.len()); - info!("Applying migration into {:?}", database_connection); + info!("There are {} database pending.", pending_migrations.len()); + info!("Applying database into {:?}", database_connection); if let Some(spinner) = &spinner { @@ -165,23 +133,6 @@ pub async fn migrate_database(database_connection: &DatabaseConnection) -> miett Ok(()) } -pub fn setup_diagnostics() { miette::set_panic_hook(); } - -// TODO: Implement logging -pub fn setup_logger() -{ - // let indicatif_layer = IndicatifLayer::new(); - // - // if atty::is(Stream::Stdout) && cfg!(debug_assertions) - // { - // tracing_subscriber::registry() - // .with(tracing_subscriber::fmt::layer(). - // with_writer(indicatif_layer.get_stderr_writer())) - // .with(indicatif_layer) - // .init(); - // } -} - pub fn parse_date_string(humanized_input: &str) -> miette::Result> {