202 Commits

Author SHA1 Message Date
5310b98542 Feat: produce an .msi for windows CI
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m27s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-02-28 03:33:54 +01:00
7099501130 Feat: overhaul to produce .dmg and .app on macOS build script 2026-02-28 03:15:51 +01:00
730ddfb716 Feat: add slicing words 2026-02-28 02:37:09 +01:00
0a186f774c Feat: more mouse support 2026-02-28 02:26:33 +01:00
25a5c77344 Feat: fixes 2026-02-27 14:39:42 +01:00
3dba8213d6 Feat: tidy up the repo
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 9m39s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-02-26 23:45:03 +01:00
299689e206 Feat: UI / UX improvements once more (mouse)
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 10m3s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-02-26 23:29:07 +01:00
a50059cf19 Feat: UI / UX fixes
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 9m42s
Deploy Website / deploy (push) Failing after 32s
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-02-26 21:17:53 +01:00
6cdb4d9d2c WIP: multi-platform builds pipeline 2026-02-26 18:54:01 +01:00
5b3252cc31 Feat: no console in bg and plugin fix
Some checks failed
Deploy Website / deploy (push) Failing after 27s
2026-02-26 12:42:22 +01:00
b728b38d6e Feat: add hidden mode and new documentation
Some checks failed
Deploy Website / deploy (push) Failing after 29s
2026-02-26 12:31:56 +01:00
8af17c01d8 Feat: WIP terse code documentation 2026-02-26 01:08:16 +01:00
c2eeebcfb7 Feat: bank / pattern import / export feature + documentation
Some checks failed
Deploy Website / deploy (push) Failing after 31s
2026-02-26 00:20:46 +01:00
7622e3d14c Fix: boundary fix in help/dict views
Some checks failed
Deploy Website / deploy (push) Failing after 31s
2026-02-25 23:29:11 +01:00
e956346ae9 Feat: text selection using mouse
Some checks failed
Deploy Website / deploy (push) Failing after 30s
2026-02-25 23:20:42 +01:00
6892575a53 Fix: copy/paste multi-step 2026-02-25 22:35:43 +01:00
27b826ebaf Add indications for cross building 2026-02-25 22:08:08 +01:00
0f0f13f2b8 Feat: mixed bag
Some checks failed
Deploy Website / deploy (push) Failing after 2m9s
2026-02-25 20:31:36 +01:00
6b94d6403a Feat: internal recording / overdubbing
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-24 13:13:56 +01:00
f0de312d6b Feat: UI/UX and ducking compressor
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s
2026-02-24 02:57:27 +01:00
7632bc76f7 Feat: lots of convenience stuff 2026-02-24 00:52:40 +01:00
78b20b5ff9 Feat: all and noall words
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-23 23:04:43 +01:00
4a8396670f Feat: lissajous
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-23 22:06:09 +01:00
77b7fa1f9e Feat: fixing stderr catching and scope not drawing completely 2026-02-23 21:53:53 +01:00
979b7639ac Feat: new harmony / melodic words and demo
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-23 02:25:32 +01:00
2a2b3c5651 Feat: fixes and demo
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-23 01:18:43 +01:00
f6c7438886 Fix: revert optimizations 2026-02-23 00:51:01 +01:00
057ba5b2f3 Feat: demo songs 2026-02-22 23:50:35 +01:00
40e69b66da Feat: script execution performance optimization
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s
2026-02-22 14:16:38 +01:00
1ce5b8597a Feat: cleanup
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-22 13:28:03 +01:00
789dbb186b Feat: CHANGELOG updates 2026-02-22 12:55:58 +01:00
8ba98e8f3b Feat: introduce follow up actions
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-22 03:59:09 +01:00
003ee0518e Feat: WIP pattern view redesign
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-22 03:26:48 +01:00
52406c7374 Feat: add wave word for drum synthesis
Some checks failed
Deploy Website / deploy (push) Failing after 4m51s
2026-02-21 22:03:07 +01:00
0b78f15ef1 Feat: fixing some errors in the documentation 2026-02-21 18:23:31 +01:00
302f40c4ac Feat: better UI in the main view
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-21 16:21:29 +01:00
79a4c3b6e2 Feat: saving screen during perfs 2026-02-21 15:56:52 +01:00
12b90bc99b Feat: update CHANGELOG
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-21 15:07:03 +01:00
a1190af494 Feat: clean the codebase as much as possible
Some checks failed
Deploy Website / deploy (push) Failing after 4m51s
2026-02-21 14:46:53 +01:00
f85a20d9a7 Feat: make some stuff optional for the CLAP/VST version 2026-02-21 13:23:43 +01:00
baa2aba381 Clean plugins
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-21 01:27:32 +01:00
75a8fd4401 Trying to clena the mess opened by plugins
Some checks failed
Deploy Website / deploy (push) Failing after 4m53s
2026-02-21 01:03:55 +01:00
ac0ddc7fb9 WIP: rename to cagire-plugins 2026-02-20 22:31:13 +01:00
07e95d5b6f WIP: fix VST3 version 2026-02-20 22:26:35 +01:00
00d6eb2f1f WIP: clap 2026-02-20 22:14:21 +01:00
12752e0167 Cargo to github
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s
2026-02-19 16:51:39 +01:00
3b41a06d5e Feat: continue to improve documentation
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-17 00:51:56 +01:00
f258358c8f Feat: collapsible help 2026-02-16 23:43:25 +01:00
2d8abe4af9 Feat: documentation
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-16 23:19:06 +01:00
37f5f74ec1 Feat: refactoring codebase 2026-02-16 16:26:57 +01:00
58624b64cf Feat: refactoring codebase 2026-02-16 16:00:57 +01:00
5385bf675a Feat: fixing ratatui big-text and UX
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-16 15:43:22 +01:00
211e71f5a9 Feat: UI / UX 2026-02-16 01:22:40 +01:00
23c7abb145 Feat: improving MIDI 2026-02-15 19:06:49 +01:00
670ae0b6b6 Feat: lots of things, preparing for live gig
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-15 11:23:11 +01:00
10ca567ac5 Feat: early mouse support 2026-02-14 16:26:29 +01:00
b2871ac251 Feat: F1 F2 F3
Some checks failed
Deploy Website / deploy (push) Failing after 4m53s
2026-02-14 15:13:21 +01:00
8ba89f91a0 Fixes 2026-02-10 23:51:17 +01:00
7d670dacb9 Re-update cargo 2026-02-10 21:42:24 +01:00
1de8c068f6 Feat: all engine params use varargs and can eat the stack, document it as such 2026-02-10 19:41:59 +01:00
d792f011ee Feat: rescale spectrum 2026-02-10 19:32:51 +01:00
897f1a776e Feat: reverb words 2026-02-10 19:27:11 +01:00
869d3af244 Feat: entretien de la codebase 2026-02-09 21:12:49 +01:00
a5f17687f1 chore: Release 2026-02-08 13:57:52 +01:00
5b851751e5 Feat: update the CHANGELOG.md correctly 2026-02-08 13:57:25 +01:00
bc5d12e53a Feat: lots of improvements
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-08 13:52:40 +01:00
d6bbae173b Feat: improve website
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-08 02:57:41 +01:00
1f339f1503 Small corrections
Some checks failed
Deploy Website / deploy (push) Failing after 4m51s
2026-02-08 01:33:50 +01:00
8ffe2c22c7 Feat: comfort features 2026-02-08 00:46:56 +01:00
20c32ce0d8 Prepare v0.0.8 release
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-07 13:14:14 +01:00
a326d58d30 Feat: restore Cargo.toml to git version 2026-02-07 13:07:56 +01:00
c72733bac8 WIP: prepare the ground for audio rate modulation 2026-02-07 12:08:11 +01:00
5758b18d58 Feat: trying to get rid of some sequencer bugs 2026-02-07 01:24:38 +01:00
52cc890a67 Feat: website WIP and new words
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-06 16:19:09 +01:00
0f9d750069 Feat: trying to improve bundling and compilation 2026-02-06 00:46:40 +01:00
66ee2e28ff Words and universal macOS installer
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-06 00:37:08 +01:00
6ec3a86568 New themes 2026-02-06 00:19:16 +01:00
51f52be4ce Feat: optimizations 2026-02-05 23:15:46 +01:00
2c98a915fa Space on all views
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 18:57:09 +01:00
e42476dd4d Feat: rework audio sample library viewer 2026-02-05 18:37:32 +01:00
3e364a6622 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 15:56:52 +01:00
1248f74b25 Feat: update CHANGELOG.md 2026-02-05 15:56:27 +01:00
fc2ab0757b Feat: update CHANGELOG.md
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-05 14:36:12 +01:00
10ed5a629a Feat: background head-preload for sample libraries 2026-02-05 14:35:26 +01:00
88c2b51720 Feat: introduce Forth words for 3-OP Fm synthesis (with feedback)
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s
2026-02-05 12:00:00 +01:00
5cda1a8f95 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-05 01:40:51 +01:00
200832f230 Feat: update CHANGELOG.md before release 2026-02-05 01:40:06 +01:00
91bc9011b2 Feat: new euclidean words and sugar for floating point numbers
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 01:30:34 +01:00
de56598fca Feat: prelude and new words
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-05 00:58:53 +01:00
abafea8ddf Feat: refactoring by breaking words in multiple files
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-04 23:50:38 +01:00
e6f776bdf4 Feat: tri is now triangle (disambiguation) 2026-02-04 20:34:37 +01:00
d40d713649 Feat: really good lookahead mechanism for scheduling
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-04 20:28:42 +01:00
767575b25d Removing lookahead concept 2026-02-04 20:01:17 +01:00
82b0668bcf Some kind of refactoring 2026-02-04 19:35:30 +01:00
6cf9d2eec1 Ungoing refactoring 2026-02-04 18:47:40 +01:00
2097997372 Feat: tweak and fix from last night workshop
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-04 09:37:29 +01:00
5579708f69 Feat: add tachyonFX animations 2026-02-04 00:40:15 +01:00
1b01491e87 Fix: prevent 0 division error when loading project 2026-02-03 23:41:27 +01:00
5581ba1881 chore: Release 2026-02-03 17:03:58 +01:00
8983b3f21c Fix: dict popup in editor is less intrusive
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 17:02:07 +01:00
4a7ae83019 Fix: desktop build
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-03 16:00:26 +01:00
61a6d7aad0 Fix: simpler scheduling
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-03 15:55:43 +01:00
1b01e3b805 WIP: improve Linux audio support
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 14:42:03 +01:00
2a57cc415b Fix: JACK stuff
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 14:23:24 +01:00
7c76bdb8d6 clamp audio options
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-03 14:14:28 +01:00
1facc72a67 Fix Linux audio: enable JACK support and RT priority for audio callback
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-03 14:04:34 +01:00
726ea16e92 Wip 2026-02-03 13:52:36 +01:00
154cac6547 Again 2026-02-03 03:25:31 +01:00
3380e454df Again 2026-02-03 03:08:13 +01:00
660f48216a Still searching... 2026-02-03 02:53:34 +01:00
fb1f73ebd6 WIP: not sure 2026-02-03 02:31:55 +01:00
cd223592a7 Insane linux fixes
Some checks failed
Deploy Website / deploy (push) Failing after 4m45s
2026-02-03 01:15:07 +01:00
af81c94207 WIP: even more crazy linux optimizations
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 00:38:46 +01:00
b53e4a76ab WIP: optimizations for linux
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-03 00:16:31 +01:00
8c31ed4196 Another round of optimization
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 22:16:00 +01:00
8024c18bb0 Less memory allocations at runtime 2026-02-02 21:55:10 +01:00
194030d953 fixing linux stuff
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 19:26:01 +01:00
e4799c1f42 Merge branch 'main' of github.com:Bubobubobubobubo/cagire
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 19:12:37 +01:00
636129688d lookahead 2026-02-02 19:12:32 +01:00
a2ee0e5a50 Fix: Copy register handling for cagire-desktop (Linux) 2026-02-02 18:25:02 +01:00
96ed74c6fe Fix: CPAL version mismatch 2026-02-02 18:08:55 +01:00
a67d982fcd Pattern mute and so on 2026-02-02 16:27:11 +01:00
c9ab7a4f0b chore: Release 2026-02-02 13:44:47 +01:00
772d21a8ed Feat: update CHANGELOG.md
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 13:42:42 +01:00
4396147a8b Euclidean + hue rotation
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 13:25:27 +01:00
c396c39b6b Fix layout 2026-02-02 12:18:22 +01:00
f6b43cb021 Add double-stack words (2dup, 2drop, 2swap, 2over) and forget
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 07:46:39 +01:00
60d1d7ca74 Feat: update website to prevent ugliness
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:38:21 +01:00
9864cc6d61 Update changelog for v0.0.3 2026-02-02 01:12:49 +01:00
985ab687d7 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m15s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-02 01:09:13 +01:00
9b925d881e Feat: update changelog
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 01:08:33 +01:00
71146c7cea Feat: more predictable projet load behavior
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:01:01 +01:00
6b95f31afd Feat: polyphony + iterator reset
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 00:33:46 +01:00
adee8d0d57 Feat: adding some basic music theory
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-01 16:15:09 +01:00
f9c284effd Feat: adding logrand and exprand 2026-02-01 15:16:20 +01:00
57fd51be3e Fix release.toml format
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m21s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-01 14:05:55 +01:00
ce70251057 Feat: work on metadata and packaging
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-01 14:00:10 +01:00
b47c789612 Feat: continue refactoring
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-01 13:39:25 +01:00
dd853b8e1b Feat: begin slight refactoring
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-01 12:38:48 +01:00
a0585b0814 MIDI Documentation and optional mouse event support
Some checks failed
Deploy Website / deploy (push) Failing after 4m45s
2026-02-01 00:51:56 +01:00
2100b82dad More robust midi implementation
Some checks failed
Deploy Website / deploy (push) Failing after 4m58s
2026-01-31 23:58:57 +01:00
15a4300db5 better quality midi 2026-01-31 23:23:36 +01:00
fed39c01e8 Lots + MIDI implementation 2026-01-31 23:13:51 +01:00
0a4f1419eb Fix: continue to fix release build and CI 2026-01-31 19:58:21 +01:00
793c83e18c Fix: again CI breaks 2026-01-31 18:04:11 +01:00
20bc0ffcb4 Fixing builds and workflows 2026-01-31 17:52:44 +01:00
8e09fd106e Remove emit_n tests (feature not implemented) 2026-01-31 17:37:00 +01:00
73ca0ff096 Add Windows/Linux desktop bundles to CI 2026-01-31 17:24:41 +01:00
425f1c8627 CI build versions 2026-01-31 16:35:38 +01:00
730332cfb0 Work on documentation
Some checks failed
Deploy Website / deploy (push) Failing after 6s
2026-01-31 15:03:20 +01:00
1d70a83759 Work on documentation 2026-01-31 14:31:44 +01:00
0299012725 Work on documentation 2026-01-31 13:46:43 +01:00
08029ec604 Working on internal documentation
Some checks failed
Deploy Website / deploy (push) Failing after 7s
2026-01-31 02:41:05 +01:00
4f9b1f39f9 Write some amount of documentation 2026-01-31 01:46:18 +01:00
4772b02f77 Feat: fix scope / spectrum / vumeter
Some checks failed
Deploy Website / deploy (push) Failing after 6s
2026-01-30 21:50:00 +01:00
4049c7787c Feat: extend CI to cover desktop 2026-01-30 21:19:48 +01:00
4c635500dd Feat: extend CI to cover desktop 2026-01-30 20:34:34 +01:00
d0e37e13e6 Feat: README update
Some checks failed
Deploy Website / deploy (push) Failing after 6s
2026-01-30 20:28:43 +01:00
7658cf9d51 Feat: add icon and reorganize desktop.rs
Some checks failed
Deploy Website / deploy (push) Failing after 7s
2026-01-30 20:27:08 +01:00
584dbb6aad Fixing color schemes 2026-01-30 20:15:43 +01:00
2731eea037 Monster commit: native version 2026-01-30 15:03:49 +01:00
22ee5f97e6 More robust workflows for website deployment 2026-01-30 12:39:09 +01:00
5fb059ea20 Corrections
Some checks failed
Deploy Website / deploy (push) Failing after 30s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 24m12s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-01-30 12:27:27 +01:00
705d93702b Deplyment 2026-01-30 12:13:38 +01:00
77a6aa9eb7 Feat: ability to rename steps 2026-01-30 11:58:16 +01:00
d25b1317fc WIP: words for wavetable synthesis 2026-01-30 01:55:40 +01:00
2851785e0d WIP: consolidate sampling 2026-01-30 00:04:25 +01:00
a72772c8cc WIP: better precision? 2026-01-29 18:50:54 +01:00
4d22bd5d2b Remi 2026-01-29 12:17:09 +01:00
495bfb3bdc Try to optimize 2026-01-29 11:53:47 +01:00
73db616139 WIP simplify 2026-01-29 09:38:41 +01:00
8efafffaff Cleaning old temporal model 2026-01-29 01:28:57 +01:00
48f5920fed Cleaning language 2026-01-29 01:10:53 +01:00
d106711708 Before going crazy 2026-01-28 18:05:50 +01:00
2be15d11f4 Mixed bag of things 2026-01-28 17:39:41 +01:00
5952807240 wip 2026-01-28 13:54:29 +01:00
0beed16c31 Help modal 2026-01-28 13:22:51 +01:00
c6860105a6 vastly improved selection system 2026-01-28 02:29:17 +01:00
f4eafdf5b2 A ton of bug fixes 2026-01-28 01:09:23 +01:00
935df84920 ok 2026-01-27 15:23:04 +01:00
a3a39ea28e Fixing subtle bugs 2026-01-27 13:40:52 +01:00
574625735b Feat: parameter duration scaling 2026-01-27 12:17:23 +01:00
40c509e295 cleaning 2026-01-27 12:00:34 +01:00
61daa9d79d big commit 2026-01-27 01:04:08 +01:00
9e597258e4 WIP 2026-01-26 12:22:44 +01:00
223679acf8 So much better 2026-01-26 02:24:04 +01:00
2235a4b0a1 Basic search mechanism in editor 2026-01-26 01:25:40 +01:00
2453b78237 Looks better now 2026-01-26 01:02:18 +01:00
fcb6adb6af ok 2026-01-26 00:24:17 +01:00
ce98acacd0 Wip: refacto 2026-01-25 22:17:08 +01:00
d2d6ef5b06 broken 2026-01-25 21:44:08 +01:00
6efcabd32d WIP: menu 2026-01-25 21:37:53 +01:00
250e359fc5 scales 2026-01-25 20:43:12 +01:00
cf5994e604 Loop word 2026-01-24 12:47:19 +01:00
e1aff189cd Flash 2026-01-24 02:16:18 +01:00
b3c56bc56c WIP: half broken 2026-01-24 01:59:51 +01:00
3bb19cbda8 chain word and better save/load UI 2026-01-23 23:36:23 +01:00
42ad77d9ae Reorganize repository 2026-01-23 20:29:44 +01:00
e853e67492 Break down forth implementation properly 2026-01-23 19:36:40 +01:00
f7e6f96cbf words definition 2026-01-23 11:15:15 +01:00
8af64fc4e2 trace 2026-01-23 10:37:48 +01:00
183dd5b516 spectrum 2026-01-23 01:42:07 +01:00
350 changed files with 86355 additions and 264747 deletions

10
.cargo/config.toml Normal file
View File

@@ -0,0 +1,10 @@
# Uncomment to use local doux for development
paths = ["/Users/bubo/doux"]
[alias]
xtask = "run --package xtask --release --"
[target.x86_64-pc-windows-gnu]
rustflags = [
"-C", "link-args=-Wl,-Bstatic -lstdc++ -lgcc -lgcc_eh -lpthread -Wl,-Bdynamic -lmingwex -lmsvcrt -lws2_32 -liphlpapi -lwinmm -lole32 -loleaut32 -luuid -lkernel32",
]

75
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: brew list cmake &>/dev/null || brew install cmake
- name: Install dependencies (Windows)
if: runner.os == 'Windows'
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Test
run: cargo test --target ${{ matrix.target }}
- name: Clippy
run: cargo clippy --target ${{ matrix.target }} -- -D warnings

59
.github/workflows/pages.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Deploy Website
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
if: github.server_url == 'https://github.com'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
working-directory: website
- name: Build
run: pnpm build
working-directory: website
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/dist
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

408
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,408 @@
name: Release
on:
workflow_dispatch:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.server_url == 'https://github.com'
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: cagire-linux-x86_64
- os: macos-15-intel
target: x86_64-apple-darwin
artifact: cagire-macos-x86_64
- os: macos-14
target: aarch64-apple-darwin
artifact: cagire-macos-aarch64
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: cagire-windows-x86_64
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@main
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
cargo binstall -y cargo-bundle
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew list cmake &>/dev/null || brew install cmake
cargo binstall -y cargo-bundle
- name: Install dependencies (Windows)
if: runner.os == 'Windows'
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Bundle desktop app
if: runner.os != 'Windows'
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Build AppImages (Linux)
if: runner.os == 'Linux'
run: |
mkdir -p target/releases
scripts/make-appimage.sh target/${{ matrix.target }}/release/cagire x86_64 target/releases
scripts/make-appimage.sh target/${{ matrix.target }}/release/cagire-desktop x86_64 target/releases
- name: Upload AppImage artifacts (Linux)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-appimage
path: target/releases/*.AppImage
- name: Bundle CLAP plugin
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Zip macOS app bundle
if: runner.os == 'macOS'
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload artifact (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire.exe
- name: Upload desktop artifact (Linux deb)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
- name: Upload desktop artifact (macOS app bundle)
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Upload desktop artifact (Windows exe)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop.exe
- name: Install cargo-wix (Windows)
if: runner.os == 'Windows'
run: cargo install cargo-wix
- name: Build MSI installer (Windows)
if: runner.os == 'Windows'
run: cargo wix --no-build --nocapture -C -p -C x64
- name: Upload MSI installer (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-msi
path: target/wix/*.msi
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: target/bundled/cagire-plugins.clap
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: target/bundled/cagire-plugins.vst3
build-cross:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-unknown-linux-gnu
artifact: cagire-linux-aarch64
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build
run: cross build --release --target ${{ matrix.target }}
- name: Build desktop
run: cross build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload desktop artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop
universal-macos:
if: github.server_url == 'https://github.com'
needs: build
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
- name: Create universal CLI binary
run: |
lipo -create \
artifacts/cagire-macos-x86_64/cagire \
artifacts/cagire-macos-aarch64/cagire \
-output cagire
chmod +x cagire
lipo -info cagire
- name: Create universal app bundle
run: |
cd artifacts/cagire-macos-aarch64-desktop
unzip Cagire.app.zip
cd ../cagire-macos-x86_64-desktop
unzip Cagire.app.zip
cd ../..
cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app
lipo -create \
artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
-output Cagire.app/Contents/MacOS/cagire-desktop
lipo -info Cagire.app/Contents/MacOS/cagire-desktop
zip -r Cagire.app.zip Cagire.app
- name: Create universal CLAP plugin
run: |
mkdir -p cagire-plugins.clap/Contents/MacOS
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/Info.plist \
cagire-plugins.clap/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/PkgInfo \
cagire-plugins.clap/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
-output cagire-plugins.clap/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.clap/Contents/MacOS/cagire-plugins
- name: Create universal VST3 plugin
run: |
mkdir -p cagire-plugins.vst3/Contents/MacOS
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Info.plist \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/PkgInfo \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Resources \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
-output cagire-plugins.vst3/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.vst3/Contents/MacOS/cagire-plugins
- uses: actions/checkout@v4
with:
sparse-checkout: |
assets/DMG-README.txt
scripts/make-dmg.sh
clean: false
- name: Create DMG
run: |
chmod +x scripts/make-dmg.sh
scripts/make-dmg.sh Cagire.app .
- name: Build .pkg installer
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/
pkgbuild --analyze --root pkg-root component.plist
plutil -replace BundleIsRelocatable -bool NO component.plist
pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal
path: cagire
- name: Upload universal app bundle
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-desktop
path: Cagire.app.zip
- name: Upload universal CLAP plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-clap
path: cagire-plugins.clap
- name: Upload universal VST3 plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-vst3
path: cagire-plugins.vst3
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-dmg
path: Cagire-*.dmg
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg
release:
needs: [build, build-cross, universal-macos]
if: startsWith(github.ref, 'refs/tags/v') && github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [[ "$name" == "cagire-macos-universal-dmg" ]]; then
cp "$dir"/*.dmg release/
elif [[ "$name" == "cagire-macos-universal-pkg" ]]; then
cp "$dir"/*.pkg release/
elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then
cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip"
elif [[ "$name" == "cagire-macos-universal" ]]; then
cp "$dir/cagire" "release/cagire-macos-universal"
elif [[ "$name" == "cagire-macos-universal-clap" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == "cagire-macos-universal-vst3" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-clap ]]; then
base="${name%-clap}"
cd "$dir" && zip -r "../../release/${base}-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == *-vst3 ]]; then
base="${name%-vst3}"
cd "$dir" && zip -r "../../release/${base}-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-msi ]]; then
cp "$dir"/*.msi release/
elif [[ "$name" == *-appimage ]]; then
cp "$dir"/*.AppImage release/
elif [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"
elif [ -f "$dir/Cagire.app.zip" ]; then
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
elif [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
fi
else
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
fi
fi
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true

9
.gitignore vendored
View File

@@ -1,4 +1,11 @@
/target
Cargo.lock
/.cache
*.prof
.DS_Store
# Local cargo overrides (doux path patch)
.cargo/config.local.toml
# Claude
.claude/
CLAUDE.md

187
BUILDING.md Normal file
View File

@@ -0,0 +1,187 @@
# Building Cagire
## Quick Start
```bash
git clone --recursive https://github.com/Bubobubobubobubo/cagire
cd cagire
cargo build --release
```
The `doux` audio engine is fetched automatically from git. No local path setup needed.
## Prerequisites
**Rust** (stable toolchain): https://rustup.rs
## System Dependencies
### macOS
```bash
brew install cmake
```
cmake is required by `rusty_link` (Ableton Link C++ bindings). Xcode Command Line Tools provide the C++ compiler. CoreAudio and CoreMIDI are built-in. The desktop build needs no additional dependencies on macOS (Cocoa/Metal are provided by the system).
### Linux (Debian/Ubuntu)
```bash
sudo apt install cmake g++ pkg-config libasound2-dev libjack-jackd2-dev
```
For the desktop build (egui/eframe), also install:
```bash
sudo apt install libgl-dev libxkbcommon-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
```
### Linux (Arch)
```bash
sudo pacman -S cmake gcc pkgconf alsa-lib jack2
```
For the desktop build:
```bash
sudo pacman -S libxkbcommon libx11 libxcursor libxrandr libxi wayland mesa
```
### Linux (Fedora)
```bash
sudo dnf install cmake gcc-c++ pkgconf-pkg-config alsa-lib-devel jack-audio-connection-kit-devel
```
For the desktop build:
```bash
sudo dnf install libxkbcommon-devel libX11-devel libXcursor-devel libXrandr-devel libXi-devel wayland-devel mesa-libGL-devel
```
### Windows
Install Visual Studio Build Tools (MSVC) and CMake. Everything else is provided by the Windows SDK.
## Build
Terminal (default):
```bash
cargo build --release
```
Desktop (egui window):
```bash
cargo build --release --features desktop --bin cagire-desktop
```
Plugins (CLAP/VST3):
```bash
cargo xtask bundle cagire-plugins --release
```
The xtask alias is defined in `.cargo/config.toml` (committed). Plugin bundles are output to `target/bundled/`.
## Run
Terminal (default):
```bash
cargo run --release -- [OPTIONS]
```
Desktop (egui window):
```bash
cargo run --release --features desktop --bin cagire-desktop
```
| Flag | Description |
|------|-------------|
| `-s, --samples <path>` | Sample directory (repeatable) |
| `-o, --output <device>` | Output audio device |
| `-i, --input <device>` | Input audio device |
| `-c, --channels <n>` | Output channel count |
| `-b, --buffer <size>` | Audio buffer size |
## Cross-Compilation
[cross](https://github.com/cross-rs/cross) uses Docker to build for other platforms without installing their toolchains locally. It works on any OS that runs Docker.
### Targets
| Target | Method | Binaries |
|--------|--------|----------|
| aarch64-apple-darwin | Native (macOS ARM only) | `cagire`, `cagire-desktop` |
| x86_64-apple-darwin | Native (macOS only) | `cagire`, `cagire-desktop` |
| x86_64-unknown-linux-gnu | `cross build` | `cagire`, `cagire-desktop` |
| aarch64-unknown-linux-gnu (RPi 64-bit) | `cross build` | `cagire`, `cagire-desktop` |
| x86_64-pc-windows-gnu | `cross build` | `cagire`, `cagire-desktop` |
macOS targets can only be built on macOS — Apple does not support cross-compilation to macOS from other platforms. Linux and Windows targets can be cross-compiled from any OS. The aarch64-unknown-linux-gnu target covers Raspberry Pi (64-bit OS).
### Windows ABI
CI produces `x86_64-pc-windows-msvc` binaries (native Windows build, better compatibility). Local cross-compilation from non-Windows hosts produces `x86_64-pc-windows-gnu` binaries (MinGW via Docker). Both work; MSVC is preferred for releases.
### Prerequisites
1. **Docker**: https://docs.docker.com/get-docker/
2. **cross**: `cargo install cross --git https://github.com/cross-rs/cross`
3. On macOS, add the Intel target: `rustup target add x86_64-apple-darwin`
Docker must be running before invoking `cross` or `scripts/build-all.sh`.
### Building Individual Targets
```bash
# Linux x86_64
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
# Linux aarch64
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target aarch64-unknown-linux-gnu
# Windows x86_64
cross build --release --target x86_64-pc-windows-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-gnu
```
### Building All Targets (macOS only)
```bash
# Interactive (prompts for platform/target selection):
scripts/build-all.sh
# Non-interactive:
scripts/build-all.sh --platforms macos-arm64,linux-x86_64 --targets cli,desktop --yes
scripts/build-all.sh --all --yes
```
Builds selected targets, producing binaries in `target/releases/`.
Platform aliases: `macos-arm64`, `macos-x86_64`, `linux-x86_64`, `linux-aarch64`, `windows-x86_64`.
Target aliases: `cli`, `desktop`, `plugins`.
### Linux AppImage Packaging
Linux releases ship as AppImages — self-contained executables that bundle all shared library dependencies (ALSA, JACK, X11, OpenGL). No runtime dependencies required.
After building a Linux target, produce an AppImage with:
```bash
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire x86_64 target/releases
```
`scripts/build-all.sh` does this automatically for every Linux target selected. The CI pipeline produces AppImages for the x86_64 Linux build. Cross-arch AppImage building (e.g. aarch64 on x86_64) is not supported — run on a matching host or in CI.
### Notes
- Custom Dockerfiles in `cross/` install the native libraries Cagire depends on (ALSA, JACK, X11, cmake, libclang, etc.). `Cross.toml` maps each target to its Dockerfile.
- The first build per target downloads Docker base images and installs packages. Subsequent builds use cached layers.
- Cross-architecture Docker builds (e.g. aarch64 on x86_64 or vice versa) run under QEMU emulation and are significantly slower.

266
CHANGELOG.md Normal file
View File

@@ -0,0 +1,266 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.0]
### Forth Language
**Bracket syntax `[ ... ]`**
- `[ v1 v2 v3 ]` pushes all items plus their count. Sugar for `v1 v2 v3 3`.
**New words:**
- `index` — select item at explicit index (wraps with modulo).
- `pbounce` — ping-pong cycle keyed by pattern iteration (vs `bounce` which is step-keyed).
- `except` — inverse of `every`: run quotation on all iterations except every nth.
- `every+` / `except+``every`/`except` with a phase offset.
- `all` / `noall` — apply current params globally to all emitted sounds; clear global params.
- `linmap` / `expmap` — linear and exponential range mapping.
- `rec` / `overdub` (`dub`) — toggle recording/overdubbing master audio to a named sample.
- `orec` / `odub` — toggle recording/overdubbing a single orbit to a named sample.
**Harmony and voicing words:**
- `key!` — set tonal center for scale operations.
- `triad` / `seventh` — diatonic triad/seventh from scale degree (follows a scale word).
- `inv` / `dinv` — chord inversion / down inversion.
- `drop2` / `drop3` — drop-2 / drop-3 voicings.
- `tp` — transpose all ints on stack by N semitones.
**New chord types:**
- `pwr`, `augmaj7`, `7sus4`, `9sus4`, `maj69`, `min69`, `maj11`, `maj13`, `min13`, `dom7s11`.
**Ducking compressor params:**
- `comp`, `compattack`/`cattack`, `comprelease`/`crelease`, `comporbit`/`corbit`.
### Engine
- SF2 soundfont support: auto-scans sample directories for `.sf2` files and loads them.
- Audio stream errors surfaced as flash messages instead of printing to stderr.
### UI / Visualization
- Lissajous XY scope: stereo phase display using Braille characters, togglable via Options.
- Gain boost (1x16x) and normalize toggle for scope/lissajous/spectrum.
- Pattern description field: editable via `d` on Patterns page, shown in pattern row and properties.
- Mute/solo on main page now apply immediately (no staging).
- 10 bundled demo projects loaded on fresh startup (togglable in Options).
### Themes
- 5 new themes: Iceberg, Everforest, Fauve, Tropicalia, Jaipur.
### Desktop (egui)
- Fixed Alt/Option key on macOS (dead-key composition now works).
- Fixed multi-character text paste.
- Extended function key support (F13F20).
### Fixed
- CycleList + ArpList index collision: arp uses timing index, cycle uses polyphony slot.
- Scope widget not drawing completely in some terminal sizes.
### Documentation
- New tutorials: Recording (`docs/tutorials/recording.md`), Soundfonts (`docs/tutorials/soundfont.md`).
### UI / UX (breaking cosmetic changes)
- **Options page**: Each option now shows a short description line below when focused, replacing the static header box.
- **Dictionary page**: Removed the Forth description box at the top. The word list now uses the full page height.
### CLAP Plugin (experimental)
- Early CLAP plugin support via nih-plug, baseview, and egui. Feature-gated builds separate CLI from plugin targets.
### Forth Language
- Removed `chain` word (replaced by pattern-level Follow Up setting).
- `case/of/endof/endcase` control flow for pattern-matching dispatch.
- `bjork` / `pbjork` — euclidean rhythm gates using quotations: execute a block only on Bjorklund-distributed hits.
- `arp` — arpeggio list type that spreads notes across time positions instead of stacking them simultaneously.
- `,varname` assignment syntax (SetKeep): assign to a variable without consuming the value from the stack.
- `every` reworked to accept quotations for cleaner conditional step logic.
- All parameter words now accept varargs — over 100 words updated to consume the full stack.
- Reverb parameter words added.
### Engine
- Follow-up actions: patterns now have a configurable follow-up behavior (Loop, Stop, or Chain to another pattern). Replaces the Forth `chain` word with a declarative setting in the Pattern Properties modal (`e` key). Chain targets specify bank and pattern via UI fields.
- Delta-time MIDI scheduling for tighter, sample-accurate timing.
- Tempo and current beat exposed in sequencer snapshot.
- Spectrum analyzer rescaling.
### UI / UX
- Patterns view redesign: new layout with banks column (showing pattern counts), expandable detail rows for the focused pattern (quantization, sync mode, progress bar), and a bottom preview strip with mini step grid and pattern properties.
- Smooth playback progress: playing patterns display a real-time progress bar interpolated between steps.
- Dynamic step grid sizing: `steps_per_page` adapts to terminal height instead of using a fixed constant.
- Mouse support: click navigation on the pattern grid, panels, and modals.
- F1F6 page navigation across the 3×2 page grid.
- Collapsible help sections with code block copy.
- Onboarding system for first-time users.
- New reusable widgets: CategoryList, HintBar, PropsForm, ScrollIndicators, SearchBar, SectionHeader.
- Show/hide preview pane toggle and zoom factor setting.
### Documentation
- Complete reorganization into `docs/` subdirectories.
- 10 getting-started guides, 5 interactive tutorials.
- New topics: control flow, generators, harmony, randomness, variables, timing.
### Theme System
- Palette-based generation: all 18 themes now derived from a 14-field Palette via Oklab color space.
- Theme definitions reduced from ~300 lines each to ~20 lines.
### Codebase
- `src/app.rs` split into 10 focused modules (dispatch, clipboard, editing, navigation, persistence, scripting, sequencer, staging, undo).
- `src/input.rs` split into 8 page-specific handlers.
- Undo/redo system with scope-based tracking.
- Feature-gated CLI vs plugin builds.
## [0.0.9]
### Website
- Compressed screenshot images: resized to 1600px and converted PNG to WebP (8MB → 538KB).
- Version number displayed in subtitle, read automatically from `Cargo.toml` at build time.
### Added
- `arp` word for arpeggios: wraps stack values into an arpeggio list that spreads notes across time positions instead of playing them all simultaneously. With explicit `at` deltas, arp items zip with deltas (cycling the shorter list); without `at`, the step is auto-subdivided evenly. Example: `sine s c4 e4 g4 b4 arp note .` plays a 4-note arpeggio across the step.
- Resolved value annotations: nondeterministic words (`rand`, `choose`, `cycle`, `bounce`, `wchoose`, `coin`, `chance`, `prob`, `exprand`, `logrand`) now display their resolved value inline (e.g., `choose [sine]`, `rand [7]`, `chance [yes]`) during playback in both Preview and Editor modals.
- Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion.
- Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`).
### Improved
- Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections.
- CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+.
- Extracted 6 reusable TUI components into `cagire-ratatui`: `CategoryList`, `render_scroll_indicators`, `render_search_bar`, `render_section_header`, `render_props_form`, `hint_line`. Reduces duplication across views.
### Fixed
- Soundless emits (e.g., `1 gain .`) no longer stack infinite voices. All emitted commands now receive a default duration of one beat unless the user explicitly sets `dur`. Use `0 dur` for intentionally infinite voices.
## [0.0.8] - 2026-02-07
### Fixed
- macOS `.pkg` installer bundle relocation: disabled `BundleIsRelocatable` so `Cagire.app` always installs to `/Applications/` instead of being redirected to an existing bundle location.
### Added
- Syntax highlighting for user-defined Forth words: words created with `: name ... ;` now render with a distinct color in both the editor and step preview, instead of dimmed gray.
- Multi-selection in Patterns view: Shift+Up/Down selects adjacent ranges of banks or patterns using anchor-based selection. Works with copy/paste (Ctrl+C/V), reset (Delete), toggle play (`p`), mute (`m`), and solo (`x`). Selection is column-scoped and clears on plain arrows, column switch, or Esc. Single-only actions (rename, pattern props, enter) are disabled during multi-selection.
- Audio-rate modulation DSL: LFO words (`lfo`, `tlfo`, `wlfo`, `qlfo` for sine/triangle/sawtooth/square), transition envelopes (`slide`, `expslide`, `sslide` for linear/exponential/smooth), random modulation (`jit`, `sjit`, `drunk` for random hold/smooth random/drunk walk), and multi-segment envelope (`env`). These produce modulation strings consumed by parameter words for continuous audio-rate control.
- Feedback delay FX words: `feedback`/`fb` (level), `fbtime`/`fbt` (delay time), `fbdamp`/`fbd` (damping), `fblfo` (LFO rate), `fblfodepth` (LFO depth), `fblfoshape` (LFO shape).
- `bounce` word: ping-pong cycle through n items by step runs (e.g., `60 64 67 72 4 bounce` → 60 64 67 72 67 64 60 64...).
- `wchoose` word: weighted random selection from n value/weight pairs (e.g., `60 0.6 64 0.3 67 0.1 3 wchoose`). Supports quotations.
- New themes: **Eden** (dark forest — green-only palette on black), **Georges** (Commodore 64 palette on black), **Ember** (warm dark tones), **Letz Light** (light theme).
- Proper desktop app icon and metadata across all platforms: moved icon to `assets/Cagire.png`, added Windows `.exe` icon and file properties embedding via `winres` build script, added PNG to cargo-bundle icon list for Linux `.deb` packaging.
- Universal macOS `.pkg` installer in CI: combines Intel and Apple Silicon builds into fat binaries via `lipo`, then packages `Cagire.app` and CLI into a single `.pkg` installer.
- Waveform rendering widget in the TUI.
### Improved
- Sample library browser: search now shows folder names only (no files) while typing, sorted by fuzzy match score. After confirming search with Enter, folders can be expanded and collapsed normally. Esc clears the search filter before closing the panel. Left arrow on a file collapses the parent folder. Cursor and scroll position stay valid after expand/collapse operations.
- RAM optimizations saving ~5 MB at startup plus smaller enums and fewer hot-path allocations:
- Removed dead `Step::command` field (~3.1 MB)
- Narrowed `Step::source` from `Option<usize>` to `Option<u8>` (~1.8 MB)
- `Op::SetParam` and `Op::GetContext` now use `&'static str` instead of `String`
- `SourceSpan` fields narrowed from `usize` to `u32`
- Dirty pattern tracking uses fixed `[[bool; 32]; 32]` array instead of `HashSet`
- Boxed `FileBrowserState` in `Modal` enum to shrink all variants
- `StepContext::cc_access` borrows instead of cloning `Arc<dyn CcAccess>`
- Removed unnecessary `Arc` wrapper from `Stack` type
- Variable key cache computes on-demand with reusable buffers instead of pre-allocating 2048 Strings
- Render pipeline: background fill uses `Clear` widget instead of generating blank paragraph lines.
### Fixed
- Sequencer sync: auto-loaded patterns now use PhaseLock instead of Reset, so they align to the global beat grid and stay in sync with manually-started patterns.
- PhaseLock off-by-one: start step calculation now uses the frontier beat instead of the lookahead end, eliminating a systematic 1-step offset.
- Stale pattern cache on load: dirty patterns are now flushed before queued start/stop changes, ensuring pattern data arrives before activation.
- Loading while paused no longer drops auto-started patterns; pending starts are preserved and activate on resume.
### Changed
- Header bar is now always 3 lines tall with vertically centered content and full-height background colors, replacing the previous 1-or-2-line width-dependent layout.
- Help view Welcome page: BigText title is now gated behind `cfg(not(feature = "desktop"))`, falling back to a plain text title in the desktop build (same strategy as the splash screen).
- Space now toggles play/pause on all views, including the Patterns page where it previously toggled pattern play. Pattern play on the Patterns page is now bound to `p`.
## [0.0.7] - 2026-05-02
### Added
- 3-operator FM synthesis words: `fm2` (operator 2 depth), `fm2h` (operator 2 harmonic ratio), `fmalgo` (algorithm: 0=cascade, 1=parallel, 2=branch), `fmfb` (feedback amount). Extends the existing 2-OP FM engine to a full 3-OP architecture with configurable routing and operator feedback.
- Background head-preload for sample libraries. At startup, a background thread decodes the first 4096 frames (~93ms) of every sample into RAM. Short samples (most percussion/drums) are fully captured and play instantly on first trigger. Eliminates first-hit misses for live performance.
- Most changes are on doux side. It makes sense to recompile and release now to ship a version that comes with these improvements.
### Fixed
- Code editor now scrolls vertically to keep the cursor visible. Previously, lines beyond the visible area were clipped and the cursor could move off-screen.
## [0.0.6] - 2026-05-02
### Added
- TachyonFX based animations
- Prelude: project-level Forth script for persistent word definitions. Press `d` to edit, `D` to re-evaluate. Runs automatically on playback start and project load.
- Varargs stack words: `rev`, `shuffle`, `sort` (ascending), `rsort` (descending), `sum`, `prod`. All take a count and operate on the top n items.
- Euclidean rhythm words: `euclid` (k n -- hits) distributes k hits across n steps, `euclidrot` (k n r -- hits) adds rotation offset.
- Shorthand float syntax: `.25` parses as `0.25`, `-.5` parses as `-0.5`.
### Changed
- Split `words.rs` (3,078 lines) into a `words/` directory module with category-based files: `core.rs`, `sound.rs`, `effects.rs`, `sequencing.rs`, `music.rs`, `midi.rs`, plus `compile.rs` and `mod.rs`.
- Renamed `tri` Forth word to `triangle`.
- Sequencer rewritten with prospective lookahead scheduling. Instead of sleeping until a substep, waking late, and detecting past events, the sequencer now pre-computes all events within a ~20ms forward window. Events arrive at doux with positive time deltas, scheduled before they need to fire. Sleep+spin-wait replaced by `recv_timeout(3ms)` on the command channel. Timing no longer depends on OS sleep precision.
- `audio_sample_pos` updated at buffer start instead of end, so `engine_time` reflects current playback position.
- Doux grace period increased from 1ms to 50ms as a safety net (events should never be late with lookahead).
- Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`.
- Hue rotation step size increased from 1° to 5° for faster adjustment.
- Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion.
- Extracted shared initialization into `src/init.rs`, deduplicating ~140 lines between terminal and desktop binaries.
- Split App dispatch into focused service modules (`help_nav`, `dict_nav`, `euclidean`, `clipboard`, extended `pattern_editor`), reducing `app.rs` by ~310 lines.
- Moved stack preview computation from render path to input time, making editor rendering pure.
- Decoupled script runtime state between UI and sequencer threads, eliminating shared mutexes on the RT path.
### Fixed
- Prelude content no longer leaks into step editor. Closing the prelude editor now restores the current step's content to the buffer.
- Desktop binary now loads color theme and connects MIDI devices on startup (was missing).
- Audio commands no longer silently dropped when channel is full; switched to unbounded channel matching MIDI dispatch pattern.
- PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default.
- Changing pattern properties is now a stage/commit operation.
- Changing pattern speed only happens at pattern boundaries.
- `mlockall` warning no longer appears on macOS; memory locking is now Linux-only.
- `clear` now resets `at` deltas, so subsequent emits default to a single emit at position 0.
## [0.0.5] - 2026-04-02
### Added
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
- Realtime thread scheduling (`SCHED_FIFO`) for sequencer thread on Unix systems, improving timing reliability.
- Deep into the Linux hellscape: trying to get reliable performance, better stability, etc.
### Fixed
- Editor completion popup no longer steals arrow keys. Arrow keys always move the cursor; use Ctrl+N/Ctrl+P to navigate the completion list.
## [0.0.4] - 2026-02-02
### Added
- Double-stack words: `2dup`, `2drop`, `2swap`, `2over`.
- `forget` word to remove user-defined words from the dictionary.
- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position.
- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement.
- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms.
- Fairyfloss theme (pastel candy colors by sailorhg).
- Hot Dog Stand theme (classic Windows 3.1 red/yellow).
- Hue rotation option in Options menu to shift all theme colors (0-360°).
### Changed
- Title view now adapts to smaller terminal sizes gracefully.
### Fixed
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
- Updated `cpal` dependency from 0.15 to 0.17 to fix type mismatch with `doux` audio backend.
- Copy/paste (Ctrl+C/V/X) not working in desktop version due to egui intercepting clipboard shortcuts.
## [0.0.3] - 2026-02-02
### Added
- Polyphonic parameters: param words (`note`, `freq`, `gain`, etc.) and sound words now consume the entire stack, enabling polyphony (e.g., `60 64 67 note sine s .` emits 3 voices).
- New random distribution words: `exprand` (exponential) and `logrand` (logarithmic).
- Music theory chord words: `maj`, `m`, `dim`, `aug`, `sus2`, `sus4`, `maj7`, `min7`, `dom7`, `dim7`, `m7b5`, `minmaj7`, `aug7`, `maj6`, `min6`, `dom9`, `maj9`, `min9`, `dom11`, `min11`, `dom13`, `add9`, `add11`, `madd9`, `dom7b9`, `dom7s9`, `dom7b5`, `dom7s5`.
- Playing patterns are now saved with the project and restored on load.
### Changed
- `at` now consumes the entire stack for time offsets; polyphony multiplies with deltas (2 notes × 2 times = 4 voices).
- Iterator (`iter`) now resets when a pattern restarts.
- Project loading now properly resets state: stops all patterns, clears user variables/dictionary, and clears queued changes.
### Removed
- `tcycle` word (replaced by polyphonic parameter behavior).
## [0.0.2] - 2026-02-01
- CI testing and codebase cleanup
## [0.0.1] - Initial Release
- CI testing

7363
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,24 @@
[workspace]
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"]
[workspace.package]
version = "0.0.9"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"
repository = "https://github.com/Bubobubobubobubo/cagire"
homepage = "https://cagire.raphaelforment.fr"
description = "Forth-based live coding music sequencer"
[package]
name = "cagire"
version = "0.1.0"
edition = "2021"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
description.workspace = true
[lib]
name = "cagire"
@@ -11,20 +28,84 @@ path = "src/lib.rs"
name = "cagire"
path = "src/main.rs"
[dependencies]
doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] }
rusty_link = "0.4"
ratatui = "0.29"
crossterm = "0.28"
cpal = "0.15"
clap = { version = "4", features = ["derive"] }
[[bin]]
name = "cagire-desktop"
path = "src/bin/desktop/main.rs"
required-features = ["desktop"]
[features]
default = ["cli"]
cli = ["dep:cpal", "dep:midir", "dep:confy", "dep:clap", "dep:thread-priority"]
block-renderer = ["dep:soft_ratatui", "dep:rustc-hash", "dep:egui"]
desktop = [
"cli",
"block-renderer",
"cagire-forth/desktop",
"dep:eframe",
"dep:egui_ratatui",
"dep:image",
]
[dependencies]
cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/sova-org/doux", features = ["native", "soundfont"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
cpal = { version = "0.17", features = ["jack"], optional = true }
clap = { version = "4", features = ["derive"], optional = true }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tui-textarea = "0.7"
tui-big-text = "0.7"
tachyonfx = { version = "0.22", features = ["std-duration"] }
tui-big-text = "0.8"
arboard = "3"
minimad = "0.13"
crossbeam-channel = "0.5"
confy = "2"
confy = { version = "2", optional = true }
rustfft = "6"
thread-priority = { version = "1", optional = true }
ringbuf = "0.4"
arc-swap = "1"
midir = { version = "0.10", optional = true }
parking_lot = "0.12"
libc = "0.2"
# Desktop-only dependencies (behind feature flag)
egui = { version = "0.33", optional = true }
eframe = { version = "0.33", optional = true }
egui_ratatui = { version = "2.1", optional = true }
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true }
rustc-hash = { version = "2", optional = true }
image = { version = "0.25", default-features = false, features = ["png"], optional = true }
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[patch."https://github.com/robbert-vdh/nih-plug"]
nih_plug_egui = { path = "plugins/nih-plug-egui" }
[patch."https://github.com/BillyDM/egui-baseview.git"]
egui-baseview = { path = "plugins/egui-baseview" }
[patch."https://github.com/RustAudio/baseview.git"]
baseview = { path = "plugins/baseview" }
[package.metadata.bundle.bin.cagire-desktop]
name = "Cagire"
identifier = "com.sova.cagire"
icon = ["assets/Cagire.icns", "assets/Cagire.ico", "assets/Cagire.png"]
copyright = "Copyright (c) 2025 Raphaël Forment"
category = "Music"
short_description = "Forth-based music sequencer"

8
Cross.toml Normal file
View File

@@ -0,0 +1,8 @@
[target.aarch64-unknown-linux-gnu]
dockerfile = "./scripts/cross/aarch64-linux.Dockerfile"
[target.x86_64-unknown-linux-gnu]
dockerfile = "./scripts/cross/x86_64-linux.Dockerfile"
[target.x86_64-pc-windows-gnu]
dockerfile = "./scripts/cross/x86_64-windows.Dockerfile"

View File

@@ -1,19 +1,37 @@
# Cagire
<h1 align="center">Cagire</h1>
A Forth Music Sequencer.
<p align="center"><em>A Forth Music Sequencer</em></p>
<p align="center">
<img src="cagire_pixel.png" alt="Cagire" width="256">
</p>
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events.
## Build
Terminal version:
```
cargo build --release
```
Desktop version (with egui window):
```
cargo build --release --features desktop --bin cagire-desktop
```
## Run
Terminal version:
```
cargo run --release
```
Desktop version:
```
cargo run --release --features desktop --bin cagire-desktop
```
## License
AGPL-3.0

BIN
assets/Cagire.icns Normal file

Binary file not shown.

BIN
assets/Cagire.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
assets/Cagire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

19
assets/DMG-README.txt Normal file
View File

@@ -0,0 +1,19 @@
Cagire - A Forth-based music sequencer
Made by BuboBubo and his friends
======================================
Installation
------------
Drag Cagire.app into the Applications folder.
Unquarantine
------------
Since this app is not signed with an Apple Developer certificate,
macOS will block it from running. To fix this, open Terminal and run:
xattr -cr /Applications/Cagire.app
Support
-------
If you enjoy Cagire, consider supporting development:
https://ko-fi.com/raphaelbubo

7
assets/cagire.desktop Normal file
View File

@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Cagire
Comment=Forth-based music sequencer
Exec=cagire
Icon=cagire
Categories=Audio;Music;AudioVideo;

25
build.rs Normal file
View File

@@ -0,0 +1,25 @@
//! Build script — embeds Windows application resources (icon, metadata).
fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
// C++ runtime (stdc++, gcc, gcc_eh, pthread) linked statically via .cargo/config.toml
// using -Wl,-Bstatic. Only Windows system DLLs go here.
println!("cargo:rustc-link-lib=ws2_32");
println!("cargo:rustc-link-lib=iphlpapi");
println!("cargo:rustc-link-lib=winmm");
println!("cargo:rustc-link-lib=ole32");
println!("cargo:rustc-link-lib=oleaut32");
}
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/Cagire.ico")
.set("ProductName", "Cagire")
.set("FileDescription", "Forth-based music sequencer")
.set("LegalCopyright", "Copyright (c) 2025 Raphaël Forment");
res.compile().expect("Failed to compile Windows resources");
}
}

42842
cool

File diff suppressed because it is too large Load Diff

17
crates/forth/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "cagire-forth"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Forth virtual machine for cagire sequencer"
[features]
default = []
desktop = []
[dependencies]
rand = "0.8"
parking_lot = "0.12"
arc-swap = "1"

View File

@@ -0,0 +1,422 @@
//! Single-pass compiler from Forth source text to Op sequences.
use std::borrow::Cow;
use std::sync::Arc;
use super::ops::Op;
use super::types::{Dictionary, SourceSpan};
use super::words::compile_word;
#[derive(Clone, Debug)]
enum Token {
Int(i64, SourceSpan),
Float(f64, SourceSpan),
Str(String, SourceSpan),
Word(String, SourceSpan),
}
/// Compile Forth source text into an executable Op sequence.
pub(super) fn compile_script(input: &str, dict: &Dictionary) -> Result<Vec<Op>, String> {
let tokens = tokenize(input);
compile(&tokens, dict)
}
fn tokenize(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.char_indices().peekable();
while let Some(&(pos, c)) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '(' || c == ')' {
chars.next();
continue;
}
if c == '"' {
let start = pos;
chars.next();
let mut s = String::new();
let mut end = start + 1;
while let Some(&(i, ch)) = chars.peek() {
end = i + ch.len_utf8();
chars.next();
if ch == '"' {
break;
}
s.push(ch);
}
tokens.push(Token::Str(s, SourceSpan { start: start as u32, end: end as u32 }));
continue;
}
if c == ';' {
chars.next(); // consume first ;
if let Some(&(_, ';')) = chars.peek() {
// ;; starts a comment to end of line
chars.next(); // consume second ;
while let Some(&(_, ch)) = chars.peek() {
if ch == '\n' {
break;
}
chars.next();
}
continue;
}
// single ; is a word, create token
tokens.push(Token::Word(
";".to_string(),
SourceSpan {
start: pos as u32,
end: (pos + 1) as u32,
},
));
continue;
}
let start = pos;
let mut word = String::new();
let mut end = start;
while let Some(&(i, ch)) = chars.peek() {
if ch.is_whitespace() {
break;
}
end = i + ch.len_utf8();
word.push(ch);
chars.next();
}
let span = SourceSpan { start: start as u32, end: end as u32 };
// Normalize shorthand float syntax: .25 -> 0.25, -.5 -> -0.5
let word_to_parse: Cow<str> = if word.starts_with('.')
&& word.len() > 1
&& word.as_bytes()[1].is_ascii_digit()
{
Cow::Owned(format!("0{word}"))
} else if word.starts_with("-.")
&& word.len() > 2
&& word.as_bytes()[2].is_ascii_digit()
{
Cow::Owned(format!("-0{}", &word[1..]))
} else {
Cow::Borrowed(&word)
};
if let Ok(i) = word_to_parse.parse::<i64>() {
tokens.push(Token::Int(i, span));
} else if let Ok(f) = word_to_parse.parse::<f64>() {
tokens.push(Token::Float(f, span));
} else {
tokens.push(Token::Word(word, span));
}
}
tokens
}
fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
let mut ops = Vec::new();
let mut i = 0;
while i < tokens.len() {
match &tokens[i] {
Token::Int(n, span) => {
ops.push(Op::PushInt(*n, Some(*span)));
}
Token::Float(f, span) => {
ops.push(Op::PushFloat(*f, Some(*span)));
}
Token::Str(s, span) => ops.push(Op::PushStr(Arc::from(s.as_str()), Some(*span))),
Token::Word(w, span) => {
let word = w.as_str();
if word == "{" {
let (quote_ops, consumed, end_span) =
compile_quotation(&tokens[i + 1..], dict)?;
i += consumed;
let body_span = SourceSpan {
start: span.start,
end: end_span.end,
};
ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span)));
} else if word == "}" {
return Err("unexpected }".into());
} else if word == "[" {
let (bracket_ops, consumed, end_span) =
compile_bracket(&tokens[i + 1..], dict)?;
i += consumed;
ops.push(Op::Mark);
ops.extend(bracket_ops);
let count_span = SourceSpan {
start: span.start,
end: end_span.end,
};
ops.push(Op::Count(Some(count_span)));
} else if word == "]" {
return Err("unexpected ]".into());
} else if word == ":" {
let (consumed, name, body) = compile_colon_def(&tokens[i + 1..], dict)?;
i += consumed;
dict.lock().insert(name, body);
} else if word == ";" {
return Err("unexpected ;".into());
} else if word == "if" {
let (then_ops, else_ops, consumed, then_span, else_span) =
compile_if(&tokens[i + 1..], dict)?;
i += consumed;
if else_ops.is_empty() {
ops.push(Op::BranchIfZero(then_ops.len(), then_span, None));
ops.extend(then_ops);
} else {
ops.push(Op::BranchIfZero(then_ops.len() + 1, then_span, else_span));
ops.extend(then_ops);
ops.push(Op::Branch(else_ops.len()));
ops.extend(else_ops);
}
} else if word == "case" {
let (case_ops, consumed) = compile_case(&tokens[i + 1..], dict)?;
i += consumed;
ops.extend(case_ops);
} else if word == "of" || word == "endof" || word == "endcase" {
return Err(format!("unexpected '{word}'"));
} else if !compile_word(word, Some(*span), &mut ops, dict) {
return Err(format!("unknown word: {word}"));
}
}
}
i += 1;
}
Ok(ops)
}
fn compile_quotation(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(Vec<Op>, usize, SourceSpan), String> {
let mut depth = 1;
let mut end_idx = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"{" => depth += 1,
"}" => {
depth -= 1;
if depth == 0 {
end_idx = Some(i);
break;
}
}
_ => {}
}
}
}
let end_idx = end_idx.ok_or("missing }")?;
let end_span = match &tokens[end_idx] {
Token::Word(_, span) => *span,
_ => unreachable!(),
};
let quote_ops = compile(&tokens[..end_idx], dict)?;
Ok((quote_ops, end_idx + 1, end_span))
}
fn compile_bracket(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(Vec<Op>, usize, SourceSpan), String> {
let mut depth = 1;
let mut end_idx = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"[" => depth += 1,
"]" => {
depth -= 1;
if depth == 0 {
end_idx = Some(i);
break;
}
}
_ => {}
}
}
}
let end_idx = end_idx.ok_or("missing ]")?;
let end_span = match &tokens[end_idx] {
Token::Word(_, span) => *span,
_ => unreachable!(),
};
let body_ops = compile(&tokens[..end_idx], dict)?;
Ok((body_ops, end_idx + 1, end_span))
}
fn token_span(tok: &Token) -> Option<SourceSpan> {
match tok {
Token::Int(_, s) | Token::Float(_, s) | Token::Str(_, s) | Token::Word(_, s) => Some(*s),
}
}
fn compile_colon_def(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(usize, String, Vec<Op>), String> {
if tokens.is_empty() {
return Err("expected word name after ':'".into());
}
let name = match &tokens[0] {
Token::Word(w, _) => w.clone(),
Token::Int(n, _) => n.to_string(),
Token::Float(f, _) => f.to_string(),
Token::Str(s, _) => s.clone(),
};
let mut semi_pos = None;
for (i, tok) in tokens[1..].iter().enumerate() {
if let Token::Word(w, _) = tok {
if w == ";" {
semi_pos = Some(i + 1);
break;
}
}
}
let semi_pos = semi_pos.ok_or("missing ';' in word definition")?;
let body_tokens = &tokens[1..semi_pos];
let body_ops = compile(body_tokens, dict)?;
Ok((semi_pos + 1, name, body_ops))
}
fn tokens_span(tokens: &[Token]) -> Option<SourceSpan> {
let first = tokens.first().and_then(token_span)?;
let last = tokens.last().and_then(token_span)?;
Some(SourceSpan {
start: first.start,
end: last.end,
})
}
#[allow(clippy::type_complexity)]
fn compile_if(
tokens: &[Token],
dict: &Dictionary,
) -> Result<
(
Vec<Op>,
Vec<Op>,
usize,
Option<SourceSpan>,
Option<SourceSpan>,
),
String,
> {
let mut depth = 1;
let mut else_pos = None;
let mut then_pos = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"if" => depth += 1,
"else" if depth == 1 => else_pos = Some(i),
"then" => {
depth -= 1;
if depth == 0 {
then_pos = Some(i);
break;
}
}
_ => {}
}
}
}
let then_pos = then_pos.ok_or("missing 'then'")?;
let (then_ops, else_ops, then_span, else_span) = if let Some(ep) = else_pos {
let then_slice = &tokens[..ep];
let else_slice = &tokens[ep + 1..then_pos];
let then_span = tokens_span(then_slice);
let else_span = tokens_span(else_slice);
let then_ops = compile(then_slice, dict)?;
let else_ops = compile(else_slice, dict)?;
(then_ops, else_ops, then_span, else_span)
} else {
let then_slice = &tokens[..then_pos];
let then_span = tokens_span(then_slice);
let then_ops = compile(then_slice, dict)?;
(then_ops, Vec::new(), then_span, None)
};
Ok((then_ops, else_ops, then_pos + 1, then_span, else_span))
}
fn compile_case(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize), String> {
let mut depth = 1;
let mut endcase_pos = None;
let mut clauses: Vec<(usize, usize)> = Vec::new();
let mut last_of = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"case" => depth += 1,
"endcase" => {
depth -= 1;
if depth == 0 {
endcase_pos = Some(i);
break;
}
}
"of" if depth == 1 => last_of = Some(i),
"endof" if depth == 1 => {
let of_pos = last_of.ok_or("'endof' without matching 'of'")?;
clauses.push((of_pos, i));
last_of = None;
}
_ => {}
}
}
}
let endcase_pos = endcase_pos.ok_or("missing 'endcase'")?;
let mut ops = Vec::new();
let mut branch_fixups: Vec<usize> = Vec::new();
let mut clause_start = 0;
for &(of_pos, endof_pos) in &clauses {
let test_ops = compile(&tokens[clause_start..of_pos], dict)?;
let body_ops = compile(&tokens[of_pos + 1..endof_pos], dict)?;
ops.extend(test_ops);
ops.push(Op::Over);
ops.push(Op::Eq);
ops.push(Op::BranchIfZero(body_ops.len() + 2, None, None));
ops.push(Op::Drop);
ops.extend(body_ops);
branch_fixups.push(ops.len());
ops.push(Op::Branch(0));
clause_start = endof_pos + 1;
}
let default_tokens = &tokens[clause_start..endcase_pos];
if !default_tokens.is_empty() {
let default_ops = compile(default_tokens, dict)?;
ops.extend(default_ops);
}
ops.push(Op::Drop);
let end = ops.len();
for pos in branch_fixups {
ops[pos] = Op::Branch(end - pos - 1);
}
Ok((ops, endcase_pos + 1))
}

15
crates/forth/src/lib.rs Normal file
View File

@@ -0,0 +1,15 @@
//! Forth virtual machine for the Cagire music sequencer.
mod compiler;
mod ops;
mod theory;
mod types;
mod vm;
mod words;
pub use types::{
CcAccess, Dictionary, ExecutionTrace, ResolvedValue, Rng, SourceSpan, StepContext, Value,
Variables, VariablesMap,
};
pub use vm::Forth;
pub use words::{lookup_word, Word, WordCompile, WORDS};

153
crates/forth/src/ops.rs Normal file
View File

@@ -0,0 +1,153 @@
//! Compiled operation variants for the Forth VM instruction set.
use std::sync::Arc;
use super::types::SourceSpan;
/// Single VM instruction produced by the compiler.
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
PushInt(i64, Option<SourceSpan>),
PushFloat(f64, Option<SourceSpan>),
PushStr(Arc<str>, Option<SourceSpan>),
Dup,
Dupn,
Drop,
Swap,
Over,
Rot,
Nip,
Tuck,
Dup2,
Drop2,
Swap2,
Over2,
Rev,
Shuffle,
Sort,
RSort,
Sum,
Prod,
Forget,
Add,
Sub,
Mul,
Div,
Mod,
Neg,
Abs,
Floor,
Ceil,
Round,
Min,
Max,
Pow,
Sqrt,
Sin,
Cos,
Log,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
And,
Or,
Not,
Xor,
Nand,
Nor,
IfElse,
Pick,
BranchIfZero(usize, Option<SourceSpan>, Option<SourceSpan>),
Branch(usize),
NewCmd,
SetParam(&'static str),
Emit,
Get,
Set,
SetKeep,
GetContext(&'static str),
Rand(Option<SourceSpan>),
ExpRand(Option<SourceSpan>),
LogRand(Option<SourceSpan>),
Seed,
Cycle(Option<SourceSpan>),
PCycle(Option<SourceSpan>),
Choose(Option<SourceSpan>),
Bounce(Option<SourceSpan>),
PBounce(Option<SourceSpan>),
WChoose(Option<SourceSpan>),
ChanceExec(Option<SourceSpan>),
ProbExec(Option<SourceSpan>),
Coin(Option<SourceSpan>),
Mtof,
Ftom,
SetTempo,
Every(Option<SourceSpan>),
Except(Option<SourceSpan>),
EveryOffset(Option<SourceSpan>),
ExceptOffset(Option<SourceSpan>),
Bjork(Option<SourceSpan>),
PBjork(Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
When,
Unless,
Adsr,
Ad,
Apply,
Ramp,
Triangle,
Range,
LinMap,
ExpMap,
Perlin,
Loop,
Degree(&'static [i64]),
Oct,
ClearCmd,
SetSpeed,
At,
Arp,
IntRange,
StepRange,
Generate,
GeomRange,
Euclid,
EuclidRot,
Times,
Chord(&'static [i64]),
Transpose,
Invert,
DownInvert,
VoiceDrop2,
VoiceDrop3,
SetKey,
DiatonicTriad(&'static [i64]),
DiatonicSeventh(&'static [i64]),
// Audio-rate modulation DSL
ModLfo(u8),
ModSlide(u8),
ModRnd(u8),
ModEnv,
// Global params
EmitAll,
ClearGlobal,
// MIDI
MidiEmit,
GetMidiCC,
MidiClock,
MidiStart,
MidiStop,
MidiContinue,
// Recording
Rec,
Overdub,
Orec,
Odub,
// Bracket syntax (mark/count for auto-counting)
Mark,
Count(Option<SourceSpan>),
Index(Option<SourceSpan>),
}

View File

@@ -0,0 +1,179 @@
//! Chord definitions as semitone interval arrays.
/// Named chord with its interval pattern.
pub struct Chord {
pub name: &'static str,
pub intervals: &'static [i64],
}
/// All built-in chord types.
pub static CHORDS: &[Chord] = &[
// Triads
Chord {
name: "maj",
intervals: &[0, 4, 7],
},
Chord {
name: "m",
intervals: &[0, 3, 7],
},
Chord {
name: "dim",
intervals: &[0, 3, 6],
},
Chord {
name: "aug",
intervals: &[0, 4, 8],
},
Chord {
name: "sus2",
intervals: &[0, 2, 7],
},
Chord {
name: "sus4",
intervals: &[0, 5, 7],
},
// Seventh chords
Chord {
name: "maj7",
intervals: &[0, 4, 7, 11],
},
Chord {
name: "min7",
intervals: &[0, 3, 7, 10],
},
Chord {
name: "dom7",
intervals: &[0, 4, 7, 10],
},
Chord {
name: "dim7",
intervals: &[0, 3, 6, 9],
},
Chord {
name: "m7b5",
intervals: &[0, 3, 6, 10],
},
Chord {
name: "minmaj7",
intervals: &[0, 3, 7, 11],
},
Chord {
name: "aug7",
intervals: &[0, 4, 8, 10],
},
// Sixth chords
Chord {
name: "maj6",
intervals: &[0, 4, 7, 9],
},
Chord {
name: "min6",
intervals: &[0, 3, 7, 9],
},
// Extended chords
Chord {
name: "dom9",
intervals: &[0, 4, 7, 10, 14],
},
Chord {
name: "maj9",
intervals: &[0, 4, 7, 11, 14],
},
Chord {
name: "min9",
intervals: &[0, 3, 7, 10, 14],
},
Chord {
name: "dom11",
intervals: &[0, 4, 7, 10, 14, 17],
},
Chord {
name: "min11",
intervals: &[0, 3, 7, 10, 14, 17],
},
Chord {
name: "dom13",
intervals: &[0, 4, 7, 10, 14, 21],
},
// Add chords
Chord {
name: "add9",
intervals: &[0, 4, 7, 14],
},
Chord {
name: "add11",
intervals: &[0, 4, 7, 17],
},
Chord {
name: "madd9",
intervals: &[0, 3, 7, 14],
},
// Power chord
Chord {
name: "pwr",
intervals: &[0, 7],
},
// Suspended seventh
Chord {
name: "7sus4",
intervals: &[0, 5, 7, 10],
},
Chord {
name: "9sus4",
intervals: &[0, 5, 7, 10, 14],
},
// Augmented major
Chord {
name: "augmaj7",
intervals: &[0, 4, 8, 11],
},
// 6/9 chords
Chord {
name: "maj69",
intervals: &[0, 4, 7, 9, 14],
},
Chord {
name: "min69",
intervals: &[0, 3, 7, 9, 14],
},
// Extended
Chord {
name: "maj11",
intervals: &[0, 4, 7, 11, 14, 17],
},
Chord {
name: "maj13",
intervals: &[0, 4, 7, 11, 14, 21],
},
Chord {
name: "min13",
intervals: &[0, 3, 7, 10, 14, 21],
},
// Altered dominants
Chord {
name: "dom7b9",
intervals: &[0, 4, 7, 10, 13],
},
Chord {
name: "dom7s9",
intervals: &[0, 4, 7, 10, 15],
},
Chord {
name: "dom7b5",
intervals: &[0, 4, 6, 10],
},
Chord {
name: "dom7s5",
intervals: &[0, 4, 8, 10],
},
Chord {
name: "dom7s11",
intervals: &[0, 4, 7, 10, 18],
},
];
/// Find a chord's intervals by name.
pub fn lookup(name: &str) -> Option<&'static [i64]> {
CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals)
}

View File

@@ -0,0 +1,6 @@
//! Music theory data — chord and scale lookup tables.
pub mod chords;
mod scales;
pub use scales::lookup;

View File

@@ -0,0 +1,135 @@
//! Scale definitions as semitone offset arrays.
/// Named scale with its semitone pattern.
pub struct Scale {
pub name: &'static str,
pub pattern: &'static [i64],
}
/// All built-in scale types.
pub static SCALES: &[Scale] = &[
Scale {
name: "major",
pattern: &[0, 2, 4, 5, 7, 9, 11],
},
Scale {
name: "minor",
pattern: &[0, 2, 3, 5, 7, 8, 10],
},
Scale {
name: "dorian",
pattern: &[0, 2, 3, 5, 7, 9, 10],
},
Scale {
name: "phrygian",
pattern: &[0, 1, 3, 5, 7, 8, 10],
},
Scale {
name: "lydian",
pattern: &[0, 2, 4, 6, 7, 9, 11],
},
Scale {
name: "mixolydian",
pattern: &[0, 2, 4, 5, 7, 9, 10],
},
Scale {
name: "aeolian",
pattern: &[0, 2, 3, 5, 7, 8, 10],
},
Scale {
name: "locrian",
pattern: &[0, 1, 3, 5, 6, 8, 10],
},
Scale {
name: "pentatonic",
pattern: &[0, 2, 4, 7, 9],
},
Scale {
name: "minpent",
pattern: &[0, 3, 5, 7, 10],
},
Scale {
name: "blues",
pattern: &[0, 3, 5, 6, 7, 10],
},
Scale {
name: "chromatic",
pattern: &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
},
Scale {
name: "wholetone",
pattern: &[0, 2, 4, 6, 8, 10],
},
Scale {
name: "harmonicminor",
pattern: &[0, 2, 3, 5, 7, 8, 11],
},
Scale {
name: "melodicminor",
pattern: &[0, 2, 3, 5, 7, 9, 11],
},
// Jazz/Bebop
Scale {
name: "bebop",
pattern: &[0, 2, 4, 5, 7, 9, 10, 11],
},
Scale {
name: "bebopmaj",
pattern: &[0, 2, 4, 5, 7, 8, 9, 11],
},
Scale {
name: "bebopmin",
pattern: &[0, 2, 3, 5, 7, 8, 9, 10],
},
Scale {
name: "altered",
pattern: &[0, 1, 3, 4, 6, 8, 10],
},
Scale {
name: "lyddom",
pattern: &[0, 2, 4, 6, 7, 9, 10],
},
Scale {
name: "halfwhole",
pattern: &[0, 1, 3, 4, 6, 7, 9, 10],
},
Scale {
name: "wholehalf",
pattern: &[0, 2, 3, 5, 6, 8, 9, 11],
},
// Symmetric
Scale {
name: "augmented",
pattern: &[0, 3, 4, 7, 8, 11],
},
Scale {
name: "tritone",
pattern: &[0, 1, 4, 6, 7, 10],
},
Scale {
name: "prometheus",
pattern: &[0, 2, 4, 6, 9, 10],
},
// Modal variants (from melodic minor)
Scale {
name: "dorianb2",
pattern: &[0, 1, 3, 5, 7, 9, 10],
},
Scale {
name: "lydianaug",
pattern: &[0, 2, 4, 6, 8, 9, 11],
},
Scale {
name: "mixb6",
pattern: &[0, 2, 4, 5, 7, 8, 10],
},
Scale {
name: "locrian2",
pattern: &[0, 2, 3, 5, 6, 8, 10],
},
];
/// Find a scale's pattern by name.
pub fn lookup(name: &str) -> Option<&'static [i64]> {
SCALES.iter().find(|s| s.name == name).map(|s| s.pattern)
}

244
crates/forth/src/types.rs Normal file
View File

@@ -0,0 +1,244 @@
//! Core types for the Forth VM: values, execution context, and shared state.
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use std::collections::HashMap;
use std::sync::Arc;
use super::ops::Op;
/// Trait for accessing MIDI CC values. Implement this to provide CC memory to the Forth VM.
pub trait CcAccess: Send + Sync {
/// Get the CC value for a given device, channel (0-15), and CC number (0-127).
fn get_cc(&self, device: usize, channel: usize, cc: usize) -> u8;
}
/// Byte range in source text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SourceSpan {
pub start: u32,
pub end: u32,
}
/// Concrete value resolved from a nondeterministic op, used for trace annotations.
#[derive(Clone, Debug)]
pub enum ResolvedValue {
Int(i64),
Float(f64),
Bool(bool),
Str(Arc<str>),
}
impl ResolvedValue {
pub fn display(&self) -> String {
match self {
ResolvedValue::Int(i) => i.to_string(),
ResolvedValue::Float(f) => format!("{f:.2}"),
ResolvedValue::Bool(b) => if *b { "yes" } else { "no" }.into(),
ResolvedValue::Str(s) => s.to_string(),
}
}
}
/// Spans and resolved values collected during a single evaluation, used for UI highlighting.
#[derive(Clone, Debug, Default)]
pub struct ExecutionTrace {
pub executed_spans: Vec<SourceSpan>,
pub selected_spans: Vec<SourceSpan>,
pub resolved: Vec<(SourceSpan, ResolvedValue)>,
}
/// Per-step sequencer state passed into the VM.
pub struct StepContext<'a> {
pub step: usize,
pub beat: f64,
pub bank: usize,
pub pattern: usize,
pub tempo: f64,
pub phase: f64,
pub slot: usize,
pub runs: usize,
pub iter: usize,
pub speed: f64,
pub fill: bool,
pub nudge_secs: f64,
pub cc_access: Option<&'a dyn CcAccess>,
pub speed_key: &'a str,
pub mouse_x: f64,
pub mouse_y: f64,
pub mouse_down: f64,
}
impl StepContext<'_> {
pub fn step_duration(&self) -> f64 {
60.0 / self.tempo / 4.0 / self.speed
}
}
/// Underlying map for user-defined variables.
pub type VariablesMap = HashMap<String, Value>;
/// Shared variable store, swapped atomically after each step.
pub type Variables = Arc<ArcSwap<VariablesMap>>;
/// Shared user-defined word dictionary.
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
/// Shared random number generator.
pub type Rng = Arc<Mutex<StdRng>>;
pub type Stack = Mutex<Vec<Value>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(&'static str, Value)]);
/// Stack value in the Forth VM.
#[derive(Clone, Debug)]
pub enum Value {
Int(i64, Option<SourceSpan>),
Float(f64, Option<SourceSpan>),
Str(Arc<str>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
CycleList(Arc<[Value]>),
ArpList(Arc<[Value]>),
}
impl PartialEq for Value {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Value::Int(a, _), Value::Int(b, _)) => a == b,
(Value::Float(a, _), Value::Float(b, _)) => a == b,
(Value::Str(a, _), Value::Str(b, _)) => a == b,
(Value::Quotation(a, _), Value::Quotation(b, _)) => a == b,
(Value::CycleList(a), Value::CycleList(b)) => a == b,
(Value::ArpList(a), Value::ArpList(b)) => a == b,
_ => false,
}
}
}
impl Value {
pub fn as_float(&self) -> Result<f64, String> {
match self {
Value::Float(f, _) => Ok(*f),
Value::Int(i, _) => Ok(*i as f64),
_ => Err("expected number".into()),
}
}
pub(super) fn as_int(&self) -> Result<i64, String> {
match self {
Value::Int(i, _) => Ok(*i),
Value::Float(f, _) => Ok(*f as i64),
_ => Err("expected number".into()),
}
}
pub(super) fn as_str(&self) -> Result<&str, String> {
match self {
Value::Str(s, _) => Ok(s),
_ => Err("expected string".into()),
}
}
pub(super) fn is_truthy(&self) -> bool {
match self {
Value::Int(i, _) => *i != 0,
Value::Float(f, _) => *f != 0.0,
Value::Str(s, _) => !s.is_empty(),
Value::Quotation(..) => true,
Value::CycleList(items) | Value::ArpList(items) => !items.is_empty(),
}
}
pub(super) fn to_param_string(&self) -> String {
match self {
Value::Int(i, _) => i.to_string(),
Value::Float(f, _) => f.to_string(),
Value::Str(s, _) => s.to_string(),
Value::Quotation(..) => String::new(),
Value::CycleList(_) | Value::ArpList(_) => String::new(),
}
}
pub(super) fn span(&self) -> Option<SourceSpan> {
match self {
Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s,
Value::CycleList(_) | Value::ArpList(_) => None,
}
}
}
#[derive(Clone, Debug, Default)]
pub(super) struct CmdRegister {
sound: Option<Value>,
params: Vec<(&'static str, Value)>,
deltas: Vec<Value>,
global_params: Vec<(&'static str, Value)>,
}
impl CmdRegister {
pub(super) fn new() -> Self {
Self {
sound: None,
params: Vec::with_capacity(16),
deltas: Vec::with_capacity(4),
global_params: Vec::new(),
}
}
pub(super) fn set_sound(&mut self, val: Value) {
self.sound = Some(val);
}
pub(super) fn set_param(&mut self, key: &'static str, val: Value) {
self.params.push((key, val));
}
pub(super) fn set_deltas(&mut self, deltas: Vec<Value>) {
self.deltas = deltas;
}
pub(super) fn deltas(&self) -> &[Value] {
&self.deltas
}
pub(super) fn sound(&self) -> Option<&Value> {
self.sound.as_ref()
}
pub(super) fn params(&self) -> &[(&'static str, Value)] {
&self.params
}
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
if self.sound.is_some() || !self.params.is_empty() {
Some((self.sound.as_ref(), self.params.as_slice()))
} else {
None
}
}
pub(super) fn global_params(&self) -> &[(&'static str, Value)] {
&self.global_params
}
pub(super) fn commit_global(&mut self) {
self.global_params.append(&mut self.params);
self.sound = None;
self.deltas.clear();
}
pub(super) fn clear_global(&mut self) {
self.global_params.clear();
}
pub fn set_global(&mut self, params: Vec<(&'static str, Value)>) {
self.global_params = params;
}
pub fn take_global(&mut self) -> Vec<(&'static str, Value)> {
std::mem::take(&mut self.global_params)
}
pub(super) fn clear(&mut self) {
self.sound = None;
self.params.clear();
self.deltas.clear();
}
}

1984
crates/forth/src/vm.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
//! Word-to-Op translation: maps Forth word names to compiled instructions.
use std::sync::Arc;
use crate::ops::Op;
use crate::theory;
use crate::types::{Dictionary, SourceSpan};
use super::{lookup_word, WordCompile::*};
pub(super) fn simple_op(name: &str) -> Option<Op> {
Some(match name {
"dup" => Op::Dup,
"dupn" => Op::Dupn,
"drop" => Op::Drop,
"swap" => Op::Swap,
"over" => Op::Over,
"rot" => Op::Rot,
"nip" => Op::Nip,
"tuck" => Op::Tuck,
"2dup" => Op::Dup2,
"2drop" => Op::Drop2,
"2swap" => Op::Swap2,
"2over" => Op::Over2,
"rev" => Op::Rev,
"shuffle" => Op::Shuffle,
"sort" => Op::Sort,
"rsort" => Op::RSort,
"sum" => Op::Sum,
"prod" => Op::Prod,
"+" => Op::Add,
"-" => Op::Sub,
"*" => Op::Mul,
"/" => Op::Div,
"mod" => Op::Mod,
"neg" => Op::Neg,
"abs" => Op::Abs,
"floor" => Op::Floor,
"ceil" => Op::Ceil,
"round" => Op::Round,
"min" => Op::Min,
"max" => Op::Max,
"pow" => Op::Pow,
"sqrt" => Op::Sqrt,
"sin" => Op::Sin,
"cos" => Op::Cos,
"log" => Op::Log,
"=" => Op::Eq,
"!=" => Op::Ne,
"lt" => Op::Lt,
"gt" => Op::Gt,
"<=" => Op::Le,
">=" => Op::Ge,
"and" => Op::And,
"or" => Op::Or,
"not" => Op::Not,
"xor" => Op::Xor,
"nand" => Op::Nand,
"nor" => Op::Nor,
"ifelse" => Op::IfElse,
"select" => Op::Pick,
"sound" => Op::NewCmd,
"." => Op::Emit,
"rand" => Op::Rand(None),
"exprand" => Op::ExpRand(None),
"logrand" => Op::LogRand(None),
"seed" => Op::Seed,
"cycle" => Op::Cycle(None),
"pcycle" => Op::PCycle(None),
"choose" => Op::Choose(None),
"bounce" => Op::Bounce(None),
"pbounce" => Op::PBounce(None),
"wchoose" => Op::WChoose(None),
"every" => Op::Every(None),
"except" => Op::Except(None),
"every+" => Op::EveryOffset(None),
"except+" => Op::ExceptOffset(None),
"bjork" => Op::Bjork(None),
"pbjork" => Op::PBjork(None),
"chance" => Op::ChanceExec(None),
"prob" => Op::ProbExec(None),
"coin" => Op::Coin(None),
"mtof" => Op::Mtof,
"ftom" => Op::Ftom,
"?" => Op::When,
"!?" => Op::Unless,
"tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed,
"at" => Op::At,
"arp" => Op::Arp,
"adsr" => Op::Adsr,
"ad" => Op::Ad,
"apply" => Op::Apply,
"ramp" => Op::Ramp,
"triangle" => Op::Triangle,
"range" => Op::Range,
"linmap" => Op::LinMap,
"expmap" => Op::ExpMap,
"perlin" => Op::Perlin,
"loop" => Op::Loop,
"oct" => Op::Oct,
"clear" => Op::ClearCmd,
"all" => Op::EmitAll,
"noall" => Op::ClearGlobal,
".." => Op::IntRange,
".," => Op::StepRange,
"gen" => Op::Generate,
"geom.." => Op::GeomRange,
"euclid" => Op::Euclid,
"euclidrot" => Op::EuclidRot,
"times" => Op::Times,
"m." => Op::MidiEmit,
"ccval" => Op::GetMidiCC,
"mclock" => Op::MidiClock,
"mstart" => Op::MidiStart,
"mstop" => Op::MidiStop,
"mcont" => Op::MidiContinue,
"rec" => Op::Rec,
"overdub" | "dub" => Op::Overdub,
"orec" => Op::Orec,
"odub" => Op::Odub,
"forget" => Op::Forget,
"index" => Op::Index(None),
"key!" => Op::SetKey,
"tp" => Op::Transpose,
"inv" => Op::Invert,
"dinv" => Op::DownInvert,
"drop2" => Op::VoiceDrop2,
"drop3" => Op::VoiceDrop3,
"lfo" => Op::ModLfo(0),
"tlfo" => Op::ModLfo(1),
"wlfo" => Op::ModLfo(2),
"qlfo" => Op::ModLfo(3),
"slide" => Op::ModSlide(0),
"expslide" => Op::ModSlide(1),
"sslide" => Op::ModSlide(2),
"jit" => Op::ModRnd(0),
"sjit" => Op::ModRnd(1),
"drunk" => Op::ModRnd(2),
"env" => Op::ModEnv,
_ => return None,
})
}
fn parse_note_name(name: &str) -> Option<i64> {
let name = name.to_lowercase();
let bytes = name.as_bytes();
if bytes.len() < 2 {
return None;
}
let base = match bytes[0] {
b'c' => 0,
b'd' => 2,
b'e' => 4,
b'f' => 5,
b'g' => 7,
b'a' => 9,
b'b' => 11,
_ => return None,
};
let (modifier, octave_start) = match bytes[1] {
b'#' | b's' => (1, 2),
b'b' if bytes.len() > 2 && bytes[2].is_ascii_digit() => (-1, 2),
b'0'..=b'9' => (0, 1),
_ => return None,
};
let octave_str = &name[octave_start..];
let octave: i64 = octave_str.parse().ok()?;
if !(-1..=9).contains(&octave) {
return None;
}
Some((octave + 1) * 12 + base + modifier)
}
fn parse_interval(name: &str) -> Option<i64> {
let simple = match name {
"P1" | "unison" => 0,
"m2" => 1,
"M2" => 2,
"m3" => 3,
"M3" => 4,
"P4" => 5,
"aug4" | "dim5" | "tritone" => 6,
"P5" => 7,
"m6" => 8,
"M6" => 9,
"m7" => 10,
"M7" => 11,
"P8" => 12,
"m9" => 13,
"M9" => 14,
"m10" => 15,
"M10" => 16,
"P11" => 17,
"aug11" => 18,
"P12" => 19,
"m13" => 20,
"M13" => 21,
"m14" => 22,
"M14" => 23,
"P15" => 24,
_ => return None,
};
Some(simple)
}
fn attach_span(op: &mut Op, span: SourceSpan) {
match op {
Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s)
| Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
| Op::Bounce(s) | Op::PBounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
| Op::Every(s) | Op::Except(s) | Op::EveryOffset(s) | Op::ExceptOffset(s)
| Op::Bjork(s) | Op::PBjork(s)
| Op::Count(s) | Op::Index(s) => *s = Some(span),
_ => {}
}
}
pub(crate) fn compile_word(
name: &str,
span: Option<SourceSpan>,
ops: &mut Vec<Op>,
dict: &Dictionary,
) -> bool {
match name {
"linramp" => {
ops.push(Op::PushFloat(1.0, span));
ops.push(Op::Ramp);
return true;
}
"expramp" => {
ops.push(Op::PushFloat(3.0, span));
ops.push(Op::Ramp);
return true;
}
"logramp" => {
ops.push(Op::PushFloat(0.3, span));
ops.push(Op::Ramp);
return true;
}
_ => {}
}
if name == "triad" || name == "seventh" {
if let Some(Op::Degree(pattern)) = ops.last() {
let pattern = *pattern;
ops.pop();
ops.push(if name == "triad" {
Op::DiatonicTriad(pattern)
} else {
Op::DiatonicSeventh(pattern)
});
return true;
}
return false;
}
if let Some(pattern) = theory::lookup(name) {
ops.push(Op::Degree(pattern));
return true;
}
if let Some(intervals) = theory::chords::lookup(name) {
ops.push(Op::Chord(intervals));
return true;
}
if let Some(word) = lookup_word(name) {
match &word.compile {
Simple => {
if let Some(mut op) = simple_op(word.name) {
if let Some(sp) = span {
attach_span(&mut op, sp);
}
ops.push(op);
}
}
Context(ctx) => ops.push(Op::GetContext(ctx)),
Param => ops.push(Op::SetParam(word.name)),
Probability(p) => {
ops.push(Op::PushFloat(*p, None));
ops.push(Op::ChanceExec(span));
}
}
return true;
}
if let Some(var_name) = name.strip_prefix('@') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::Get);
return true;
}
}
if let Some(var_name) = name.strip_prefix('!') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::Set);
return true;
}
}
if let Some(var_name) = name.strip_prefix(',') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::SetKeep);
return true;
}
}
if let Some(midi) = parse_note_name(name) {
ops.push(Op::PushInt(midi, span));
return true;
}
if let Some(semitones) = parse_interval(name) {
ops.push(Op::Dup);
ops.push(Op::PushInt(semitones, span));
ops.push(Op::Add);
return true;
}
if let Some(op) = simple_op(name) {
ops.push(op);
return true;
}
if let Some(body) = dict.lock().get(name) {
ops.extend(body.iter().cloned());
return true;
}
ops.push(Op::PushStr(Arc::from(name), span));
true
}

View File

@@ -0,0 +1,622 @@
//! Word metadata for core language primitives (stack, arithmetic, logic, variables, definitions).
use super::{Word, WordCompile::*};
pub(super) const WORDS: &[Word] = &[
// Stack manipulation
Word {
name: "dup",
aliases: &[],
category: "Stack",
stack: "(a -- a a)",
desc: "Duplicate top of stack",
example: "3 dup => 3 3",
compile: Simple,
varargs: false,
},
Word {
name: "dupn",
aliases: &["!"],
category: "Stack",
stack: "(a n -- a a ... a)",
desc: "Duplicate a onto stack n times",
example: "2 4 dupn => 2 2 2 2",
compile: Simple,
varargs: true,
},
Word {
name: "drop",
aliases: &[],
category: "Stack",
stack: "(a --)",
desc: "Remove top of stack",
example: "1 2 drop => 1",
compile: Simple,
varargs: false,
},
Word {
name: "swap",
aliases: &[],
category: "Stack",
stack: "(a b -- b a)",
desc: "Exchange top two items",
example: "1 2 swap => 2 1",
compile: Simple,
varargs: false,
},
Word {
name: "over",
aliases: &[],
category: "Stack",
stack: "(a b -- a b a)",
desc: "Copy second to top",
example: "1 2 over => 1 2 1",
compile: Simple,
varargs: false,
},
Word {
name: "rot",
aliases: &[],
category: "Stack",
stack: "(a b c -- b c a)",
desc: "Rotate top three",
example: "1 2 3 rot => 2 3 1",
compile: Simple,
varargs: false,
},
Word {
name: "nip",
aliases: &[],
category: "Stack",
stack: "(a b -- b)",
desc: "Remove second item",
example: "1 2 nip => 2",
compile: Simple,
varargs: false,
},
Word {
name: "tuck",
aliases: &[],
category: "Stack",
stack: "(a b -- b a b)",
desc: "Copy top under second",
example: "1 2 tuck => 2 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2dup",
aliases: &[],
category: "Stack",
stack: "(a b -- a b a b)",
desc: "Duplicate top two values",
example: "1 2 2dup => 1 2 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2drop",
aliases: &[],
category: "Stack",
stack: "(a b --)",
desc: "Drop top two values",
example: "1 2 3 2drop => 1",
compile: Simple,
varargs: false,
},
Word {
name: "2swap",
aliases: &[],
category: "Stack",
stack: "(a b c d -- c d a b)",
desc: "Swap top two pairs",
example: "1 2 3 4 2swap => 3 4 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2over",
aliases: &[],
category: "Stack",
stack: "(a b c d -- a b c d a b)",
desc: "Copy second pair to top",
example: "1 2 3 4 2over => 1 2 3 4 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "rev",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Reverse top n items",
example: "1 2 3 3 rev => 3 2 1",
compile: Simple,
varargs: true,
},
Word {
name: "shuffle",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Randomly shuffle top n items",
example: "1 2 3 3 shuffle",
compile: Simple,
varargs: true,
},
Word {
name: "sort",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Sort top n items ascending",
example: "3 1 2 3 sort => 1 2 3",
compile: Simple,
varargs: true,
},
Word {
name: "rsort",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Sort top n items descending",
example: "1 2 3 3 rsort => 3 2 1",
compile: Simple,
varargs: true,
},
Word {
name: "sum",
aliases: &[],
category: "Stack",
stack: "(..n n -- total)",
desc: "Sum top n items",
example: "1 2 3 3 sum => 6",
compile: Simple,
varargs: true,
},
Word {
name: "prod",
aliases: &[],
category: "Stack",
stack: "(..n n -- product)",
desc: "Multiply top n items",
example: "2 3 4 3 prod => 24",
compile: Simple,
varargs: true,
},
// Arithmetic
Word {
name: "+",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a+b)",
desc: "Add",
example: "2 3 + => 5",
compile: Simple,
varargs: false,
},
Word {
name: "-",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a-b)",
desc: "Subtract",
example: "5 3 - => 2",
compile: Simple,
varargs: false,
},
Word {
name: "*",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a*b)",
desc: "Multiply",
example: "3 4 * => 12",
compile: Simple,
varargs: false,
},
Word {
name: "/",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a/b)",
desc: "Divide",
example: "10 2 / => 5",
compile: Simple,
varargs: false,
},
Word {
name: "mod",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a%b)",
desc: "Modulo",
example: "7 3 mod => 1",
compile: Simple,
varargs: false,
},
Word {
name: "neg",
aliases: &[],
category: "Arithmetic",
stack: "(a -- -a)",
desc: "Negate",
example: "5 neg => -5",
compile: Simple,
varargs: false,
},
Word {
name: "abs",
aliases: &[],
category: "Arithmetic",
stack: "(a -- |a|)",
desc: "Absolute value",
example: "-5 abs => 5",
compile: Simple,
varargs: false,
},
Word {
name: "floor",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round down to integer",
example: "3.7 floor => 3",
compile: Simple,
varargs: false,
},
Word {
name: "ceil",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round up to integer",
example: "3.2 ceil => 4",
compile: Simple,
varargs: false,
},
Word {
name: "round",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round to nearest integer",
example: "3.5 round => 4",
compile: Simple,
varargs: false,
},
Word {
name: "min",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- min)",
desc: "Minimum of two values",
example: "3 5 min => 3",
compile: Simple,
varargs: false,
},
Word {
name: "max",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- max)",
desc: "Maximum of two values",
example: "3 5 max => 5",
compile: Simple,
varargs: false,
},
Word {
name: "pow",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a^b)",
desc: "Exponentiation",
example: "2 3 pow => 8",
compile: Simple,
varargs: false,
},
Word {
name: "sqrt",
aliases: &[],
category: "Arithmetic",
stack: "(a -- √a)",
desc: "Square root",
example: "16 sqrt => 4",
compile: Simple,
varargs: false,
},
Word {
name: "sin",
aliases: &[],
category: "Arithmetic",
stack: "(a -- sin(a))",
desc: "Sine (radians)",
example: "3.14159 2 / sin => 1.0",
compile: Simple,
varargs: false,
},
Word {
name: "cos",
aliases: &[],
category: "Arithmetic",
stack: "(a -- cos(a))",
desc: "Cosine (radians)",
example: "0 cos => 1.0",
compile: Simple,
varargs: false,
},
Word {
name: "log",
aliases: &[],
category: "Arithmetic",
stack: "(a -- ln(a))",
desc: "Natural logarithm",
example: "2.718 log => 1.0",
compile: Simple,
varargs: false,
},
Word {
name: "linmap",
aliases: &[],
category: "Arithmetic",
stack: "(val inlo inhi outlo outhi -- mapped)",
desc: "Linear map from [inlo,inhi] to [outlo,outhi]",
example: "64 0 127 200 2000 linmap => 1007.87",
compile: Simple,
varargs: false,
},
Word {
name: "expmap",
aliases: &[],
category: "Arithmetic",
stack: "(val lo hi -- mapped)",
desc: "Exponential map from [0,1] to [lo,hi]",
example: "0.5 200 8000 expmap => 1264.91",
compile: Simple,
varargs: false,
},
// Comparison
Word {
name: "=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Equal",
example: "3 3 = => 1",
compile: Simple,
varargs: false,
},
Word {
name: "!=",
aliases: &["<>"],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Not equal",
example: "3 4 != => 1",
compile: Simple,
varargs: false,
},
Word {
name: "lt",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Less than",
example: "2 3 lt => 1",
compile: Simple,
varargs: false,
},
Word {
name: "gt",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Greater than",
example: "3 2 gt => 1",
compile: Simple,
varargs: false,
},
Word {
name: "<=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Less or equal",
example: "3 3 <= => 1",
compile: Simple,
varargs: false,
},
Word {
name: ">=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Greater or equal",
example: "3 3 >= => 1",
compile: Simple,
varargs: false,
},
// Logic
Word {
name: "and",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Logical and",
example: "1 1 and => 1",
compile: Simple,
varargs: false,
},
Word {
name: "or",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Logical or",
example: "0 1 or => 1",
compile: Simple,
varargs: false,
},
Word {
name: "not",
aliases: &[],
category: "Logic",
stack: "(a -- bool)",
desc: "Logical not",
example: "0 not => 1",
compile: Simple,
varargs: false,
},
Word {
name: "xor",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Exclusive or",
example: "1 0 xor => 1",
compile: Simple,
varargs: false,
},
Word {
name: "nand",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Not and",
example: "1 1 nand => 0",
compile: Simple,
varargs: false,
},
Word {
name: "nor",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Not or",
example: "0 0 nor => 1",
compile: Simple,
varargs: false,
},
Word {
name: "ifelse",
aliases: &[],
category: "Logic",
stack: "(true-quot false-quot bool --)",
desc: "Execute true-quot if true, else false-quot",
example: "{ 1 } { 2 } coin ifelse",
compile: Simple,
varargs: false,
},
Word {
name: "select",
aliases: &[],
category: "Logic",
stack: "(..quots n --)",
desc: "Execute nth quotation (0-indexed)",
example: "{ 1 } { 2 } { 3 } 2 select => 3",
compile: Simple,
varargs: true,
},
Word {
name: "?",
aliases: &[],
category: "Logic",
stack: "(quot bool --)",
desc: "Execute quotation if true",
example: "{ 2 distort } 0.5 chance ?",
compile: Simple,
varargs: false,
},
Word {
name: "!?",
aliases: &[],
category: "Logic",
stack: "(quot bool --)",
desc: "Execute quotation if false",
example: "{ 1 distort } 0.5 chance !?",
compile: Simple,
varargs: false,
},
Word {
name: "apply",
aliases: &[],
category: "Logic",
stack: "(quot --)",
desc: "Execute quotation unconditionally",
example: "{ 2 * } apply",
compile: Simple,
varargs: false,
},
// Control
Word {
name: "times",
aliases: &[],
category: "Control",
stack: "(n quot --)",
desc: "Execute quotation n times, @i holds current index",
example: "4 { @i . } times => 0 1 2 3",
compile: Simple,
varargs: false,
},
// Variables
Word {
name: "@<var>",
aliases: &[],
category: "Variables",
stack: "( -- val)",
desc: "Fetch variable value",
example: "@freq => 440",
compile: Simple,
varargs: false,
},
Word {
name: "!<var>",
aliases: &[],
category: "Variables",
stack: "(val --)",
desc: "Store value in variable",
example: "440 !freq",
compile: Simple,
varargs: false,
},
Word {
name: ",<var>",
aliases: &[],
category: "Variables",
stack: "(val -- val)",
desc: "Store value in variable, keep on stack",
example: "440 ,freq => 440",
compile: Simple,
varargs: false,
},
// Definitions
Word {
name: ":",
aliases: &[],
category: "Definitions",
stack: "( -- )",
desc: "Begin word definition",
example: ": kick \"kick\" s emit ;",
compile: Simple,
varargs: false,
},
Word {
name: ";",
aliases: &[],
category: "Definitions",
stack: "( -- )",
desc: "End word definition",
example: ": kick \"kick\" s emit ;",
compile: Simple,
varargs: false,
},
Word {
name: "forget",
aliases: &[],
category: "Definitions",
stack: "(name --)",
desc: "Remove user-defined word from dictionary",
example: "\"double\" forget",
compile: Simple,
varargs: false,
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
//! MIDI word definitions: channel, CC, pitch bend, transport, and device routing.
use super::{Word, WordCompile::*};
pub(super) const WORDS: &[Word] = &[
Word {
name: "chan",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set MIDI channel 1-16",
example: "1 chan",
compile: Param,
varargs: true,
},
Word {
name: "ccnum",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set MIDI CC number 0-127",
example: "1 ccnum",
compile: Param,
varargs: true,
},
Word {
name: "ccout",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set MIDI CC output value 0-127",
example: "64 ccout",
compile: Param,
varargs: true,
},
Word {
name: "bend",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
example: "0.5 bend",
compile: Param,
varargs: true,
},
Word {
name: "pressure",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set channel pressure (aftertouch) 0-127",
example: "64 pressure",
compile: Param,
varargs: true,
},
Word {
name: "program",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set program change number 0-127",
example: "0 program",
compile: Param,
varargs: true,
},
Word {
name: "m.",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Emit MIDI message from params (note/cc/bend/pressure/program)",
example: "60 note 100 velocity 1 chan m.",
compile: Simple,
varargs: false,
},
Word {
name: "mclock",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI clock pulse (24 per quarter note)",
example: "mclock",
compile: Simple,
varargs: false,
},
Word {
name: "mstart",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI start message",
example: "mstart",
compile: Simple,
varargs: false,
},
Word {
name: "mstop",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI stop message",
example: "mstop",
compile: Simple,
varargs: false,
},
Word {
name: "mcont",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI continue message",
example: "mcont",
compile: Simple,
varargs: false,
},
Word {
name: "ccval",
aliases: &[],
category: "MIDI",
stack: "(cc chan -- val)",
desc: "Read CC value 0-127 from MIDI input (uses dev param for device)",
example: "1 1 ccval",
compile: Simple,
varargs: false,
},
Word {
name: "dev",
aliases: &[],
category: "MIDI",
stack: "(v.. --)",
desc: "Set MIDI device slot 0-3 for output/input",
example: "1 dev 60 note m.",
compile: Param,
varargs: true,
},
];

View File

@@ -0,0 +1,65 @@
//! Built-in word definitions and lookup for the Forth VM.
mod compile;
mod core;
mod effects;
mod midi;
mod music;
mod sequencing;
mod sound;
use std::collections::HashMap;
use std::sync::LazyLock;
pub(crate) use compile::compile_word;
/// How a word is compiled into ops.
#[derive(Clone, Copy)]
pub enum WordCompile {
Simple,
Context(&'static str),
Param,
Probability(f64),
}
/// Metadata for a built-in Forth word.
#[derive(Clone, Copy)]
pub struct Word {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub category: &'static str,
pub stack: &'static str,
pub desc: &'static str,
pub example: &'static str,
pub compile: WordCompile,
pub varargs: bool,
}
/// All built-in words, aggregated from every category module.
pub static WORDS: LazyLock<Vec<Word>> = LazyLock::new(|| {
let mut words = Vec::new();
words.extend_from_slice(self::core::WORDS);
words.extend_from_slice(sound::WORDS);
words.extend_from_slice(effects::WORDS);
words.extend_from_slice(sequencing::WORDS);
words.extend_from_slice(music::WORDS);
words.extend_from_slice(midi::WORDS);
words
});
/// Index mapping word names and aliases to their definitions.
static WORD_MAP: LazyLock<HashMap<&'static str, &'static Word>> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(WORDS.len() * 2);
for word in WORDS.iter() {
map.insert(word.name, word);
for alias in word.aliases {
map.insert(alias, word);
}
}
map
});
/// Find a word by name or alias.
pub fn lookup_word(name: &str) -> Option<&'static Word> {
WORD_MAP.get(name).copied()
}

View File

@@ -0,0 +1,496 @@
//! Word definitions for music theory, harmony, and chord construction.
use super::{Word, WordCompile::*};
pub(super) const WORDS: &[Word] = &[
// Music
Word {
name: "mtof",
aliases: &[],
category: "Music",
stack: "(midi -- hz)",
desc: "MIDI note to frequency",
example: "69 mtof => 440.0",
compile: Simple,
varargs: false,
},
Word {
name: "ftom",
aliases: &[],
category: "Music",
stack: "(hz -- midi)",
desc: "Frequency to MIDI note",
example: "440 ftom => 69.0",
compile: Simple,
varargs: false,
},
// Harmony
Word {
name: "key!",
aliases: &[],
category: "Harmony",
stack: "(root --)",
desc: "Set tonal center for scale operations",
example: "g3 key! 0 major => 55",
compile: Simple,
varargs: false,
},
Word {
name: "triad",
aliases: &[],
category: "Harmony",
stack: "(degree -- n1 n2 n3)",
desc: "Diatonic triad from scale degree (follows a scale word)",
example: "0 major triad => 60 64 67",
compile: Simple,
varargs: true,
},
Word {
name: "seventh",
aliases: &[],
category: "Harmony",
stack: "(degree -- n1 n2 n3 n4)",
desc: "Diatonic seventh from scale degree (follows a scale word)",
example: "0 major seventh => 60 64 67 71",
compile: Simple,
varargs: true,
},
// Chord voicings
Word {
name: "inv",
aliases: &[],
category: "Chord",
stack: "(a b c.. -- b c.. a+12)",
desc: "Inversion: bottom note moves up an octave",
example: "c4 maj inv => 64 67 72",
compile: Simple,
varargs: true,
},
Word {
name: "dinv",
aliases: &[],
category: "Chord",
stack: "(a b.. z -- z-12 a b..)",
desc: "Down inversion: top note moves down an octave",
example: "c4 maj dinv => 55 60 64",
compile: Simple,
varargs: true,
},
Word {
name: "drop2",
aliases: &[],
category: "Chord",
stack: "(a b c d -- b-12 a c d)",
desc: "Drop-2 voicing: 2nd from top moves down an octave",
example: "c4 maj7 drop2 => 55 60 64 71",
compile: Simple,
varargs: true,
},
Word {
name: "drop3",
aliases: &[],
category: "Chord",
stack: "(a b c d -- c-12 a b d)",
desc: "Drop-3 voicing: 3rd from top moves down an octave",
example: "c4 maj7 drop3 => 52 60 67 71",
compile: Simple,
varargs: true,
},
// Transpose
Word {
name: "tp",
aliases: &[],
category: "Harmony",
stack: "(n --)",
desc: "Transpose all ints on stack by N semitones",
example: "c4 maj 3 tp => 63 67 70",
compile: Simple,
varargs: true,
},
// Chords - Triads
Word {
name: "pwr",
aliases: &[],
category: "Chord",
stack: "(root -- root fifth)",
desc: "Power chord",
example: "c4 pwr => 60 67",
compile: Simple,
varargs: true,
},
Word {
name: "maj",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Major triad",
example: "c4 maj => 60 64 67",
compile: Simple,
varargs: true,
},
Word {
name: "m",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Minor triad",
example: "c4 m => 60 63 67",
compile: Simple,
varargs: true,
},
Word {
name: "dim",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Diminished triad",
example: "c4 dim => 60 63 66",
compile: Simple,
varargs: true,
},
Word {
name: "aug",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Augmented triad",
example: "c4 aug => 60 64 68",
compile: Simple,
varargs: true,
},
Word {
name: "sus2",
aliases: &[],
category: "Chord",
stack: "(root -- root second fifth)",
desc: "Suspended 2nd",
example: "c4 sus2 => 60 62 67",
compile: Simple,
varargs: true,
},
Word {
name: "sus4",
aliases: &[],
category: "Chord",
stack: "(root -- root fourth fifth)",
desc: "Suspended 4th",
example: "c4 sus4 => 60 65 67",
compile: Simple,
varargs: true,
},
// Chords - Seventh
Word {
name: "maj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Major 7th",
example: "c4 maj7 => 60 64 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "min7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor 7th",
example: "c4 min7 => 60 63 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Dominant 7th",
example: "c4 dom7 => 60 64 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dim7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Diminished 7th",
example: "c4 dim7 => 60 63 66 69",
compile: Simple,
varargs: true,
},
Word {
name: "m7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Half-diminished (min7b5)",
example: "c4 m7b5 => 60 63 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "minmaj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor-major 7th",
example: "c4 minmaj7 => 60 63 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "aug7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Augmented 7th",
example: "c4 aug7 => 60 64 68 70",
compile: Simple,
varargs: true,
},
Word {
name: "augmaj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Augmented major 7th",
example: "c4 augmaj7 => 60 64 68 71",
compile: Simple,
varargs: true,
},
Word {
name: "7sus4",
aliases: &[],
category: "Chord",
stack: "(root -- root fourth fifth seventh)",
desc: "Dominant 7 sus4",
example: "c4 7sus4 => 60 65 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "9sus4",
aliases: &[],
category: "Chord",
stack: "(root -- root fourth fifth seventh ninth)",
desc: "9 sus4",
example: "c4 9sus4 => 60 65 67 70 74",
compile: Simple,
varargs: true,
},
// Chords - Sixth
Word {
name: "maj6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Major 6th",
example: "c4 maj6 => 60 64 67 69",
compile: Simple,
varargs: true,
},
Word {
name: "min6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Minor 6th",
example: "c4 min6 => 60 63 67 69",
compile: Simple,
varargs: true,
},
Word {
name: "maj69",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth ninth)",
desc: "Major 6/9",
example: "c4 maj69 => 60 64 67 69 74",
compile: Simple,
varargs: true,
},
Word {
name: "min69",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth ninth)",
desc: "Minor 6/9",
example: "c4 min69 => 60 63 67 69 74",
compile: Simple,
varargs: true,
},
// Chords - Extended
Word {
name: "dom9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Dominant 9th",
example: "c4 dom9 => 60 64 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "maj9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Major 9th",
example: "c4 maj9 => 60 64 67 71 74",
compile: Simple,
varargs: true,
},
Word {
name: "min9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Minor 9th",
example: "c4 min9 => 60 63 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "dom11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Dominant 11th",
example: "c4 dom11 => 60 64 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "maj11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Major 11th",
example: "c4 maj11 => 60 64 67 71 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "min11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Minor 11th",
example: "c4 min11 => 60 63 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "dom13",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth thirteenth)",
desc: "Dominant 13th",
example: "c4 dom13 => 60 64 67 70 74 81",
compile: Simple,
varargs: true,
},
Word {
name: "maj13",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth thirteenth)",
desc: "Major 13th",
example: "c4 maj13 => 60 64 67 71 74 81",
compile: Simple,
varargs: true,
},
Word {
name: "min13",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth thirteenth)",
desc: "Minor 13th",
example: "c4 min13 => 60 63 67 70 74 81",
compile: Simple,
varargs: true,
},
// Chords - Add
Word {
name: "add9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Major add 9",
example: "c4 add9 => 60 64 67 74",
compile: Simple,
varargs: true,
},
Word {
name: "add11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth eleventh)",
desc: "Major add 11",
example: "c4 add11 => 60 64 67 77",
compile: Simple,
varargs: true,
},
Word {
name: "madd9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Minor add 9",
example: "c4 madd9 => 60 63 67 74",
compile: Simple,
varargs: true,
},
// Chords - Altered dominants
Word {
name: "dom7b9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh flatninth)",
desc: "7th flat 9",
example: "c4 dom7b9 => 60 64 67 70 73",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh sharpninth)",
desc: "7th sharp 9 (Hendrix chord)",
example: "c4 dom7s9 => 60 64 67 70 75",
compile: Simple,
varargs: true,
},
Word {
name: "dom7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third flatfifth seventh)",
desc: "7th flat 5",
example: "c4 dom7b5 => 60 64 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s5",
aliases: &[],
category: "Chord",
stack: "(root -- root third sharpfifth seventh)",
desc: "7th sharp 5",
example: "c4 dom7s5 => 60 64 68 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh sharpelev)",
desc: "7th sharp 11 (lydian dominant)",
example: "c4 dom7s11 => 60 64 67 70 78",
compile: Simple,
varargs: true,
},
];

View File

@@ -0,0 +1,524 @@
//! Word metadata for sequencing: probability, timing, context queries, generators.
use super::{Word, WordCompile::*};
pub(super) const WORDS: &[Word] = &[
// Probability
Word {
name: "rand",
aliases: &[],
category: "Probability",
stack: "(min max -- n|f)",
desc: "Random in range. Int if both args are int, float otherwise",
example: "1 6 rand => 4 | 0.0 1.0 rand => 0.42",
compile: Simple,
varargs: false,
},
Word {
name: "exprand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward lo. Both args must be positive",
example: "1.0 100.0 exprand => 3.7",
compile: Simple,
varargs: false,
},
Word {
name: "logrand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward hi. Both args must be positive",
example: "1.0 100.0 logrand => 87.2",
compile: Simple,
varargs: false,
},
Word {
name: "seed",
aliases: &[],
category: "Probability",
stack: "(n --)",
desc: "Set random seed",
example: "12345 seed",
compile: Simple,
varargs: false,
},
Word {
name: "coin",
aliases: &[],
category: "Probability",
stack: "(-- bool)",
desc: "50/50 random boolean",
example: "coin => 0 or 1",
compile: Simple,
varargs: false,
},
Word {
name: "chance",
aliases: &[],
category: "Probability",
stack: "(quot prob --)",
desc: "Execute quotation with probability (0.0-1.0)",
example: "{ 2 distort } 0.75 chance",
compile: Simple,
varargs: false,
},
Word {
name: "prob",
aliases: &[],
category: "Probability",
stack: "(quot pct --)",
desc: "Execute quotation with probability (0-100)",
example: "{ 2 distort } 75 prob",
compile: Simple,
varargs: false,
},
Word {
name: "choose",
aliases: &[],
category: "Probability",
stack: "(..n n -- val)",
desc: "Random pick from n items",
example: "1 2 3 3 choose",
compile: Simple,
varargs: true,
},
Word {
name: "cycle",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by step runs",
example: "60 64 67 3 cycle",
compile: Simple,
varargs: true,
},
Word {
name: "pcycle",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by pattern iteration",
example: "60 64 67 3 pcycle",
compile: Simple,
varargs: true,
},
Word {
name: "bounce",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Ping-pong cycle through n items by step runs",
example: "60 64 67 72 4 bounce",
compile: Simple,
varargs: true,
},
Word {
name: "pbounce",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Ping-pong cycle through n items by pattern iteration",
example: "60 64 67 72 4 pbounce",
compile: Simple,
varargs: true,
},
Word {
name: "index",
aliases: &[],
category: "Probability",
stack: "(v1..vn n idx -- selected)",
desc: "Select item at explicit index",
example: "[ c4 e4 g4 ] step index",
compile: Simple,
varargs: true,
},
Word {
name: "wchoose",
aliases: &[],
category: "Probability",
stack: "(v1 w1 v2 w2 ... n -- selected)",
desc: "Weighted random pick from n value/weight pairs",
example: "60 0.6 64 0.3 67 0.1 3 wchoose",
compile: Simple,
varargs: true,
},
Word {
name: "always",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Always execute quotation",
example: "{ 2 distort } always",
compile: Probability(1.0),
varargs: false,
},
Word {
name: "never",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Never execute quotation",
example: "{ 2 distort } never",
compile: Probability(0.0),
varargs: false,
},
Word {
name: "often",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 75% of the time",
example: "{ 2 distort } often",
compile: Probability(0.75),
varargs: false,
},
Word {
name: "sometimes",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 50% of the time",
example: "{ 2 distort } sometimes",
compile: Probability(0.5),
varargs: false,
},
Word {
name: "rarely",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 25% of the time",
example: "{ 2 distort } rarely",
compile: Probability(0.25),
varargs: false,
},
Word {
name: "almostNever",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 10% of the time",
example: "{ 2 distort } almostNever",
compile: Probability(0.1),
varargs: false,
},
Word {
name: "almostAlways",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 90% of the time",
example: "{ 2 distort } almostAlways",
compile: Probability(0.9),
varargs: false,
},
// Time
Word {
name: "every",
aliases: &[],
category: "Time",
stack: "(quot n --)",
desc: "Execute quotation every nth iteration",
example: "{ 2 distort } 4 every",
compile: Simple,
varargs: false,
},
Word {
name: "except",
aliases: &[],
category: "Time",
stack: "(quot n --)",
desc: "Execute quotation on all iterations except every nth",
example: "{ 2 distort } 4 except",
compile: Simple,
varargs: false,
},
Word {
name: "every+",
aliases: &[],
category: "Time",
stack: "(quot n offset --)",
desc: "Execute quotation every nth iteration with phase offset",
example: "{ snare } 4 2 every+ => fires at iter 2, 6, 10...",
compile: Simple,
varargs: false,
},
Word {
name: "except+",
aliases: &[],
category: "Time",
stack: "(quot n offset --)",
desc: "Skip quotation every nth iteration with phase offset",
example: "{ snare } 4 2 except+ => skips at iter 2, 6, 10...",
compile: Simple,
varargs: false,
},
Word {
name: "bjork",
aliases: &[],
category: "Time",
stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over step runs",
example: "{ 2 distort } 3 8 bjork",
compile: Simple,
varargs: false,
},
Word {
name: "pbjork",
aliases: &[],
category: "Time",
stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over pattern iterations",
example: "{ 2 distort } 3 8 pbjork",
compile: Simple,
varargs: false,
},
Word {
name: "loop",
aliases: &[],
category: "Time",
stack: "(n --)",
desc: "Fit sample to n beats",
example: "\"break\" s 4 loop @",
compile: Simple,
varargs: false,
},
Word {
name: "tempo!",
aliases: &[],
category: "Time",
stack: "(bpm --)",
desc: "Set global tempo",
example: "140 tempo!",
compile: Simple,
varargs: false,
},
Word {
name: "speed!",
aliases: &[],
category: "Time",
stack: "(multiplier --)",
desc: "Set pattern speed multiplier",
example: "2.0 speed!",
compile: Simple,
varargs: false,
},
Word {
name: "at",
aliases: &[],
category: "Time",
stack: "(v1..vn --)",
desc: "Set delta context for emit timing",
example: "0 0.5 at kick s . => emits at 0 and 0.5 of step",
compile: Simple,
varargs: true,
},
// Context
Word {
name: "step",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current step index",
example: "step => 0",
compile: Context("step"),
varargs: false,
},
Word {
name: "beat",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Current beat position",
example: "beat => 4.5",
compile: Context("beat"),
varargs: false,
},
Word {
name: "pattern",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current pattern index",
example: "pattern => 0",
compile: Context("pattern"),
varargs: false,
},
Word {
name: "pbank",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current pattern's bank index",
example: "pbank => 0",
compile: Context("bank"),
varargs: false,
},
Word {
name: "tempo",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Current BPM",
example: "tempo => 120.0",
compile: Context("tempo"),
varargs: false,
},
Word {
name: "phase",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Phase in bar (0-1)",
example: "phase => 0.25",
compile: Context("phase"),
varargs: false,
},
Word {
name: "slot",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current slot number",
example: "slot => 0",
compile: Context("slot"),
varargs: false,
},
Word {
name: "runs",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Times this step ran",
example: "runs => 3",
compile: Context("runs"),
varargs: false,
},
Word {
name: "iter",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Pattern iteration count",
example: "iter => 2",
compile: Context("iter"),
varargs: false,
},
Word {
name: "stepdur",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Step duration in seconds",
example: "stepdur => 0.125",
compile: Context("stepdur"),
varargs: false,
},
Word {
name: "fill",
aliases: &[],
category: "Context",
stack: "(-- bool)",
desc: "True when fill is on (f key)",
example: "\"snare\" s . fill ?",
compile: Context("fill"),
varargs: false,
},
// Desktop
#[cfg(feature = "desktop")]
Word {
name: "mx",
aliases: &[],
category: "Desktop",
stack: "(-- x)",
desc: "Normalized mouse X position (0-1)",
example: "mx 440 880 range freq",
compile: Context("mx"),
varargs: false,
},
#[cfg(feature = "desktop")]
Word {
name: "my",
aliases: &[],
category: "Desktop",
stack: "(-- y)",
desc: "Normalized mouse Y position (0-1)",
example: "my 0.1 0.9 range gain",
compile: Context("my"),
varargs: false,
},
#[cfg(feature = "desktop")]
Word {
name: "mdown",
aliases: &[],
category: "Desktop",
stack: "(-- bool)",
desc: "1 when mouse button held, 0 otherwise",
example: "mdown { \"crash\" s . } ?",
compile: Context("mdown"),
varargs: false,
},
// Generator
Word {
name: "..",
aliases: &[],
category: "Generator",
stack: "(start end -- start start+1 ... end)",
desc: "Push arithmetic sequence from start to end",
example: "1 4 .. => 1 2 3 4",
compile: Simple,
varargs: false,
},
Word {
name: ".,",
aliases: &[],
category: "Generator",
stack: "(start end step -- start start+step ...)",
desc: "Push arithmetic sequence with custom step",
example: "0 1 0.25 ., => 0 0.25 0.5 0.75 1",
compile: Simple,
varargs: false,
},
Word {
name: "gen",
aliases: &[],
category: "Generator",
stack: "(quot n -- results...)",
desc: "Execute quotation n times, push all results",
example: "{ 1 6 rand } 4 gen => 4 random values",
compile: Simple,
varargs: true,
},
Word {
name: "geom..",
aliases: &[],
category: "Generator",
stack: "(start ratio count -- start start*r start*r^2 ...)",
desc: "Push geometric sequence",
example: "1 2 4 geom.. => 1 2 4 8",
compile: Simple,
varargs: false,
},
Word {
name: "euclid",
aliases: &[],
category: "Generator",
stack: "(k n -- i1 i2 ... ik)",
desc: "Push indices for k hits evenly distributed over n steps",
example: "4 8 euclid => 0 2 4 6",
compile: Simple,
varargs: false,
},
Word {
name: "euclidrot",
aliases: &[],
category: "Generator",
stack: "(k n r -- i1 i2 ... ik)",
desc: "Push Euclidean indices with rotation r",
example: "3 8 2 euclidrot => 1 4 6",
compile: Simple,
varargs: false,
},
];

View File

@@ -0,0 +1,875 @@
//! Word metadata for sound commands, sample/oscillator params, FM, modulation, and LFO.
use super::{Word, WordCompile::*};
pub(super) const WORDS: &[Word] = &[
// Sound
Word {
name: "sound",
aliases: &["s"],
category: "Sound",
stack: "(name --)",
desc: "Begin sound command",
example: "\"kick\" sound",
compile: Simple,
varargs: false,
},
Word {
name: ".",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Emit current sound",
example: "\"kick\" s . . . .",
compile: Simple,
varargs: false,
},
Word {
name: "arp",
aliases: &[],
category: "Sound",
stack: "(v1..vn -- arplist)",
desc: "Wrap stack values as arpeggio list for spreading across deltas",
example: "c4 e4 g4 b4 arp note => arpeggio",
compile: Simple,
varargs: true,
},
Word {
name: "clear",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Clear sound register (sound and all params)",
example: "\"kick\" s 0.5 gain . clear \"hat\" s .",
compile: Simple,
varargs: false,
},
Word {
name: "all",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Apply current params to all sounds",
example: "500 lpf 0.5 verb all",
compile: Simple,
varargs: false,
},
Word {
name: "noall",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Clear global params",
example: "noall",
compile: Simple,
varargs: false,
},
// Recording
Word {
name: "rec",
aliases: &[],
category: "Sound",
stack: "(name --)",
desc: "Toggle recording audio output to named sample",
example: "\"loop1\" rec",
compile: Simple,
varargs: false,
},
Word {
name: "overdub",
aliases: &["dub"],
category: "Sound",
stack: "(name --)",
desc: "Toggle overdub recording onto existing named sample",
example: "\"loop1\" overdub",
compile: Simple,
varargs: false,
},
Word {
name: "orec",
aliases: &[],
category: "Sound",
stack: "(name orbit --)",
desc: "Toggle recording a single orbit into named sample",
example: "\"drums\" 0 orec",
compile: Simple,
varargs: false,
},
Word {
name: "odub",
aliases: &[],
category: "Sound",
stack: "(name orbit --)",
desc: "Toggle overdub recording a single orbit onto named sample",
example: "\"drums\" 0 odub",
compile: Simple,
varargs: false,
},
// Sample
Word {
name: "bank",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set sample bank suffix",
example: "\"a\" bank",
compile: Param,
varargs: true,
},
Word {
name: "time",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set time offset",
example: "0.1 time",
compile: Param,
varargs: true,
},
Word {
name: "repeat",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set repeat count",
example: "4 repeat",
compile: Param,
varargs: true,
},
Word {
name: "dur",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set duration",
example: "0.5 dur",
compile: Param,
varargs: true,
},
Word {
name: "gate",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set gate time",
example: "0.8 gate",
compile: Param,
varargs: true,
},
Word {
name: "speed",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set playback speed",
example: "1.5 speed",
compile: Param,
varargs: true,
},
Word {
name: "begin",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set sample start (0-1)",
example: "0.25 begin",
compile: Param,
varargs: true,
},
Word {
name: "end",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set sample end (0-1)",
example: "0.75 end",
compile: Param,
varargs: true,
},
Word {
name: "slice",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Divide sample into N equal slices",
example: r#""break" s 8 slice 3 pick ."#,
compile: Param,
varargs: true,
},
Word {
name: "pick",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Select which slice to play (0-indexed, wraps)",
example: r#""break" s 8 slice 3 pick ."#,
compile: Param,
varargs: true,
},
Word {
name: "voice",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set voice number",
example: "1 voice",
compile: Param,
varargs: true,
},
Word {
name: "orbit",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set orbit/bus",
example: "0 orbit",
compile: Param,
varargs: true,
},
Word {
name: "n",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set sample number",
example: "0 n",
compile: Param,
varargs: true,
},
Word {
name: "cut",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set cut group",
example: "1 cut",
compile: Param,
varargs: true,
},
Word {
name: "reset",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Reset parameter",
example: "1 reset",
compile: Param,
varargs: true,
},
// Oscillator
Word {
name: "freq",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set frequency (Hz)",
example: "440 freq",
compile: Param,
varargs: true,
},
Word {
name: "detune",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set detune amount",
example: "0.01 detune",
compile: Param,
varargs: true,
},
Word {
name: "glide",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set glide/portamento",
example: "0.1 glide",
compile: Param,
varargs: true,
},
Word {
name: "pw",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set pulse width",
example: "0.5 pw",
compile: Param,
varargs: true,
},
Word {
name: "wave",
aliases: &["waveform"],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set drum waveform [0,1]: 0=sine, 0.5=tri, 1=saw",
example: "0.5 wave",
compile: Param,
varargs: true,
},
Word {
name: "spread",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set stereo spread",
example: "0.5 spread",
compile: Param,
varargs: true,
},
Word {
name: "mult",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set multiplier",
example: "2 mult",
compile: Param,
varargs: true,
},
Word {
name: "warp",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set warp amount",
example: "0.5 warp",
compile: Param,
varargs: true,
},
Word {
name: "mirror",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set mirror",
example: "1 mirror",
compile: Param,
varargs: true,
},
Word {
name: "harmonics",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set harmonics (mutable only)",
example: "4 harmonics",
compile: Param,
varargs: true,
},
Word {
name: "timbre",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set timbre (mutable only)",
example: "0.5 timbre",
compile: Param,
varargs: true,
},
Word {
name: "morph",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set morph (mutable only)",
example: "0.5 morph",
compile: Param,
varargs: true,
},
Word {
name: "coarse",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set coarse tune",
example: "12 coarse",
compile: Param,
varargs: true,
},
Word {
name: "sub",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set sub oscillator level",
example: "0.5 sub",
compile: Param,
varargs: true,
},
Word {
name: "suboct",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set sub oscillator octave",
example: "2 suboct",
compile: Param,
varargs: true,
},
Word {
name: "subwave",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set sub oscillator waveform",
example: "1 subwave",
compile: Param,
varargs: true,
},
Word {
name: "note",
aliases: &[],
category: "Oscillator",
stack: "(v.. --)",
desc: "Set MIDI note",
example: "60 note",
compile: Param,
varargs: true,
},
// Wavetable
Word {
name: "scan",
aliases: &[],
category: "Wavetable",
stack: "(v.. --)",
desc: "Set wavetable scan position (0-1)",
example: "0.5 scan",
compile: Param,
varargs: true,
},
Word {
name: "wtlen",
aliases: &[],
category: "Wavetable",
stack: "(v.. --)",
desc: "Set wavetable cycle length in samples",
example: "2048 wtlen",
compile: Param,
varargs: true,
},
Word {
name: "scanlfo",
aliases: &[],
category: "Wavetable",
stack: "(v.. --)",
desc: "Set scan LFO rate (Hz)",
example: "0.2 scanlfo",
compile: Param,
varargs: true,
},
Word {
name: "scandepth",
aliases: &[],
category: "Wavetable",
stack: "(v.. --)",
desc: "Set scan LFO depth (0-1)",
example: "0.4 scandepth",
compile: Param,
varargs: true,
},
Word {
name: "scanshape",
aliases: &[],
category: "Wavetable",
stack: "(v.. --)",
desc: "Set scan LFO shape (sine/tri/saw/square/sh)",
example: "\"tri\" scanshape",
compile: Param,
varargs: true,
},
// FM
Word {
name: "fm",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM frequency",
example: "200 fm",
compile: Param,
varargs: true,
},
Word {
name: "fmh",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM harmonic ratio",
example: "2 fmh",
compile: Param,
varargs: true,
},
Word {
name: "fmshape",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM shape",
example: "0 fmshape",
compile: Param,
varargs: true,
},
Word {
name: "fme",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM envelope",
example: "0.5 fme",
compile: Param,
varargs: true,
},
Word {
name: "fma",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM attack",
example: "0.01 fma",
compile: Param,
varargs: true,
},
Word {
name: "fmd",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM decay",
example: "0.1 fmd",
compile: Param,
varargs: true,
},
Word {
name: "fms",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM sustain",
example: "0.5 fms",
compile: Param,
varargs: true,
},
Word {
name: "fmr",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM release",
example: "0.1 fmr",
compile: Param,
varargs: true,
},
Word {
name: "fm2",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM operator 2 depth",
example: "1.5 fm2",
compile: Param,
varargs: true,
},
Word {
name: "fm2h",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM operator 2 harmonic ratio",
example: "3 fm2h",
compile: Param,
varargs: true,
},
Word {
name: "fmalgo",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM algorithm (0=cascade 1=parallel 2=branch)",
example: "0 fmalgo",
compile: Param,
varargs: true,
},
Word {
name: "fmfb",
aliases: &[],
category: "FM",
stack: "(v.. --)",
desc: "Set FM feedback amount",
example: "0.5 fmfb",
compile: Param,
varargs: true,
},
// Modulation
Word {
name: "vib",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set vibrato rate",
example: "5 vib",
compile: Param,
varargs: true,
},
Word {
name: "vibmod",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set vibrato depth",
example: "0.5 vibmod",
compile: Param,
varargs: true,
},
Word {
name: "vibshape",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set vibrato shape",
example: "0 vibshape",
compile: Param,
varargs: true,
},
Word {
name: "am",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set AM frequency",
example: "10 am",
compile: Param,
varargs: true,
},
Word {
name: "amdepth",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set AM depth",
example: "0.5 amdepth",
compile: Param,
varargs: true,
},
Word {
name: "amshape",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set AM shape",
example: "0 amshape",
compile: Param,
varargs: true,
},
Word {
name: "rm",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set RM frequency",
example: "100 rm",
compile: Param,
varargs: true,
},
Word {
name: "rmdepth",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set RM depth",
example: "0.5 rmdepth",
compile: Param,
varargs: true,
},
Word {
name: "rmshape",
aliases: &[],
category: "Modulation",
stack: "(v.. --)",
desc: "Set RM shape",
example: "0 rmshape",
compile: Param,
varargs: true,
},
// LFO
Word {
name: "ramp",
aliases: &[],
category: "LFO",
stack: "(freq curve -- val)",
desc: "Ramp [0,1]: fract(freq*beat)^curve",
example: "0.25 2.0 ramp",
compile: Simple,
varargs: false,
},
Word {
name: "range",
aliases: &[],
category: "LFO",
stack: "(val min max -- scaled)",
desc: "Scale [0,1] to [min,max]",
example: "0.5 200 800 range => 500",
compile: Simple,
varargs: false,
},
Word {
name: "linramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Linear ramp (curve=1)",
example: "1.0 linramp",
compile: Simple,
varargs: false,
},
Word {
name: "expramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Exponential ramp (curve=3)",
example: "0.25 expramp",
compile: Simple,
varargs: false,
},
Word {
name: "logramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Logarithmic ramp (curve=0.3)",
example: "2.0 logramp",
compile: Simple,
varargs: false,
},
Word {
name: "triangle",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Triangle wave [0,1]: 0→1→0",
example: "0.5 triangle",
compile: Simple,
varargs: false,
},
Word {
name: "perlin",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Perlin noise [0,1] sampled at freq*beat",
example: "0.25 perlin",
compile: Simple,
varargs: false,
},
// Audio-rate Modulation DSL
Word {
name: "lfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Sine oscillation: min~max:period",
example: "200 4000 2 lfo lpf",
compile: Simple,
varargs: false,
},
Word {
name: "tlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Triangle oscillation: min~max:periodt",
example: "0.3 0.7 0.5 tlfo pan",
compile: Simple,
varargs: false,
},
Word {
name: "wlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Sawtooth oscillation: min~max:periodw",
example: "200 4000 1 wlfo lpf",
compile: Simple,
varargs: false,
},
Word {
name: "qlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Square oscillation: min~max:periodq",
example: "0.0 1.0 0.25 qlfo gain",
compile: Simple,
varargs: false,
},
Word {
name: "slide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Linear transition: start>end:dur",
example: "0 1 0.01 slide gain",
compile: Simple,
varargs: false,
},
Word {
name: "expslide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Exponential transition: start>end:dure",
example: "0 1 0.5 expslide gain",
compile: Simple,
varargs: false,
},
Word {
name: "sslide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Smooth transition: start>end:durs",
example: "200 800 1 sslide lpf",
compile: Simple,
varargs: false,
},
Word {
name: "jit",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Random hold: min?max:period",
example: "200 4000 0.5 jit lpf",
compile: Simple,
varargs: false,
},
Word {
name: "sjit",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Smooth random: min?max:periods",
example: "200 4000 0.5 sjit lpf",
compile: Simple,
varargs: false,
},
Word {
name: "drunk",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Drunk walk: min?max:periodd",
example: "200 4000 0.5 drunk lpf",
compile: Simple,
varargs: false,
},
Word {
name: "env",
aliases: &[],
category: "Audio Modulation",
stack: "(start t1 d1 ... -- str)",
desc: "Multi-segment envelope: start>t1:d1>...",
example: "0 1 0.01 0.7 0.1 0 2 env gain",
compile: Simple,
varargs: false,
},
];

View File

@@ -0,0 +1,12 @@
[package]
name = "cagire-markdown"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Markdown rendering for cagire sequencer"
[dependencies]
minimad = "0.13"
ratatui = "0.30"

View File

@@ -0,0 +1,17 @@
//! Syntax highlighting trait for fenced code blocks in markdown.
use ratatui::style::Style;
/// Produce styled spans from a single line of source code.
pub trait CodeHighlighter {
fn highlight(&self, line: &str) -> Vec<(Style, String)>;
}
/// Pass-through highlighter that applies no styling.
pub struct NoHighlight;
impl CodeHighlighter for NoHighlight {
fn highlight(&self, line: &str) -> Vec<(Style, String)> {
vec![(Style::default(), line.to_string())]
}
}

View File

@@ -0,0 +1,9 @@
//! Parse markdown into styled ratatui lines with pluggable syntax highlighting.
mod highlighter;
mod parser;
mod theme;
pub use highlighter::{CodeHighlighter, NoHighlight};
pub use parser::{parse, CodeBlock, ParsedMarkdown};
pub use theme::{DefaultTheme, MarkdownTheme};

View File

@@ -0,0 +1,390 @@
//! Parse markdown text into styled ratatui lines with syntax-highlighted code blocks.
use minimad::{Composite, CompositeStyle, Compound, Line, TableRow};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line as RLine, Span};
use crate::highlighter::CodeHighlighter;
use crate::theme::MarkdownTheme;
/// Span of lines within a parsed document that form a fenced code block.
pub struct CodeBlock {
pub start_line: usize,
pub end_line: usize,
pub source: String,
}
/// Result of parsing a markdown string: styled lines and extracted code blocks.
pub struct ParsedMarkdown {
pub lines: Vec<RLine<'static>>,
pub code_blocks: Vec<CodeBlock>,
}
/// Parse markdown text into themed, syntax-highlighted ratatui lines.
pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
md: &str,
theme: &T,
highlighter: &H,
) -> ParsedMarkdown {
let processed = preprocess_markdown(md);
let text = minimad::Text::from(processed.as_str());
let mut lines = Vec::new();
let mut code_line_nr: usize = 0;
let mut table_buffer: Vec<TableRow> = Vec::new();
let mut code_blocks: Vec<CodeBlock> = Vec::new();
let mut current_block_start: Option<usize> = None;
let mut current_block_source: Vec<String> = Vec::new();
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, theme: &T| {
if buf.is_empty() {
return;
}
let col_widths = compute_column_widths(buf);
for (row_idx, row) in buf.drain(..).enumerate() {
out.push(render_table_row(row, row_idx, &col_widths, theme));
}
};
let close_block = |start: Option<usize>,
source: &mut Vec<String>,
blocks: &mut Vec<CodeBlock>,
lines: &[RLine<'static>]| {
if let Some(start) = start {
blocks.push(CodeBlock {
start_line: start,
end_line: lines.len(),
source: std::mem::take(source).join("\n"),
});
}
};
for line in text.lines {
let is_code = matches!(&line, Line::Normal(c) if c.style == CompositeStyle::Code);
if !is_code {
close_block(
current_block_start.take(),
&mut current_block_source,
&mut code_blocks,
&lines,
);
}
match line {
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
flush_table(&mut table_buffer, &mut lines, theme);
code_line_nr += 1;
if current_block_start.is_none() {
current_block_start = Some(lines.len());
}
let raw: String = composite
.compounds
.iter()
.map(|c: &minimad::Compound| c.src)
.collect();
current_block_source.push(raw.clone());
let mut spans = vec![
Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()),
Span::styled("", theme.code_border()),
];
spans.extend(
highlighter
.highlight(&raw)
.into_iter()
.map(|(style, text)| Span::styled(text, style)),
);
lines.push(RLine::from(spans));
}
Line::Normal(composite) => {
flush_table(&mut table_buffer, &mut lines, theme);
code_line_nr = 0;
lines.push(composite_to_line(composite, theme));
}
Line::TableRow(row) => {
code_line_nr = 0;
table_buffer.push(row);
}
Line::TableRule(_) => {}
_ => {
flush_table(&mut table_buffer, &mut lines, theme);
code_line_nr = 0;
lines.push(RLine::from(""));
}
}
}
close_block(
current_block_start.take(),
&mut current_block_source,
&mut code_blocks,
&lines,
);
flush_table(&mut table_buffer, &mut lines, theme);
ParsedMarkdown { lines, code_blocks }
}
fn preprocess_markdown(md: &str) -> String {
let mut out = String::with_capacity(md.len());
for line in md.lines() {
let line = convert_dash_lists(line);
let mut result = String::with_capacity(line.len());
let mut chars = line.char_indices().peekable();
let bytes = line.as_bytes();
while let Some((i, c)) = chars.next() {
if c == '`' {
result.push(c);
for (_, ch) in chars.by_ref() {
result.push(ch);
if ch == '`' {
break;
}
}
continue;
}
if c == '_' {
let before_is_space = i == 0 || bytes[i - 1] == b' ';
if before_is_space {
if let Some(end) = line[i + 1..].find('_') {
let inner = &line[i + 1..i + 1 + end];
if !inner.is_empty() {
result.push('*');
result.push_str(inner);
result.push('*');
for _ in 0..end {
chars.next();
}
chars.next();
continue;
}
}
}
}
result.push(c);
}
out.push_str(&result);
out.push('\n');
}
out
}
fn convert_dash_lists(line: &str) -> String {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("- ") {
let indent = line.len() - trimmed.len();
format!("{}* {}", " ".repeat(indent), rest)
} else {
line.to_string()
}
}
fn cell_text_width(cell: &Composite) -> usize {
cell.compounds.iter().map(|c| c.src.chars().count()).sum()
}
fn compute_column_widths(rows: &[TableRow]) -> Vec<usize> {
let mut widths: Vec<usize> = Vec::new();
for row in rows {
for (i, cell) in row.cells.iter().enumerate() {
let w = cell_text_width(cell);
if i >= widths.len() {
widths.push(w);
} else if w > widths[i] {
widths[i] = w;
}
}
}
widths
}
fn render_table_row<T: MarkdownTheme>(
row: TableRow,
row_idx: usize,
col_widths: &[usize],
theme: &T,
) -> RLine<'static> {
let is_header = row_idx == 0;
let bg = if is_header {
theme.table_header_bg()
} else if row_idx.is_multiple_of(2) {
theme.table_row_even()
} else {
theme.table_row_odd()
};
let base_style = if is_header {
theme.text().bg(bg).add_modifier(Modifier::BOLD)
} else {
theme.text().bg(bg)
};
let sep_style = theme.code_border().bg(bg);
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, cell) in row.cells.into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled("", sep_style));
}
let target_width = col_widths.get(i).copied().unwrap_or(0);
let cell_width = cell
.compounds
.iter()
.map(|c| c.src.chars().count())
.sum::<usize>();
for compound in cell.compounds {
compound_to_spans(compound, base_style, &mut spans, theme);
}
let padding = target_width.saturating_sub(cell_width);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), base_style));
}
}
RLine::from(spans)
}
fn composite_to_line<T: MarkdownTheme>(composite: Composite, theme: &T) -> RLine<'static> {
let base_style = match composite.style {
CompositeStyle::Header(1) => theme.h1(),
CompositeStyle::Header(2) => theme.h2(),
CompositeStyle::Header(_) => theme.h3(),
CompositeStyle::ListItem(_) => theme.list(),
CompositeStyle::Quote => theme.quote(),
CompositeStyle::Code => theme.code(),
CompositeStyle::Paragraph => theme.text(),
};
let prefix: String = match composite.style {
CompositeStyle::ListItem(depth) => {
let indent = " ".repeat(depth as usize);
format!("{indent}")
}
CompositeStyle::Quote => "".to_string(),
_ => String::new(),
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix, base_style));
}
for compound in composite.compounds {
compound_to_spans(compound, base_style, &mut spans, theme);
}
RLine::from(spans)
}
fn compound_to_spans<T: MarkdownTheme>(
compound: Compound,
base: Style,
out: &mut Vec<Span<'static>>,
theme: &T,
) {
let mut style = base;
if compound.bold {
style = style.add_modifier(Modifier::BOLD);
}
if compound.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if compound.code {
style = theme.code();
}
if compound.strikeout {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
let src = compound.src.to_string();
let link_style = theme.link();
let mut rest = src.as_str();
while let Some(start) = rest.find('[') {
let after_bracket = &rest[start + 1..];
if let Some(text_end) = after_bracket.find("](") {
let url_start = start + 1 + text_end + 2;
if let Some(url_end) = rest[url_start..].find(')') {
if start > 0 {
out.push(Span::styled(rest[..start].to_string(), style));
}
let text = &rest[start + 1..start + 1 + text_end];
let url = &rest[url_start..url_start + url_end];
if text == url {
out.push(Span::styled(url.to_string(), link_style));
} else {
out.push(Span::styled(text.to_string(), link_style));
out.push(Span::styled(format!(" ({url})"), theme.link_url()));
}
rest = &rest[url_start + url_end + 1..];
continue;
}
}
out.push(Span::styled(rest[..start + 1].to_string(), style));
rest = &rest[start + 1..];
}
if !rest.is_empty() {
out.push(Span::styled(rest.to_string(), style));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlighter::NoHighlight;
use crate::theme::DefaultTheme;
#[test]
fn test_preprocess_underscores() {
assert_eq!(preprocess_markdown("_italic_"), "*italic*\n");
assert_eq!(preprocess_markdown("word_with_underscores"), "word_with_underscores\n");
assert_eq!(preprocess_markdown("hello _world_"), "hello *world*\n");
}
#[test]
fn test_preprocess_dash_lists() {
assert_eq!(convert_dash_lists("- item"), "* item");
assert_eq!(convert_dash_lists(" - nested"), " * nested");
assert_eq!(convert_dash_lists("not-a-list"), "not-a-list");
}
#[test]
fn test_parse_headings() {
let md = "# H1\n## H2\n### H3";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(parsed.lines.len(), 3);
}
#[test]
fn test_parse_code_block() {
let md = "```\ncode line\n```";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert!(!parsed.lines.is_empty());
assert_eq!(parsed.code_blocks.len(), 1);
assert_eq!(parsed.code_blocks[0].source, "code line");
}
#[test]
fn test_parse_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(parsed.lines.len(), 2);
}
#[test]
fn test_default_theme_works() {
let md = "Hello **world**";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(parsed.lines.len(), 1);
}
#[test]
fn test_multiple_code_blocks() {
let md = "text\n```\nfirst\n```\nmore text\n```\nsecond line 1\nsecond line 2\n```";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(parsed.code_blocks.len(), 2);
assert_eq!(parsed.code_blocks[0].source, "first");
assert_eq!(parsed.code_blocks[1].source, "second line 1\nsecond line 2");
}
}

View File

@@ -0,0 +1,81 @@
//! Style provider trait for markdown rendering.
use ratatui::style::{Color, Modifier, Style};
/// Style provider for each markdown element type.
pub trait MarkdownTheme {
fn h1(&self) -> Style;
fn h2(&self) -> Style;
fn h3(&self) -> Style;
fn text(&self) -> Style;
fn code(&self) -> Style;
fn code_border(&self) -> Style;
fn link(&self) -> Style;
fn link_url(&self) -> Style;
fn quote(&self) -> Style;
fn list(&self) -> Style;
fn table_header_bg(&self) -> Color;
fn table_row_even(&self) -> Color;
fn table_row_odd(&self) -> Color;
}
/// Fallback theme with hardcoded terminal colors, used in tests.
pub struct DefaultTheme;
impl MarkdownTheme for DefaultTheme {
fn h1(&self) -> Style {
Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
fn h2(&self) -> Style {
Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD)
}
fn h3(&self) -> Style {
Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD)
}
fn text(&self) -> Style {
Style::new().fg(Color::White)
}
fn code(&self) -> Style {
Style::new().fg(Color::Yellow)
}
fn code_border(&self) -> Style {
Style::new().fg(Color::DarkGray)
}
fn link(&self) -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn link_url(&self) -> Style {
Style::new().fg(Color::DarkGray)
}
fn quote(&self) -> Style {
Style::new().fg(Color::Gray)
}
fn list(&self) -> Style {
Style::new().fg(Color::White)
}
fn table_header_bg(&self) -> Color {
Color::DarkGray
}
fn table_row_even(&self) -> Color {
Color::Reset
}
fn table_row_odd(&self) -> Color {
Color::Reset
}
}

18
crates/project/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "cagire-project"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Project data structures for cagire sequencer"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rmp-serde = "1"
brotli = "7"
base64 = "0.22"
[dev-dependencies]
flate2 = "1"

143
crates/project/src/file.rs Normal file
View File

@@ -0,0 +1,143 @@
//! JSON-based project file persistence with versioned format.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::project::{Bank, PatternSpeed, Project};
const VERSION: u8 = 1;
const EXTENSION: &str = "cagire";
fn ensure_extension(path: &Path) -> PathBuf {
if path.extension().map(|e| e == EXTENSION).unwrap_or(false) {
path.to_path_buf()
} else {
path.with_extension(EXTENSION)
}
}
#[derive(Serialize, Deserialize)]
struct ProjectFile {
version: u8,
banks: Vec<Bank>,
#[serde(default)]
sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
tempo: f64,
#[serde(default)]
playing_patterns: Vec<(usize, usize)>,
#[serde(default, skip_serializing_if = "String::is_empty")]
prelude: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
script: String,
#[serde(default, skip_serializing_if = "is_default_speed")]
script_speed: PatternSpeed,
#[serde(default = "default_script_length", skip_serializing_if = "is_default_script_length")]
script_length: usize,
}
fn is_default_speed(s: &PatternSpeed) -> bool {
*s == PatternSpeed::default()
}
fn default_script_length() -> usize {
16
}
fn is_default_script_length(n: &usize) -> bool {
*n == default_script_length()
}
fn default_tempo() -> f64 {
120.0
}
impl From<&Project> for ProjectFile {
fn from(project: &Project) -> Self {
Self {
version: VERSION,
banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(),
tempo: project.tempo,
playing_patterns: project.playing_patterns.clone(),
prelude: project.prelude.clone(),
script: project.script.clone(),
script_speed: project.script_speed,
script_length: project.script_length,
}
}
}
impl From<ProjectFile> for Project {
fn from(file: ProjectFile) -> Self {
let mut project = Self {
banks: file.banks,
sample_paths: file.sample_paths,
tempo: file.tempo,
playing_patterns: file.playing_patterns,
prelude: file.prelude,
script: file.script,
script_speed: file.script_speed,
script_length: file.script_length,
};
project.normalize();
project
}
}
/// Error returned by project save/load operations.
#[derive(Debug)]
pub enum FileError {
Io(io::Error),
Json(serde_json::Error),
Version(u8),
}
impl std::fmt::Display for FileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileError::Io(e) => write!(f, "IO error: {e}"),
FileError::Json(e) => write!(f, "JSON error: {e}"),
FileError::Version(v) => write!(f, "Unsupported version: {v}"),
}
}
}
impl From<io::Error> for FileError {
fn from(e: io::Error) -> Self {
FileError::Io(e)
}
}
impl From<serde_json::Error> for FileError {
fn from(e: serde_json::Error) -> Self {
FileError::Json(e)
}
}
/// Write a project to disk as pretty-printed JSON, returning the final path.
pub fn save(project: &Project, path: &Path) -> Result<PathBuf, FileError> {
let path = ensure_extension(path);
let file = ProjectFile::from(project);
let json = serde_json::to_string_pretty(&file)?;
fs::write(&path, json)?;
Ok(path)
}
/// Read a project from a `.cagire` file on disk.
pub fn load(path: &Path) -> Result<Project, FileError> {
let json = fs::read_to_string(path)?;
load_str(&json)
}
/// Parse a project from a JSON string.
pub fn load_str(json: &str) -> Result<Project, FileError> {
let file: ProjectFile = serde_json::from_str(json)?;
if file.version > VERSION {
return Err(FileError::Version(file.version));
}
Ok(Project::from(file))
}

17
crates/project/src/lib.rs Normal file
View File

@@ -0,0 +1,17 @@
//! Project data model: banks, patterns, and steps for the Cagire sequencer.
mod file;
mod project;
pub mod share;
/// Maximum number of banks in a project.
pub const MAX_BANKS: usize = 32;
/// Maximum number of patterns per bank.
pub const MAX_PATTERNS: usize = 32;
/// Maximum number of steps per pattern.
pub const MAX_STEPS: usize = 1024;
/// Default pattern length in steps.
pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, load_str, save, FileError};
pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode};

View File

@@ -0,0 +1,613 @@
//! Project, Bank, Pattern, and Step structs with serialization.
use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
/// Speed multiplier for a pattern, expressed as a rational fraction.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct PatternSpeed {
pub num: u8,
pub denom: u8,
}
impl PatternSpeed {
pub const EIGHTH: Self = Self { num: 1, denom: 8 };
pub const FIFTH: Self = Self { num: 1, denom: 5 };
pub const QUARTER: Self = Self { num: 1, denom: 4 };
pub const THIRD: Self = Self { num: 1, denom: 3 };
pub const HALF: Self = Self { num: 1, denom: 2 };
pub const TWO_THIRDS: Self = Self { num: 2, denom: 3 };
pub const NORMAL: Self = Self { num: 1, denom: 1 };
pub const DOUBLE: Self = Self { num: 2, denom: 1 };
pub const QUAD: Self = Self { num: 4, denom: 1 };
pub const OCTO: Self = Self { num: 8, denom: 1 };
const PRESETS: &[Self] = &[
Self::EIGHTH,
Self::FIFTH,
Self::QUARTER,
Self::THIRD,
Self::HALF,
Self::TWO_THIRDS,
Self::NORMAL,
Self::DOUBLE,
Self::QUAD,
Self::OCTO,
];
/// Return the speed as a floating-point multiplier.
pub fn multiplier(&self) -> f64 {
self.num as f64 / self.denom as f64
}
/// Format as a human-readable label (e.g. "2x", "1/4x").
pub fn label(&self) -> String {
if self.denom == 1 {
format!("{}x", self.num)
} else {
format!("{}/{}x", self.num, self.denom)
}
}
/// Return the next faster preset, or self if already at maximum.
pub fn next(&self) -> Self {
let current = self.multiplier();
Self::PRESETS
.iter()
.find(|p| p.multiplier() > current + 0.0001)
.copied()
.unwrap_or(*self)
}
/// Return the next slower preset, or self if already at minimum.
pub fn prev(&self) -> Self {
let current = self.multiplier();
Self::PRESETS
.iter()
.rev()
.find(|p| p.multiplier() < current - 0.0001)
.copied()
.unwrap_or(*self)
}
/// Parse a speed label like "2x" or "1/4x" into a `PatternSpeed`.
pub fn from_label(s: &str) -> Option<Self> {
let s = s.trim().trim_end_matches('x');
if let Some((num, denom)) = s.split_once('/') {
let num: u8 = num.parse().ok()?;
let denom: u8 = denom.parse().ok()?;
if denom == 0 {
return None;
}
return Some(Self { num, denom });
}
if let Ok(val) = s.parse::<f64>() {
if val <= 0.0 || val > 255.0 {
return None;
}
if (val - val.round()).abs() < 0.0001 {
return Some(Self {
num: val.round() as u8,
denom: 1,
});
}
for denom in 1..=16u8 {
let num = val * denom as f64;
if (num - num.round()).abs() < 0.0001 && (1.0..=255.0).contains(&num) {
return Some(Self {
num: num.round() as u8,
denom,
});
}
}
}
None
}
}
impl Default for PatternSpeed {
fn default() -> Self {
Self::NORMAL
}
}
impl Serialize for PatternSpeed {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
(self.num, self.denom).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PatternSpeed {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum SpeedFormat {
Tuple((u8, u8)),
Legacy(String),
}
match SpeedFormat::deserialize(deserializer)? {
SpeedFormat::Tuple((num, denom)) => Ok(Self { num, denom }),
SpeedFormat::Legacy(s) => Ok(match s.as_str() {
"Eighth" => Self::EIGHTH,
"Quarter" => Self::QUARTER,
"Half" => Self::HALF,
"Normal" => Self::NORMAL,
"Double" => Self::DOUBLE,
"Quad" => Self::QUAD,
"Octo" => Self::OCTO,
_ => Self::NORMAL,
}),
}
}
}
/// Quantization grid for launching patterns.
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum LaunchQuantization {
Immediate,
Beat,
#[default]
Bar,
Bars2,
Bars4,
Bars8,
}
impl LaunchQuantization {
/// Human-readable label for display.
pub fn label(&self) -> &'static str {
match self {
Self::Immediate => "Immediate",
Self::Beat => "Beat",
Self::Bar => "1 Bar",
Self::Bars2 => "2 Bars",
Self::Bars4 => "4 Bars",
Self::Bars8 => "8 Bars",
}
}
/// Cycle to the next longer quantization, clamped at `Bars8`.
pub fn next(&self) -> Self {
match self {
Self::Immediate => Self::Beat,
Self::Beat => Self::Bar,
Self::Bar => Self::Bars2,
Self::Bars2 => Self::Bars4,
Self::Bars4 => Self::Bars8,
Self::Bars8 => Self::Bars8,
}
}
/// Cycle to the next shorter quantization, clamped at `Immediate`.
pub fn prev(&self) -> Self {
match self {
Self::Immediate => Self::Immediate,
Self::Beat => Self::Immediate,
Self::Bar => Self::Beat,
Self::Bars2 => Self::Bar,
Self::Bars4 => Self::Bars2,
Self::Bars8 => Self::Bars4,
}
}
}
/// How a pattern synchronizes when launched: restart or phase-lock.
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum SyncMode {
#[default]
Reset,
PhaseLock,
}
impl SyncMode {
/// Human-readable label for display.
pub fn label(&self) -> &'static str {
match self {
Self::Reset => "Reset",
Self::PhaseLock => "Phase-Lock",
}
}
/// Toggle between Reset and PhaseLock.
pub fn toggle(&self) -> Self {
match self {
Self::Reset => Self::PhaseLock,
Self::PhaseLock => Self::Reset,
}
}
}
/// What happens when a pattern finishes: loop, stop, or chain to another.
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum FollowUp {
#[default]
Loop,
Stop,
Chain { bank: usize, pattern: usize },
}
impl FollowUp {
/// Human-readable label for display.
pub fn label(&self) -> &'static str {
match self {
Self::Loop => "Loop",
Self::Stop => "Stop",
Self::Chain { .. } => "Chain",
}
}
/// Cycle forward through follow-up modes.
pub fn next_mode(&self) -> Self {
match self {
Self::Loop => Self::Stop,
Self::Stop => Self::Chain { bank: 0, pattern: 0 },
Self::Chain { .. } => Self::Loop,
}
}
/// Cycle backward through follow-up modes.
pub fn prev_mode(&self) -> Self {
match self {
Self::Loop => Self::Chain { bank: 0, pattern: 0 },
Self::Stop => Self::Loop,
Self::Chain { .. } => Self::Stop,
}
}
}
fn is_default_follow_up(f: &FollowUp) -> bool {
*f == FollowUp::default()
}
/// Single step in a pattern, holding a Forth script and optional metadata.
#[derive(Clone, Serialize, Deserialize)]
pub struct Step {
pub active: bool,
pub script: String,
#[serde(default)]
pub source: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Step {
/// True if all fields are at their default values.
pub fn is_default(&self) -> bool {
self.active && self.script.is_empty() && self.source.is_none() && self.name.is_none()
}
/// True if the script is non-empty.
pub fn has_content(&self) -> bool {
!self.script.is_empty()
}
}
impl Default for Step {
fn default() -> Self {
Self {
active: true,
script: String::new(),
source: None,
name: None,
}
}
}
/// Sequence of steps with playback settings (speed, quantization, sync, follow-up).
#[derive(Clone)]
pub struct Pattern {
pub steps: Vec<Step>,
pub length: usize,
pub speed: PatternSpeed,
pub name: Option<String>,
pub description: Option<String>,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}
#[derive(Serialize, Deserialize)]
struct SparseStep {
i: usize,
#[serde(default = "default_active", skip_serializing_if = "is_true")]
active: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
fn default_active() -> bool {
true
}
fn is_true(v: &bool) -> bool {
*v
}
#[derive(Serialize, Deserialize)]
struct SparsePattern {
steps: Vec<SparseStep>,
length: usize,
#[serde(default)]
speed: PatternSpeed,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default, skip_serializing_if = "is_default_quantization")]
quantization: LaunchQuantization,
#[serde(default, skip_serializing_if = "is_default_sync_mode")]
sync_mode: SyncMode,
#[serde(default, skip_serializing_if = "is_default_follow_up")]
follow_up: FollowUp,
}
fn is_default_quantization(q: &LaunchQuantization) -> bool {
*q == LaunchQuantization::default()
}
fn is_default_sync_mode(s: &SyncMode) -> bool {
*s == SyncMode::default()
}
#[derive(Deserialize)]
struct LegacyPattern {
steps: Vec<Step>,
length: usize,
#[serde(default)]
speed: PatternSpeed,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
quantization: LaunchQuantization,
#[serde(default)]
sync_mode: SyncMode,
#[serde(default)]
follow_up: FollowUp,
}
impl Serialize for Pattern {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let sparse_steps: Vec<SparseStep> = self
.steps
.iter()
.enumerate()
.filter(|(_, step)| !step.is_default())
.map(|(i, step)| SparseStep {
i,
active: step.active,
script: step.script.clone(),
source: step.source,
name: step.name.clone(),
})
.collect();
let sparse = SparsePattern {
steps: sparse_steps,
length: self.length,
speed: self.speed,
name: self.name.clone(),
description: self.description.clone(),
quantization: self.quantization,
sync_mode: self.sync_mode,
follow_up: self.follow_up,
};
sparse.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum PatternFormat {
Sparse(SparsePattern),
Legacy(LegacyPattern),
}
match PatternFormat::deserialize(deserializer)? {
PatternFormat::Sparse(sparse) => {
let mut steps: Vec<Step> = (0..MAX_STEPS).map(|_| Step::default()).collect();
for ss in sparse.steps {
if ss.i < MAX_STEPS {
steps[ss.i] = Step {
active: ss.active,
script: ss.script,
source: ss.source,
name: ss.name,
};
}
}
Ok(Pattern {
steps,
length: sparse.length,
speed: sparse.speed,
name: sparse.name,
description: sparse.description,
quantization: sparse.quantization,
sync_mode: sparse.sync_mode,
follow_up: sparse.follow_up,
})
}
PatternFormat::Legacy(legacy) => Ok(Pattern {
steps: legacy.steps,
length: legacy.length,
speed: legacy.speed,
name: legacy.name,
description: legacy.description,
quantization: legacy.quantization,
sync_mode: legacy.sync_mode,
follow_up: legacy.follow_up,
}),
}
}
}
impl Default for Pattern {
fn default() -> Self {
Self {
steps: (0..MAX_STEPS).map(|_| Step::default()).collect(),
length: DEFAULT_LENGTH,
speed: PatternSpeed::default(),
name: None,
description: None,
quantization: LaunchQuantization::default(),
sync_mode: SyncMode::default(),
follow_up: FollowUp::default(),
}
}
}
impl Pattern {
/// Borrow a step by index.
pub fn step(&self, index: usize) -> Option<&Step> {
self.steps.get(index)
}
/// Mutably borrow a step by index.
pub fn step_mut(&mut self, index: usize) -> Option<&mut Step> {
self.steps.get_mut(index)
}
/// Set the active length, clamped to `[1, MAX_STEPS]`.
pub fn set_length(&mut self, length: usize) {
let length = length.clamp(1, MAX_STEPS);
while self.steps.len() < length {
self.steps.push(Step::default());
}
self.length = length;
}
/// Follow the source chain from `index` to find the originating step.
pub fn resolve_source(&self, index: usize) -> usize {
let mut current = index;
for _ in 0..self.steps.len() {
if let Some(step) = self.steps.get(current) {
if let Some(source) = step.source {
current = source as usize;
} else {
return current;
}
} else {
return index;
}
}
index
}
/// Return the script at the resolved source of `index`.
pub fn resolve_script(&self, index: usize) -> Option<&str> {
let source_idx = self.resolve_source(index);
self.steps.get(source_idx).map(|s| s.script.as_str())
}
/// Count active-length steps that have a script or a source reference.
pub fn content_step_count(&self) -> usize {
self.steps[..self.length]
.iter()
.filter(|s| s.has_content() || s.source.is_some())
.count()
}
}
/// Collection of patterns forming a bank.
#[derive(Clone, Serialize, Deserialize)]
pub struct Bank {
pub patterns: Vec<Pattern>,
#[serde(default)]
pub name: Option<String>,
}
impl Bank {
/// Count patterns that contain at least one non-empty step.
pub fn content_pattern_count(&self) -> usize {
self.patterns
.iter()
.filter(|p| p.content_step_count() > 0)
.count()
}
}
impl Default for Bank {
fn default() -> Self {
Self {
patterns: (0..MAX_PATTERNS).map(|_| Pattern::default()).collect(),
name: None,
}
}
}
/// Top-level project: banks, tempo, sample paths, and prelude script.
#[derive(Clone, Serialize, Deserialize)]
pub struct Project {
pub banks: Vec<Bank>,
#[serde(default)]
pub sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
pub tempo: f64,
#[serde(default)]
pub playing_patterns: Vec<(usize, usize)>,
#[serde(default)]
pub prelude: String,
#[serde(default)]
pub script: String,
#[serde(default)]
pub script_speed: PatternSpeed,
#[serde(default = "default_script_length")]
pub script_length: usize,
}
fn default_tempo() -> f64 {
120.0
}
fn default_script_length() -> usize {
16
}
impl Default for Project {
fn default() -> Self {
Self {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(),
tempo: default_tempo(),
playing_patterns: Vec::new(),
prelude: String::new(),
script: String::new(),
script_speed: PatternSpeed::default(),
script_length: default_script_length(),
}
}
}
impl Project {
/// Borrow a pattern by bank and pattern index.
pub fn pattern_at(&self, bank: usize, pattern: usize) -> &Pattern {
&self.banks[bank].patterns[pattern]
}
/// Mutably borrow a pattern by bank and pattern index.
pub fn pattern_at_mut(&mut self, bank: usize, pattern: usize) -> &mut Pattern {
&mut self.banks[bank].patterns[pattern]
}
/// Pad banks, patterns, and steps to their maximum sizes after deserialization.
pub fn normalize(&mut self) {
self.banks.resize_with(MAX_BANKS, Bank::default);
for bank in &mut self.banks {
bank.patterns.resize_with(MAX_PATTERNS, Pattern::default);
for pattern in &mut bank.patterns {
pattern.steps.resize_with(MAX_STEPS, Step::default);
}
}
}
}

214
crates/project/src/share.rs Normal file
View File

@@ -0,0 +1,214 @@
//! Pattern and project sharing via compact text strings.
//!
//! Export: data → MessagePack → Brotli → base64 URL-safe → prefix
//! Import: strip prefix → base64 decode → Brotli decompress → MessagePack → data
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use crate::{Bank, Pattern};
const PATTERN_PREFIX: &str = "cgr:";
const BANK_PREFIX: &str = "cgrb:";
/// Error during pattern or bank import/export.
#[derive(Debug)]
pub enum ShareError {
InvalidPrefix,
Base64(base64::DecodeError),
Decompress(std::io::Error),
Deserialize(rmp_serde::decode::Error),
Serialize(rmp_serde::encode::Error),
Compress(std::io::Error),
}
impl std::fmt::Display for ShareError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPrefix => write!(f, "missing cgr:/cgrb: prefix"),
Self::Base64(e) => write!(f, "base64: {e}"),
Self::Decompress(e) => write!(f, "decompress: {e}"),
Self::Deserialize(e) => write!(f, "deserialize: {e}"),
Self::Serialize(e) => write!(f, "serialize: {e}"),
Self::Compress(e) => write!(f, "compress: {e}"),
}
}
}
fn compress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
let mut output = Vec::new();
let params = brotli::enc::BrotliEncoderParams {
quality: 11,
lgwin: 22,
lgblock: 0,
..Default::default()
};
brotli::BrotliCompress(&mut &data[..], &mut output, &params).map_err(ShareError::Compress)?;
Ok(output)
}
fn decompress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
let mut output = Vec::new();
brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?;
Ok(output)
}
fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareError> {
let packed = rmp_serde::to_vec_named(value).map_err(ShareError::Serialize)?;
let compressed = compress(&packed)?;
let encoded = URL_SAFE_NO_PAD.encode(&compressed);
Ok(format!("{prefix}{encoded}"))
}
fn decode<T: serde::de::DeserializeOwned>(text: &str, prefix: &str) -> Result<T, ShareError> {
let text = text.trim();
let payload = text.strip_prefix(prefix).ok_or(ShareError::InvalidPrefix)?;
let compressed = URL_SAFE_NO_PAD.decode(payload).map_err(ShareError::Base64)?;
let packed = decompress(&compressed)?;
rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize)
}
/// Encode a pattern as a shareable `cgr:` string.
pub fn export(pattern: &Pattern) -> Result<String, ShareError> {
encode(pattern, PATTERN_PREFIX)
}
/// Decode a `cgr:` string back into a pattern.
pub fn import(text: &str) -> Result<Pattern, ShareError> {
decode(text, PATTERN_PREFIX)
}
/// Encode a bank as a shareable `cgrb:` string.
pub fn export_bank(bank: &Bank) -> Result<String, ShareError> {
encode(bank, BANK_PREFIX)
}
/// Decode a `cgrb:` string back into a bank.
pub fn import_bank(text: &str) -> Result<Bank, ShareError> {
decode(text, BANK_PREFIX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Step;
#[test]
fn roundtrip_empty() {
let pattern = Pattern::default();
let encoded = export(&pattern).unwrap();
assert!(encoded.starts_with("cgr:"));
let decoded = import(&encoded).unwrap();
assert_eq!(decoded.length, pattern.length);
assert_eq!(decoded.steps.len(), pattern.steps.len());
}
#[test]
fn roundtrip_with_steps() {
let mut pattern = Pattern::default();
pattern.steps[0] = Step {
active: true,
script: "kick 60 note".to_string(),
source: None,
name: Some("kick".to_string()),
};
pattern.steps[1] = Step {
active: false,
script: "snare".to_string(),
source: None,
name: None,
};
pattern.steps[3] = Step {
active: true,
script: String::new(),
source: Some(0),
name: None,
};
pattern.length = 8;
pattern.name = Some("Test".to_string());
let encoded = export(&pattern).unwrap();
let decoded = import(&encoded).unwrap();
assert_eq!(decoded.length, 8);
assert_eq!(decoded.name.as_deref(), Some("Test"));
assert_eq!(decoded.steps[0].script, "kick 60 note");
assert_eq!(decoded.steps[0].name.as_deref(), Some("kick"));
assert!(!decoded.steps[1].active);
assert_eq!(decoded.steps[1].script, "snare");
assert_eq!(decoded.steps[3].source, Some(0));
}
#[test]
fn bad_prefix() {
assert!(matches!(import("xxx:abc"), Err(ShareError::InvalidPrefix)));
}
#[test]
fn bad_base64() {
assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_))));
}
#[test]
fn whitespace_trimming() {
let pattern = Pattern::default();
let encoded = export(&pattern).unwrap();
let padded = format!(" {encoded} \n");
let decoded = import(&padded).unwrap();
assert_eq!(decoded.length, pattern.length);
}
#[test]
fn msgpack_brotli_smaller_than_json_deflate() {
let mut pattern = Pattern::default();
for i in 0..16 {
pattern.steps[i] = Step {
active: true,
script: format!("kick {i} note 0.5 dur"),
source: None,
name: Some(format!("step_{i}")),
};
}
pattern.length = 16;
// Current (msgpack+brotli)
let new_encoded = export(&pattern).unwrap();
// Old pipeline (json+deflate) for comparison
use std::io::Write;
let json = serde_json::to_vec(&pattern).unwrap();
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
encoder.write_all(&json).unwrap();
let old_compressed = encoder.finish().unwrap();
let old_encoded = format!("cgr:{}", URL_SAFE_NO_PAD.encode(&old_compressed));
assert!(
new_encoded.len() < old_encoded.len(),
"msgpack+brotli ({}) should be smaller than json+deflate ({})",
new_encoded.len(),
old_encoded.len()
);
}
#[test]
fn roundtrip_bank() {
let mut bank = Bank::default();
bank.patterns[0].steps[0] = Step {
active: true,
script: "kick 60 note".to_string(),
source: None,
name: Some("kick".to_string()),
};
bank.patterns[0].length = 8;
bank.name = Some("Drums".to_string());
let encoded = export_bank(&bank).unwrap();
assert!(encoded.starts_with("cgrb:"));
let decoded = import_bank(&encoded).unwrap();
assert_eq!(decoded.name.as_deref(), Some("Drums"));
assert_eq!(decoded.patterns[0].length, 8);
assert_eq!(decoded.patterns[0].steps[0].script, "kick 60 note");
}
}

14
crates/ratatui/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "cagire-ratatui"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "TUI components for cagire sequencer"
[dependencies]
rand = "0.8"
ratatui = "0.30"
regex = "1"
tui-textarea = { git = "https://github.com/phsym/tui-textarea", rev = "e2ec4d3", features = ["search"] }

View File

@@ -0,0 +1,189 @@
//! Collapsible categorized list widget with section headers.
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::Frame;
use crate::theme;
/// Entry in a category list: either a section header or a leaf item.
pub struct CategoryItem<'a> {
pub label: &'a str,
pub is_section: bool,
pub collapsed: bool,
}
/// What is currently selected: a leaf item or a section header.
pub enum Selection {
Item(usize),
Section(usize),
}
/// Scrollable list with collapsible section headers.
pub struct CategoryList<'a> {
items: &'a [CategoryItem<'a>],
selection: Selection,
focused: bool,
title: &'a str,
section_color: Color,
focused_color: Color,
selected_color: Color,
normal_color: Color,
dimmed_color: Option<Color>,
}
impl<'a> CategoryList<'a> {
pub fn new(items: &'a [CategoryItem<'a>], selection: Selection) -> Self {
let theme = theme::get();
Self {
items,
selection,
focused: false,
title: "",
section_color: theme.ui.text_dim,
focused_color: theme.dict.category_focused,
selected_color: theme.dict.category_selected,
normal_color: theme.dict.category_normal,
dimmed_color: None,
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
pub fn selected_color(mut self, color: Color) -> Self {
self.selected_color = color;
self
}
pub fn normal_color(mut self, color: Color) -> Self {
self.normal_color = color;
self
}
pub fn dimmed(mut self, color: Color) -> Self {
self.dimmed_color = Some(color);
self
}
/// Build the visible items list, filtering out children of collapsed sections.
/// Returns (item, section_index_if_section, item_index_if_item).
fn visible_items(&self) -> Vec<(&CategoryItem<'a>, Option<usize>, Option<usize>)> {
let mut result = Vec::new();
let mut skipping = false;
let mut section_idx = 0usize;
let mut item_idx = 0usize;
for item in self.items.iter() {
if item.is_section {
skipping = item.collapsed;
result.push((item, Some(section_idx), None));
section_idx += 1;
} else if !skipping {
result.push((item, None, Some(item_idx)));
item_idx += 1;
} else {
item_idx += 1;
}
}
result
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let theme = theme::get();
let visible = self.visible_items();
let visible_height = area.height.saturating_sub(2) as usize;
let total_items = visible.len();
let selected_visual_idx = match &self.selection {
Selection::Item(sel) => visible
.iter()
.position(|(_, _, item_idx)| *item_idx == Some(*sel))
.unwrap_or(0),
Selection::Section(sel) => visible
.iter()
.position(|(_, sec_idx, _)| *sec_idx == Some(*sel))
.unwrap_or(0),
};
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
let is_dimmed = self.dimmed_color.is_some();
let items: Vec<ListItem> = visible
.iter()
.skip(scroll)
.take(visible_height)
.enumerate()
.map(|(vis_offset, (item, sec_idx, _itm_idx))| {
let visual_pos = scroll + vis_offset;
if item.is_section {
let is_selected =
matches!(&self.selection, Selection::Section(s) if Some(*s) == *sec_idx);
let arrow = if item.collapsed { "" } else { "" };
let style = if is_selected && self.focused {
Style::new()
.fg(self.focused_color)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::new()
.fg(self.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(self.section_color)
};
let prefix = if is_selected && !is_dimmed { "> " } else { "" };
ListItem::new(format!("{prefix}{arrow} {}", item.label)).style(style)
} else {
let is_selected = visual_pos == selected_visual_idx
&& matches!(&self.selection, Selection::Item(_));
let style = if let Some(dim_color) = self.dimmed_color {
Style::new().fg(dim_color)
} else if is_selected && self.focused {
Style::new()
.fg(self.focused_color)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::new()
.fg(self.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(self.normal_color)
};
let prefix = if is_selected && !is_dimmed {
"> "
} else {
" "
};
ListItem::new(format!("{prefix}{}", item.label)).style(style)
}
})
.collect();
let border_color = if self.focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title(self.title);
let list = List::new(items).block(block);
frame.render_widget(list, area);
}
}

View File

@@ -1,11 +1,15 @@
//! Yes/No confirmation dialog widget.
use crate::theme;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::ModalFrame;
/// Modal dialog with Yes/No buttons.
pub struct ConfirmModal<'a> {
title: &'a str,
message: &'a str,
@@ -21,11 +25,12 @@ impl<'a> ConfirmModal<'a> {
}
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let t = theme::get();
let inner = ModalFrame::new(self.title)
.width(30)
.height(5)
.border_color(Color::Yellow)
.border_color(t.confirm.border)
.render_centered(frame, term);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
@@ -36,12 +41,12 @@ impl<'a> ConfirmModal<'a> {
);
let yes_style = if self.selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
Style::new().fg(t.confirm.button_selected_fg).bg(t.confirm.button_selected_bg)
} else {
Style::default()
};
let no_style = if !self.selected {
Style::new().fg(Color::Black).bg(Color::Yellow)
Style::new().fg(t.confirm.button_selected_fg).bg(t.confirm.button_selected_bg)
} else {
Style::default()
};
@@ -56,5 +61,7 @@ impl<'a> ConfirmModal<'a> {
Paragraph::new(buttons).alignment(Alignment::Center),
rows[1],
);
inner
}
}

View File

@@ -0,0 +1,758 @@
//! Script editor widget with completion, search, and sample finder popups.
use std::cell::Cell;
use crate::theme;
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
Frame,
};
use tui_textarea::TextArea;
/// Callback that syntax-highlights a single line, returning styled spans (bool = annotation).
pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String, bool)>;
/// Metadata for a single autocomplete entry.
#[derive(Clone)]
pub struct CompletionCandidate {
pub name: String,
pub signature: String,
pub description: String,
pub example: String,
}
struct CompletionState {
candidates: Vec<CompletionCandidate>,
matches: Vec<usize>,
cursor: usize,
prefix: String,
prefix_start_col: usize,
active: bool,
enabled: bool,
}
impl CompletionState {
fn new() -> Self {
Self {
candidates: Vec::new(),
matches: Vec::new(),
cursor: 0,
prefix: String::new(),
prefix_start_col: 0,
active: false,
enabled: true,
}
}
}
struct SampleFinderState {
query: String,
folders: Vec<String>,
matches: Vec<usize>,
cursor: usize,
active: bool,
}
impl SampleFinderState {
fn new() -> Self {
Self {
query: String::new(),
folders: Vec::new(),
matches: Vec::new(),
cursor: 0,
active: false,
}
}
}
struct SearchState {
query: String,
active: bool,
}
impl SearchState {
fn new() -> Self {
Self {
query: String::new(),
active: false,
}
}
}
/// Multi-line text editor backed by tui_textarea.
pub struct Editor {
text: TextArea<'static>,
completion: CompletionState,
sample_finder: SampleFinderState,
search: SearchState,
scroll_offset: Cell<u16>,
}
impl Editor {
pub fn start_selection(&mut self) {
self.text.start_selection();
}
pub fn cancel_selection(&mut self) {
self.text.cancel_selection();
}
pub fn is_selecting(&self) -> bool {
self.text.is_selecting()
}
pub fn move_cursor_to(&mut self, row: u16, col: u16) {
self.text.move_cursor(tui_textarea::CursorMove::Jump(row, col));
}
pub fn scroll_offset(&self) -> u16 {
self.scroll_offset.get()
}
pub fn copy(&mut self) {
self.text.copy();
}
pub fn cut(&mut self) -> bool {
self.text.cut()
}
pub fn paste(&mut self) -> bool {
self.text.paste()
}
pub fn yank_text(&self) -> String {
self.text.yank_text()
}
pub fn set_yank_text(&mut self, text: impl Into<String>) {
self.text.set_yank_text(text);
}
pub fn select_all(&mut self) {
self.text.select_all();
}
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
self.text.selection_range()
}
}
impl Default for Editor {
fn default() -> Self {
Self::new()
}
}
impl Editor {
pub fn new() -> Self {
Self {
text: TextArea::default(),
completion: CompletionState::new(),
sample_finder: SampleFinderState::new(),
search: SearchState::new(),
scroll_offset: Cell::new(0),
}
}
pub fn set_content(&mut self, lines: Vec<String>) {
let yank = self.text.yank_text();
self.text = TextArea::new(lines);
if !yank.is_empty() {
self.text.set_yank_text(yank);
}
self.completion.active = false;
self.sample_finder.active = false;
self.search.query.clear();
self.search.active = false;
self.scroll_offset.set(0);
}
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
self.completion.candidates = candidates;
}
pub fn insert_str(&mut self, s: &str) {
self.text.insert_str(s);
}
pub fn content(&self) -> String {
self.text.lines().join("\n")
}
pub fn lines(&self) -> &[String] {
self.text.lines()
}
pub fn cursor(&self) -> (usize, usize) {
self.text.cursor()
}
pub fn completion_active(&self) -> bool {
self.completion.active
}
pub fn dismiss_completion(&mut self) {
self.completion.active = false;
}
pub fn completion_next(&mut self) {
if self.completion.cursor + 1 < self.completion.matches.len() {
self.completion.cursor += 1;
}
}
pub fn completion_prev(&mut self) {
if self.completion.cursor > 0 {
self.completion.cursor -= 1;
}
}
pub fn set_completion_enabled(&mut self, enabled: bool) {
self.completion.enabled = enabled;
if !enabled {
self.completion.active = false;
}
}
pub fn activate_search(&mut self) {
self.search.active = true;
self.completion.active = false;
}
pub fn search_active(&self) -> bool {
self.search.active
}
pub fn search_query(&self) -> &str {
&self.search.query
}
pub fn search_input(&mut self, c: char) {
self.search.query.push(c);
self.apply_search_pattern();
}
pub fn search_backspace(&mut self) {
self.search.query.pop();
self.apply_search_pattern();
}
pub fn search_confirm(&mut self) {
self.search.active = false;
}
pub fn search_clear(&mut self) {
self.search.query.clear();
self.search.active = false;
let _ = self.text.set_search_pattern("");
}
pub fn search_next(&mut self) -> bool {
if self.search.query.is_empty() {
return false;
}
self.text.search_forward(false)
}
pub fn search_prev(&mut self) -> bool {
if self.search.query.is_empty() {
return false;
}
self.text.search_back(false)
}
fn apply_search_pattern(&mut self) {
if self.search.query.is_empty() {
let _ = self.text.set_search_pattern("");
} else {
let pattern = format!("(?i){}", regex::escape(&self.search.query));
let _ = self.text.set_search_pattern(&pattern);
}
}
pub fn set_sample_folders(&mut self, folders: Vec<String>) {
self.sample_finder.folders = folders;
}
pub fn activate_sample_finder(&mut self) {
self.completion.active = false;
self.sample_finder.query.clear();
self.sample_finder.cursor = 0;
self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect();
self.sample_finder.active = true;
}
pub fn dismiss_sample_finder(&mut self) {
self.sample_finder.active = false;
}
pub fn sample_finder_active(&self) -> bool {
self.sample_finder.active
}
pub fn sample_finder_input(&mut self, c: char) {
self.sample_finder.query.push(c);
self.update_sample_finder_matches();
}
pub fn sample_finder_backspace(&mut self) {
self.sample_finder.query.pop();
self.update_sample_finder_matches();
}
pub fn sample_finder_next(&mut self) {
if self.sample_finder.cursor + 1 < self.sample_finder.matches.len() {
self.sample_finder.cursor += 1;
}
}
pub fn sample_finder_prev(&mut self) {
if self.sample_finder.cursor > 0 {
self.sample_finder.cursor -= 1;
}
}
pub fn accept_sample_finder(&mut self) {
if self.sample_finder.matches.is_empty() {
self.sample_finder.active = false;
return;
}
let idx = self.sample_finder.matches[self.sample_finder.cursor];
let name = self.sample_finder.folders[idx].clone();
self.text.insert_str(&name);
self.sample_finder.active = false;
}
fn update_sample_finder_matches(&mut self) {
if self.sample_finder.query.is_empty() {
self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect();
} else {
let mut scored: Vec<(usize, usize)> = self
.sample_finder
.folders
.iter()
.enumerate()
.filter_map(|(i, name)| fuzzy_match(&self.sample_finder.query, name).map(|s| (s, i)))
.collect();
scored.sort_by_key(|(score, _)| *score);
self.sample_finder.matches = scored.into_iter().map(|(_, i)| i).collect();
}
self.sample_finder.cursor = self
.sample_finder
.cursor
.min(self.sample_finder.matches.len().saturating_sub(1));
}
pub fn input(&mut self, input: impl Into<tui_textarea::Input>) {
let input: tui_textarea::Input = input.into();
let has_modifier = input.ctrl || input.alt;
if self.completion.active && !has_modifier {
match &input {
tui_textarea::Input { key: tui_textarea::Key::Tab, .. } => {
self.accept_completion();
return;
}
tui_textarea::Input { key: tui_textarea::Key::Esc, .. } => {
self.completion.active = false;
return;
}
tui_textarea::Input { key: tui_textarea::Key::Char(c), .. } => {
if !is_word_char(*c) {
self.completion.active = false;
}
self.text.input(input);
self.update_completion();
return;
}
_ => {
self.completion.active = false;
}
}
}
self.text.input(input);
if !has_modifier {
self.update_completion();
}
}
fn update_completion(&mut self) {
if !self.completion.enabled || self.completion.candidates.is_empty() || self.sample_finder.active {
return;
}
let (row, col) = self.text.cursor();
let line = &self.text.lines()[row];
// col is a character index; convert to byte offset for slicing
let byte_col = line.char_indices()
.nth(col)
.map(|(i, _)| i)
.unwrap_or(line.len());
let prefix_start = line[..byte_col]
.char_indices()
.rev()
.take_while(|(_, c)| is_word_char(*c))
.last()
.map(|(i, _)| i)
.unwrap_or(byte_col);
let prefix = &line[prefix_start..byte_col];
if prefix.len() < 2 {
self.completion.active = false;
return;
}
let prefix_lower = prefix.to_lowercase();
let matches: Vec<usize> = self
.completion
.candidates
.iter()
.enumerate()
.filter(|(_, c)| c.name.to_lowercase().starts_with(&prefix_lower))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
self.completion.active = false;
return;
}
if matches.len() == 1
&& self.completion.candidates[matches[0]].name.to_lowercase() == prefix_lower
{
self.completion.active = false;
return;
}
self.completion.prefix = prefix.to_string();
self.completion.prefix_start_col = prefix_start;
self.completion.matches = matches;
self.completion.cursor = self.completion.cursor.min(
self.completion.matches.len().saturating_sub(1),
);
self.completion.active = true;
}
fn accept_completion(&mut self) {
if self.completion.matches.is_empty() {
self.completion.active = false;
return;
}
let idx = self.completion.matches[self.completion.cursor];
let name = self.completion.candidates[idx].name.clone();
let prefix_len = self.completion.prefix.len();
for _ in 0..prefix_len {
self.text.delete_char();
}
self.text.insert_str(&name);
self.completion.active = false;
}
pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) {
let t = theme::get();
let (cursor_row, cursor_col) = self.text.cursor();
let cursor_style = Style::default().bg(t.editor_widget.cursor_bg).fg(t.editor_widget.cursor_fg);
let selection_style = Style::default().bg(t.editor_widget.selection_bg);
let selection = self.text.selection_range();
let lines: Vec<Line> = self
.text
.lines()
.iter()
.enumerate()
.map(|(row, line)| {
let tokens = highlighter(row, line);
let mut spans: Vec<Span> = Vec::new();
let mut col = 0;
for (base_style, text, is_annotation) in tokens {
for ch in text.chars() {
let style = if is_annotation {
base_style
} else {
let is_cursor = row == cursor_row && col == cursor_col;
let is_selected = is_in_selection(row, col, selection);
if is_cursor {
cursor_style
} else if is_selected {
base_style.bg(selection_style.bg.unwrap())
} else {
base_style
}
};
spans.push(Span::styled(ch.to_string(), style));
if !is_annotation {
col += 1;
}
}
}
if row == cursor_row && cursor_col >= col {
spans.push(Span::styled(" ", cursor_style));
}
Line::from(spans)
})
.collect();
let viewport_height = area.height as usize;
let offset = self.scroll_offset.get() as usize;
let offset = if cursor_row < offset {
cursor_row
} else if cursor_row >= offset + viewport_height {
cursor_row - viewport_height + 1
} else {
offset
};
self.scroll_offset.set(offset as u16);
frame.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area);
if self.sample_finder.active && !self.sample_finder.matches.is_empty() {
self.render_sample_finder(frame, area, cursor_row - offset);
} else if self.completion.active && !self.completion.matches.is_empty() {
self.render_completion(frame, area, cursor_row - offset);
}
}
fn render_completion(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) {
let t = theme::get();
let max_visible: usize = 6;
let list_width: u16 = 18;
let doc_width: u16 = 40;
let total_width = list_width + doc_width;
let visible_count = self.completion.matches.len().min(max_visible);
let list_height = visible_count as u16;
let doc_height = 4u16;
let total_height = list_height.max(doc_height);
let popup_x = (editor_area.x + self.completion.prefix_start_col as u16)
.min(editor_area.x + editor_area.width.saturating_sub(total_width));
let below_y = editor_area.y + cursor_row as u16 + 1;
let popup_y = if below_y + total_height > editor_area.y + editor_area.height {
(editor_area.y + cursor_row as u16).saturating_sub(total_height)
} else {
below_y
};
let scroll_offset = if self.completion.cursor >= max_visible {
self.completion.cursor - max_visible + 1
} else {
0
};
// List panel
let list_area = Rect::new(popup_x, popup_y, list_width, total_height);
frame.render_widget(Clear, list_area);
let highlight_style = Style::default().fg(t.editor_widget.completion_selected).add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(t.editor_widget.completion_fg);
let bg_style = Style::default().bg(t.editor_widget.completion_bg);
let list_lines: Vec<Line> = (scroll_offset..scroll_offset + visible_count)
.map(|i| {
let idx = self.completion.matches[i];
let name = &self.completion.candidates[idx].name;
let style = if i == self.completion.cursor {
highlight_style
} else {
normal_style
};
let prefix = if i == self.completion.cursor { "> " } else { " " };
let display = format!("{prefix}{name:<width$}", width = list_width as usize - 2);
Line::from(Span::styled(display, style.bg(t.editor_widget.completion_bg)))
})
.collect();
// Fill remaining height with empty bg lines
let mut all_list_lines = list_lines;
for _ in visible_count as u16..total_height {
all_list_lines.push(Line::from(Span::styled(
" ".repeat(list_width as usize),
bg_style,
)));
}
frame.render_widget(Paragraph::new(all_list_lines), list_area);
// Doc panel
let doc_area = Rect::new(popup_x + list_width, popup_y, doc_width, total_height);
frame.render_widget(Clear, doc_area);
let selected_idx = self.completion.matches[self.completion.cursor];
let candidate = &self.completion.candidates[selected_idx];
let name_style = Style::default()
.fg(t.editor_widget.completion_selected)
.add_modifier(Modifier::BOLD)
.bg(t.editor_widget.completion_bg);
let desc_style = Style::default()
.fg(t.editor_widget.completion_fg)
.bg(t.editor_widget.completion_bg);
let example_style = Style::default()
.fg(t.editor_widget.completion_example)
.bg(t.editor_widget.completion_bg);
let w = doc_width as usize;
let mut doc_lines: Vec<Line> = Vec::new();
let header = format!(" {} {}", candidate.name, candidate.signature);
doc_lines.push(Line::from(Span::styled(
format!("{header:<w$}"),
name_style,
)));
let desc = format!(" {}", candidate.description);
doc_lines.push(Line::from(Span::styled(
format!("{desc:<w$}"),
desc_style,
)));
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
if !candidate.example.is_empty() {
let ex = format!(" {}", candidate.example);
doc_lines.push(Line::from(Span::styled(
format!("{ex:<w$}"),
example_style,
)));
}
for _ in doc_lines.len() as u16..total_height {
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
}
frame.render_widget(Paragraph::new(doc_lines), doc_area);
}
fn render_sample_finder(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) {
let t = theme::get();
let max_visible: usize = 8;
let width: u16 = 24;
let visible_count = self.sample_finder.matches.len().min(max_visible);
let total_height = visible_count as u16 + 1; // +1 for query line
let (_, cursor_col) = self.text.cursor();
let popup_x = (editor_area.x + cursor_col as u16)
.min(editor_area.x + editor_area.width.saturating_sub(width));
let below_y = editor_area.y + cursor_row as u16 + 1;
let popup_y = if below_y + total_height > editor_area.y + editor_area.height {
(editor_area.y + cursor_row as u16).saturating_sub(total_height)
} else {
below_y
};
let area = Rect::new(popup_x, popup_y, width, total_height);
frame.render_widget(Clear, area);
let bg_style = Style::default().bg(t.editor_widget.completion_bg);
let highlight_style = Style::default()
.fg(t.editor_widget.completion_selected)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(t.editor_widget.completion_fg);
let w = width as usize;
let mut lines: Vec<Line> = Vec::new();
let query_display = format!("/{}", self.sample_finder.query);
lines.push(Line::from(Span::styled(
format!("{query_display:<w$}"),
highlight_style.bg(t.editor_widget.completion_bg),
)));
let scroll_offset = if self.sample_finder.cursor >= max_visible {
self.sample_finder.cursor - max_visible + 1
} else {
0
};
for i in scroll_offset..scroll_offset + visible_count {
let idx = self.sample_finder.matches[i];
let name = &self.sample_finder.folders[idx];
let style = if i == self.sample_finder.cursor {
highlight_style
} else {
normal_style
};
let prefix = if i == self.sample_finder.cursor { "> " } else { " " };
let display = format!("{prefix}{name:<width$}", width = w - 2);
lines.push(Line::from(Span::styled(
format!("{display:<w$}"),
style.bg(t.editor_widget.completion_bg),
)));
}
// Fill rest with bg
for _ in lines.len() as u16..total_height {
lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
}
frame.render_widget(Paragraph::new(lines), area);
}
}
/// Score a fuzzy match of `query` against `target`. Lower is better; `None` if no match.
pub fn fuzzy_match(query: &str, target: &str) -> Option<usize> {
let target_lower: Vec<char> = target.to_lowercase().chars().collect();
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
let mut ti = 0;
let mut score = 0;
let mut prev_pos = 0;
for (qi, &qc) in query_lower.iter().enumerate() {
loop {
if ti >= target_lower.len() {
return None;
}
if target_lower[ti] == qc {
if qi > 0 {
score += ti - prev_pos;
}
prev_pos = ti;
ti += 1;
break;
}
ti += 1;
}
}
Some(score)
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#')
}
fn is_in_selection(row: usize, col: usize, selection: Option<((usize, usize), (usize, usize))>) -> bool {
let Some(((start_row, start_col), (end_row, end_col))) = selection else {
return false;
};
if row < start_row || row > end_row {
return false;
}
if row == start_row && row == end_row {
col >= start_col && col < end_col
} else if row == start_row {
col >= start_col
} else if row == end_row {
col < end_col
} else {
true
}
}

View File

@@ -0,0 +1,121 @@
//! File/directory browser modal widget.
use crate::theme;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::ModalFrame;
/// Modal listing files and directories with a filter input line.
pub struct FileBrowserModal<'a> {
title: &'a str,
input: &'a str,
entries: &'a [(String, bool, bool)],
selected: usize,
scroll_offset: usize,
border_color: Option<Color>,
width: u16,
height: u16,
}
impl<'a> FileBrowserModal<'a> {
pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool, bool)]) -> Self {
Self {
title,
input,
entries,
selected: 0,
scroll_offset: 0,
border_color: None,
width: 60,
height: 16,
}
}
pub fn selected(mut self, idx: usize) -> Self {
self.selected = idx;
self
}
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn border_color(mut self, c: Color) -> Self {
self.border_color = Some(c);
self
}
pub fn width(mut self, w: u16) -> Self {
self.width = w;
self
}
pub fn height(mut self, h: u16) -> Self {
self.height = h;
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
let inner = ModalFrame::new(self.title)
.width(self.width)
.height(self.height)
.border_color(border_color)
.render_centered(frame, term);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
// Input line
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(colors.input.text)),
Span::styled("", Style::new().fg(colors.input.cursor)),
])),
rows[0],
);
// Entries list
let visible_height = rows[1].height as usize;
let visible_entries = self
.entries
.iter()
.skip(self.scroll_offset)
.take(visible_height);
let lines: Vec<Line> = visible_entries
.enumerate()
.map(|(i, (name, is_dir, is_cagire))| {
let abs_idx = i + self.scroll_offset;
let is_selected = abs_idx == self.selected;
let prefix = if is_selected { "> " } else { " " };
let display = if *is_dir {
format!("{prefix}{name}/")
} else {
format!("{prefix}{name}")
};
let color = if is_selected {
colors.browser.selected
} else if *is_dir {
colors.browser.directory
} else if *is_cagire {
colors.browser.project_file
} else {
colors.browser.file
};
Line::from(Span::styled(display, Style::new().fg(color)))
})
.collect();
frame.render_widget(Paragraph::new(lines), rows[1]);
inner
}
}

View File

@@ -0,0 +1,30 @@
//! Bottom-bar keyboard hint renderer.
use ratatui::text::{Line, Span};
use ratatui::style::Style;
use crate::theme;
/// Build a styled line of key/action pairs for the hint bar.
pub fn hint_line(pairs: &[(&str, &str)]) -> Line<'static> {
let theme = theme::get();
let key_style = Style::default().fg(theme.hint.key);
let text_style = Style::default().fg(theme.hint.text);
let spans: Vec<Span> = pairs
.iter()
.enumerate()
.flat_map(|(i, (key, action))| {
let mut s = vec![
Span::styled(key.to_string(), key_style),
Span::styled(format!(" {action}"), text_style),
];
if i + 1 < pairs.len() {
s.push(Span::styled(" ", text_style));
}
s
})
.collect();
Line::from(spans)
}

44
crates/ratatui/src/lib.rs Normal file
View File

@@ -0,0 +1,44 @@
//! Reusable TUI widgets for the Cagire sequencer interface.
mod category_list;
mod confirm;
mod editor;
mod file_browser;
mod hint_bar;
mod lissajous;
mod list_select;
mod modal;
mod nav_minimap;
mod props_form;
mod sample_browser;
mod scope;
mod scroll_indicators;
mod search_bar;
mod section_header;
mod sparkles;
mod spectrum;
mod text_input;
pub mod theme;
mod vu_meter;
mod waveform;
pub use category_list::{CategoryItem, CategoryList, Selection};
pub use confirm::ConfirmModal;
pub use editor::{fuzzy_match, CompletionCandidate, Editor};
pub use file_browser::FileBrowserModal;
pub use hint_bar::hint_line;
pub use lissajous::Lissajous;
pub use list_select::ListSelect;
pub use modal::ModalFrame;
pub use nav_minimap::{hit_test_tile, minimap_area, NavMinimap, NavTile};
pub use props_form::render_props_form;
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
pub use scope::{Orientation, Scope};
pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign};
pub use search_bar::render_search_bar;
pub use section_header::render_section_header;
pub use sparkles::Sparkles;
pub use spectrum::{Spectrum, SpectrumStyle};
pub use text_input::TextInputModal;
pub use vu_meter::VuMeter;
pub use waveform::Waveform;

View File

@@ -0,0 +1,234 @@
//! Lissajous XY oscilloscope widget using braille characters.
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static TRAIL: RefCell<TrailState> = const { RefCell::new(TrailState { fine_w: 0, fine_h: 0, heat: Vec::new() }) };
}
struct TrailState {
fine_w: usize,
fine_h: usize,
heat: Vec<f32>,
}
/// XY oscilloscope plotting left vs right channels as a Lissajous curve.
pub struct Lissajous<'a> {
left: &'a [f32],
right: &'a [f32],
color: Option<Color>,
gain: f32,
trails: bool,
}
impl<'a> Lissajous<'a> {
pub fn new(left: &'a [f32], right: &'a [f32]) -> Self {
Self {
left,
right,
color: None,
gain: 1.0,
trails: false,
}
}
pub fn trails(mut self, enabled: bool) -> Self {
self.trails = enabled;
self
}
pub fn color(mut self, c: Color) -> Self {
self.color = Some(c);
self
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
}
impl Widget for Lissajous<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.left.is_empty() || self.right.is_empty() {
return;
}
if self.trails {
self.render_trails(area, buf);
} else {
self.render_normal(area, buf);
}
}
}
impl Lissajous<'_> {
fn render_normal(self, area: Rect, buf: &mut Buffer) {
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = self.left.len().min(self.right.len());
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
let size = width * height;
patterns.clear();
patterns.resize(size, 0);
for i in 0..len {
let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
let r = (self.right[i] * self.gain).clamp(-1.0, 1.0);
let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fine_y = ((1.0 - l) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_x = fine_x.min(fine_width - 1);
let fine_y = fine_y.min(fine_height - 1);
let char_x = fine_x / 2;
let char_y = fine_y / 4;
let dot_x = fine_x % 2;
let dot_y = fine_y % 4;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn render_trails(self, area: Rect, buf: &mut Buffer) {
let theme = theme::get();
let width = area.width as usize;
let height = area.height as usize;
let fine_w = width * 2;
let fine_h = height * 4;
let len = self.left.len().min(self.right.len());
TRAIL.with(|t| {
let mut trail = t.borrow_mut();
// Reset if dimensions changed
if trail.fine_w != fine_w || trail.fine_h != fine_h {
trail.fine_w = fine_w;
trail.fine_h = fine_h;
trail.heat.clear();
trail.heat.resize(fine_w * fine_h, 0.0);
}
// Decay existing heat
for h in trail.heat.iter_mut() {
*h *= 0.85;
}
// Plot new sample points
for i in 0..len {
let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
let r = (self.right[i] * self.gain).clamp(-1.0, 1.0);
let fx = ((r + 1.0) * 0.5 * (fine_w - 1) as f32).round() as usize;
let fy = ((1.0 - l) * 0.5 * (fine_h - 1) as f32).round() as usize;
let fx = fx.min(fine_w - 1);
let fy = fy.min(fine_h - 1);
trail.heat[fy * fine_w + fx] = 1.0;
}
// Convert heat map to braille
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
// Track brightest color per cell
let mut colors: Vec<Option<Color>> = vec![None; width * height];
for fy in 0..fine_h {
for fx in 0..fine_w {
let h = trail.heat[fy * fine_w + fx];
if h < 0.05 {
continue;
}
let cx = fx / 2;
let cy = fy / 4;
let dx = fx % 2;
let dy = fy % 4;
let idx = cy * width + cx;
patterns[idx] |= braille_bit(dx, dy);
let dot_color = if h > 0.7 {
theme.meter.high
} else if h > 0.25 {
theme.meter.mid
} else {
theme.meter.low
};
let replace = match colors[idx] {
None => true,
Some(cur) => {
rank_color(dot_color, &theme) > rank_color(cur, &theme)
}
};
if replace {
colors[idx] = Some(dot_color);
}
}
}
for cy in 0..height {
for cx in 0..width {
let idx = cy * width + cx;
let pattern = patterns[idx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
let color = colors[idx].unwrap_or(theme.meter.low);
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
});
}
}
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
}
}
fn rank_color(c: Color, theme: &crate::theme::ThemeColors) -> u8 {
if c == theme.meter.high { 2 }
else if c == theme.meter.mid { 1 }
else { 0 }
}

View File

@@ -0,0 +1,108 @@
//! Scrollable single-select list widget with cursor highlight.
use crate::theme;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
/// Scrollable list with a highlighted cursor and selected-item marker.
pub struct ListSelect<'a> {
items: &'a [String],
selected: usize,
cursor: usize,
focused: bool,
visible_count: usize,
scroll_offset: usize,
}
impl<'a> ListSelect<'a> {
pub fn new(items: &'a [String], selected: usize, cursor: usize) -> Self {
Self {
items,
selected,
cursor,
focused: false,
visible_count: 5,
scroll_offset: 0,
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn visible_count(mut self, n: usize) -> Self {
self.visible_count = n;
self
}
pub fn height(&self) -> u16 {
let item_lines = self.items.len().min(self.visible_count) as u16;
if self.items.len() > self.visible_count {
item_lines + 1
} else {
item_lines
}
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let colors = theme::get();
let cursor_style = Style::new().fg(colors.hint.key).add_modifier(Modifier::BOLD);
let selected_style = Style::new().fg(colors.ui.accent);
let normal_style = Style::default();
let indicator_style = Style::new().fg(colors.ui.text_dim);
let visible_end = (self.scroll_offset + self.visible_count).min(self.items.len());
let has_above = self.scroll_offset > 0;
let has_below = visible_end < self.items.len();
let mut lines: Vec<Line> = Vec::new();
for i in self.scroll_offset..visible_end {
let name = &self.items[i];
let is_cursor = self.focused && i == self.cursor;
let is_selected = i == self.selected;
let style = if is_cursor {
cursor_style
} else if is_selected {
selected_style
} else {
normal_style
};
let prefix = if is_selected { "x " } else { " " };
let mut spans = vec![
Span::styled(prefix.to_string(), style),
Span::styled(name.clone(), style),
];
if has_above && i == self.scroll_offset {
spans.push(Span::styled("", indicator_style));
} else if has_below && i == visible_end - 1 {
spans.push(Span::styled("", indicator_style));
}
lines.push(Line::from(spans));
}
if self.items.len() > self.visible_count {
let position = self.cursor + 1;
let total = self.items.len();
lines.push(Line::from(Span::styled(
format!(" ({position}/{total})"),
indicator_style,
)));
}
frame.render_widget(Paragraph::new(lines), area);
}
}

View File

@@ -1,13 +1,17 @@
//! Centered modal frame with border and title.
use crate::theme;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Clear};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
/// Centered modal overlay with titled border.
pub struct ModalFrame<'a> {
title: &'a str,
width: u16,
height: u16,
border_color: Color,
border_color: Option<Color>,
}
impl<'a> ModalFrame<'a> {
@@ -16,7 +20,7 @@ impl<'a> ModalFrame<'a> {
title,
width: 40,
height: 5,
border_color: Color::White,
border_color: None,
}
}
@@ -31,11 +35,12 @@ impl<'a> ModalFrame<'a> {
}
pub fn border_color(mut self, c: Color) -> Self {
self.border_color = c;
self.border_color = Some(c);
self
}
pub fn render_centered(&self, frame: &mut Frame, term: Rect) -> Rect {
let t = theme::get();
let width = self.width.min(term.width.saturating_sub(4));
let height = self.height.min(term.height.saturating_sub(4));
@@ -45,10 +50,21 @@ impl<'a> ModalFrame<'a> {
frame.render_widget(Clear, area);
// Fill background with theme color
let bg_fill = " ".repeat(area.width as usize);
for row in 0..area.height {
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
frame.render_widget(
Paragraph::new(bg_fill.clone()).style(Style::new().bg(t.ui.bg)),
line_area,
);
}
let border_color = self.border_color.unwrap_or(t.ui.text_primary);
let block = Block::default()
.borders(Borders::ALL)
.title(self.title)
.border_style(Style::new().fg(self.border_color));
.border_style(Style::new().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);

View File

@@ -0,0 +1,119 @@
//! Page navigation minimap showing a 3x2 grid of tiles.
use crate::theme;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Style;
use ratatui::widgets::{Clear, Paragraph};
use ratatui::Frame;
const TILE_W: u16 = 12;
const TILE_H: u16 = 3;
const GAP: u16 = 1;
const PAD: u16 = 2;
const GRID_COLS: u16 = 3;
const GRID_ROWS: u16 = 2;
/// Compute the centered minimap area for a 3x2 grid.
pub fn minimap_area(term: Rect) -> Rect {
let content_w = TILE_W * GRID_COLS + GAP * (GRID_COLS - 1);
let content_h = TILE_H * GRID_ROWS + GAP * (GRID_ROWS - 1);
let modal_w = content_w + PAD * 2;
let modal_h = content_h + PAD * 2;
let x = term.x + (term.width.saturating_sub(modal_w)) / 2;
let y = term.y + (term.height.saturating_sub(modal_h)) / 2;
Rect::new(x, y, modal_w, modal_h)
}
/// Hit-test: returns `(grid_col, grid_row)` if the click lands on a tile.
pub fn hit_test_tile(col: u16, row: u16, term: Rect) -> Option<(i8, i8)> {
let area = minimap_area(term);
let inner_x = area.x + PAD;
let inner_y = area.y + PAD;
for grid_row in 0..GRID_ROWS {
for grid_col in 0..GRID_COLS {
let tx = inner_x + grid_col * (TILE_W + GAP);
let ty = inner_y + grid_row * (TILE_H + GAP);
if col >= tx && col < tx + TILE_W && row >= ty && row < ty + TILE_H {
return Some((grid_col as i8, grid_row as i8));
}
}
}
None
}
/// A tile in the navigation grid
pub struct NavTile {
pub col: i8,
pub row: i8,
pub name: &'static str,
}
/// Navigation minimap widget that renders a grid of page tiles
pub struct NavMinimap<'a> {
tiles: &'a [NavTile],
selected: (i8, i8),
}
impl<'a> NavMinimap<'a> {
pub fn new(tiles: &'a [NavTile], selected: (i8, i8)) -> Self {
Self { tiles, selected }
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
if self.tiles.is_empty() {
return;
}
let area = minimap_area(term);
frame.render_widget(Clear, area);
// Fill background with theme color
let t = theme::get();
let bg_fill = " ".repeat(area.width as usize);
for row in 0..area.height {
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
frame.render_widget(
Paragraph::new(bg_fill.clone()).style(Style::new().bg(t.ui.bg)),
line_area,
);
}
let inner_x = area.x + PAD;
let inner_y = area.y + PAD;
for tile in self.tiles {
let tile_x = inner_x + (tile.col as u16) * (TILE_W + GAP);
let tile_y = inner_y + (tile.row as u16) * (TILE_H + GAP);
let tile_area = Rect::new(tile_x, tile_y, TILE_W, TILE_H);
let is_selected = (tile.col, tile.row) == self.selected;
self.render_tile(frame, tile_area, tile.name, is_selected);
}
}
fn render_tile(&self, frame: &mut Frame, area: Rect, label: &str, is_selected: bool) {
let t = theme::get();
let (bg, fg) = if is_selected {
(t.nav.selected_bg, t.nav.selected_fg)
} else {
(t.nav.unselected_bg, t.nav.unselected_fg)
};
// Fill background
for row in 0..area.height {
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
let fill = " ".repeat(area.width as usize);
frame.render_widget(Paragraph::new(fill).style(Style::new().bg(bg)), line_area);
}
// Center text vertically
let text_y = area.y + area.height / 2;
let text_area = Rect::new(area.x, text_y, area.width, 1);
let paragraph = Paragraph::new(label)
.style(Style::new().bg(bg).fg(fg))
.alignment(Alignment::Center);
frame.render_widget(paragraph, text_area);
}
}

View File

@@ -0,0 +1,45 @@
//! Vertical label/value property form renderer.
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
/// Render a vertical list of label/value pairs with selection highlight.
pub fn render_props_form(frame: &mut Frame, area: Rect, fields: &[(&str, &str, bool)]) {
let theme = theme::get();
for (i, (label, value, selected)) in fields.iter().enumerate() {
let y = area.y + i as u16;
if y >= area.y + area.height {
break;
}
let (label_style, value_style) = if *selected {
(
Style::default()
.fg(theme.hint.key)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.ui.text_primary)
.bg(theme.ui.surface),
)
} else {
(
Style::default().fg(theme.ui.text_muted),
Style::default().fg(theme.ui.text_primary),
)
};
let label_area = Rect::new(area.x + 1, y, 14, 1);
let value_area = Rect::new(area.x + 16, y, area.width.saturating_sub(18), 1);
frame.render_widget(
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
}
}

View File

@@ -0,0 +1,182 @@
//! Tree-view sample browser with search filtering.
use crate::theme;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
/// Node type in the sample tree.
#[derive(Clone, Copy)]
pub enum TreeLineKind {
Root { expanded: bool },
Folder { expanded: bool },
File,
}
/// A single row in the sample browser tree.
#[derive(Clone)]
pub struct TreeLine {
pub depth: u8,
pub kind: TreeLineKind,
pub label: String,
pub folder: String,
pub index: usize,
}
/// Tree-view browser for navigating sample folders.
pub struct SampleBrowser<'a> {
entries: &'a [TreeLine],
cursor: usize,
scroll_offset: usize,
search_query: &'a str,
search_active: bool,
focused: bool,
}
impl<'a> SampleBrowser<'a> {
pub fn new(entries: &'a [TreeLine], cursor: usize) -> Self {
Self {
entries,
cursor,
scroll_offset: 0,
search_query: "",
search_active: false,
focused: false,
}
}
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn search(mut self, query: &'a str, active: bool) -> Self {
self.search_query = query;
self.search_active = active;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let colors = theme::get();
let border_style = if self.focused {
Style::new().fg(colors.browser.focused_border)
} else {
Style::new().fg(colors.browser.unfocused_border)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Samples ");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let show_search = self.search_active || !self.search_query.is_empty();
let (search_area, list_area) = if show_search {
let [s, l] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner);
(Some(s), l)
} else {
(None, inner)
};
if let Some(sa) = search_area {
self.render_search(frame, sa, &colors);
}
self.render_tree(frame, list_area, &colors);
}
fn render_search(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let style = if self.search_active {
Style::new().fg(colors.search.active)
} else {
Style::new().fg(colors.search.inactive)
};
let cursor = if self.search_active { "_" } else { "" };
let text = format!("/{}{}", self.search_query, cursor);
let line = Line::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(vec![line]), area);
}
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let height = area.height as usize;
if self.entries.is_empty() {
let msg = if self.search_query.is_empty() {
"No samples loaded"
} else {
"No matches"
};
let line = Line::from(Span::styled(msg, Style::new().fg(colors.browser.empty_text)));
frame.render_widget(Paragraph::new(vec![line]), area);
return;
}
let visible_end = (self.scroll_offset + height).min(self.entries.len());
let mut lines: Vec<Line> = Vec::with_capacity(height);
for i in self.scroll_offset..visible_end {
let entry = &self.entries[i];
let is_cursor = i == self.cursor;
let indent = " ".repeat(entry.depth as usize);
let (icon, icon_color) = match entry.kind {
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
("\u{25BC} ", colors.browser.folder_icon)
}
TreeLineKind::Root { expanded: false }
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", colors.browser.folder_icon),
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
};
let label_style = if is_cursor && self.focused {
Style::new().fg(colors.browser.selected).add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::new().fg(colors.browser.file)
} else {
match entry.kind {
TreeLineKind::Root { .. } => {
Style::new().fg(colors.browser.root).add_modifier(Modifier::BOLD)
}
TreeLineKind::Folder { .. } => Style::new().fg(colors.browser.directory),
TreeLineKind::File => Style::default(),
}
};
let icon_style = if is_cursor && self.focused {
label_style
} else {
Style::new().fg(icon_color)
};
let mut spans = vec![
Span::raw(indent),
Span::styled(icon, icon_style),
Span::styled(&entry.label, label_style),
];
if matches!(entry.kind, TreeLineKind::File) {
let idx_style = Style::new().fg(colors.browser.empty_text);
spans.push(Span::styled(format!(" {}", entry.index), idx_style));
}
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(lines), area);
}
}

176
crates/ratatui/src/scope.rs Normal file
View File

@@ -0,0 +1,176 @@
//! Oscilloscope waveform widget using braille characters.
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
/// Rendering direction for the oscilloscope.
#[derive(Clone, Copy)]
pub enum Orientation {
Horizontal,
Vertical,
}
/// Single-channel oscilloscope using braille dot plotting.
pub struct Scope<'a> {
data: &'a [f32],
orientation: Orientation,
color: Option<Color>,
gain: f32,
}
impl<'a> Scope<'a> {
pub fn new(data: &'a [f32]) -> Self {
Self {
data,
orientation: Orientation::Horizontal,
color: None,
gain: 1.0,
}
}
pub fn orientation(mut self, o: Orientation) -> Self {
self.orientation = o;
self
}
pub fn color(mut self, c: Color) -> Self {
self.color = Some(c);
self
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
}
impl Widget for Scope<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.data.is_empty() {
return;
}
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
match self.orientation {
Orientation::Horizontal => {
render_horizontal(self.data, area, buf, color, self.gain)
}
Orientation::Vertical => render_vertical(self.data, area, buf, color, self.gain),
}
}
}
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
let size = width * height;
patterns.clear();
patterns.resize(size, 0);
for fine_x in 0..fine_width {
let sample_idx = (fine_x * data.len()) / fine_width;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_y = fine_y.min(fine_height - 1);
let char_x = fine_x / 2;
let char_y = fine_y / 4;
let dot_x = fine_x % 2;
let dot_y = fine_y % 4;
let bit = match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
};
patterns[char_y * width + char_x] |= bit;
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
let size = width * height;
patterns.clear();
patterns.resize(size, 0);
for fine_y in 0..fine_height {
let sample_idx = (fine_y * data.len()) / fine_height;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fine_x = fine_x.min(fine_width - 1);
let char_x = fine_x / 2;
let char_y = fine_y / 4;
let dot_x = fine_x % 2;
let dot_y = fine_y % 4;
let bit = match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
};
patterns[char_y * width + char_x] |= bit;
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}

View File

@@ -0,0 +1,57 @@
//! Up/down arrow scroll indicators for bounded lists.
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
/// Horizontal alignment for scroll indicators.
pub enum IndicatorAlign {
Center,
Right,
}
/// Render up/down scroll arrows when content overflows.
pub fn render_scroll_indicators(
frame: &mut Frame,
area: Rect,
offset: usize,
visible: usize,
total: usize,
color: Color,
align: IndicatorAlign,
) {
let style = Style::new().fg(color);
match align {
IndicatorAlign::Center => {
if offset > 0 {
let indicator = Paragraph::new("")
.style(style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..area });
}
if offset + visible < total {
let y = area.y + area.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..area });
}
}
IndicatorAlign::Right => {
let x = area.x + area.width.saturating_sub(1);
if offset > 0 {
let indicator = Paragraph::new("").style(style);
frame.render_widget(indicator, Rect::new(x, area.y, 1, 1));
}
if offset + visible < total {
let indicator = Paragraph::new("").style(style);
frame.render_widget(
indicator,
Rect::new(x, area.y + area.height.saturating_sub(1), 1, 1),
);
}
}
}
}

View File

@@ -0,0 +1,23 @@
//! Inline search bar with active/inactive styling.
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
/// Render a `/query` search bar.
pub fn render_search_bar(frame: &mut Frame, area: Rect, query: &str, active: bool) {
let theme = theme::get();
let style = if active {
Style::new().fg(theme.search.active)
} else {
Style::new().fg(theme.search.inactive)
};
let cursor = if active { "_" } else { "" };
let text = format!(" /{query}{cursor}");
let line = Line::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(vec![line]), area);
}

View File

@@ -0,0 +1,33 @@
//! Section header with horizontal divider for engine-view panels.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
/// Render a section title with a horizontal divider below it.
pub fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
let theme = theme::get();
let [header_area, divider_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let header_style = if focused {
Style::new()
.fg(theme.engine.header_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(theme.engine.header)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(Paragraph::new(title).style(header_style), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)),
divider_area,
);
}

View File

@@ -0,0 +1,63 @@
//! Decorative particle effect using random Unicode glyphs.
use crate::theme;
use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
const CHARS: &[char] = &['·', '✦', '✧', '°', '•', '+', '⋆', '*'];
struct Sparkle {
x: u16,
y: u16,
char_idx: usize,
life: u8,
}
/// Animated sparkle particles for visual flair.
#[derive(Default)]
pub struct Sparkles {
sparkles: Vec<Sparkle>,
}
impl Sparkles {
pub fn tick(&mut self, area: Rect) {
let mut rng = rand::thread_rng();
for _ in 0..3 {
if rng.gen_bool(0.6) {
self.sparkles.push(Sparkle {
x: rng.gen_range(0..area.width),
y: rng.gen_range(0..area.height),
char_idx: rng.gen_range(0..CHARS.len()),
life: rng.gen_range(15..40),
});
}
}
self.sparkles
.iter_mut()
.for_each(|s| s.life = s.life.saturating_sub(1));
self.sparkles.retain(|s| s.life > 0);
}
}
impl Widget for &Sparkles {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = theme::get().sparkle.colors;
for sp in &self.sparkles {
let color = colors[sp.char_idx % colors.len()];
let intensity = (sp.life as f32 / 30.0).min(1.0);
let r = (color.0 as f32 * intensity) as u8;
let g = (color.1 as f32 * intensity) as u8;
let b = (color.2 as f32 * intensity) as u8;
if sp.x < area.width && sp.y < area.height {
let x = area.x + sp.x;
let y = area.y + sp.y;
let ch = CHARS[sp.char_idx];
buf[(x, y)].set_char(ch).set_style(Style::new().fg(Color::Rgb(r, g, b)));
}
}
}
}

View File

@@ -0,0 +1,238 @@
//! 32-band frequency spectrum display with optional peak hold.
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}'];
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum SpectrumStyle {
#[default]
Bars,
Line,
Filled,
}
thread_local! {
static PEAKS: RefCell<[f32; 32]> = const { RefCell::new([0.0; 32]) };
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
/// 32-band spectrum analyzer using block characters.
pub struct Spectrum<'a> {
data: &'a [f32; 32],
gain: f32,
style: SpectrumStyle,
peaks: bool,
}
impl<'a> Spectrum<'a> {
pub fn new(data: &'a [f32; 32]) -> Self {
Self {
data,
gain: 1.0,
style: SpectrumStyle::Bars,
peaks: false,
}
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
pub fn style(mut self, s: SpectrumStyle) -> Self {
self.style = s;
self
}
pub fn peaks(mut self, enabled: bool) -> Self {
self.peaks = enabled;
self
}
}
impl Widget for Spectrum<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
// Update peak hold state
let peak_values = if self.peaks {
Some(PEAKS.with(|p| {
let mut peaks = p.borrow_mut();
for (i, &mag) in self.data.iter().enumerate() {
let v = (mag * self.gain).min(1.0);
if v >= peaks[i] {
peaks[i] = v;
} else {
peaks[i] = (peaks[i] - 0.02).max(v);
}
}
*peaks
}))
} else {
None
};
match self.style {
SpectrumStyle::Bars => render_bars(self.data, area, buf, self.gain, peak_values.as_ref()),
SpectrumStyle::Line => render_braille(self.data, area, buf, self.gain, false, peak_values.as_ref()),
SpectrumStyle::Filled => render_braille(self.data, area, buf, self.gain, true, peak_values.as_ref()),
}
}
}
fn band_color(ratio: f32, colors: &theme::ThemeColors) -> Color {
if ratio < 0.33 {
Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2)
} else if ratio < 0.66 {
Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2)
} else {
Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2)
}
}
fn render_bars(data: &[f32; 32], area: Rect, buf: &mut Buffer, gain: f32, peaks: Option<&[f32; 32]>) {
let colors = theme::get();
let height = area.height as f32;
let base = area.width as usize / 32;
let remainder = area.width as usize % 32;
if base == 0 && remainder == 0 {
return;
}
let mut x_start = area.x;
for (band, &mag) in data.iter().enumerate() {
let w = base + if band < remainder { 1 } else { 0 };
if w == 0 {
continue;
}
let bar_height = (mag * gain).min(1.0) * height;
let full_cells = bar_height as usize;
let frac = bar_height - full_cells as f32;
let frac_idx = (frac * 8.0) as usize;
// Peak hold row
let peak_row = peaks.map(|p| {
let ph = p[band] * height;
let row = (height - ph).max(0.0) as usize;
row.min(area.height as usize - 1)
});
for row in 0..area.height as usize {
let y = area.y + area.height - 1 - row as u16;
let ratio = row as f32 / area.height as f32;
let color = band_color(ratio, &colors);
for dx in 0..w as u16 {
let x = x_start + dx;
if row < full_cells {
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
} else if row == full_cells && frac_idx > 0 {
buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color);
} else if let Some(pr) = peak_row {
// peak_row is from top (0 = top), row is from bottom
let from_top = area.height as usize - 1 - row;
if from_top == pr {
buf[(x, y)].set_char('─').set_fg(colors.meter.high);
}
}
}
}
x_start += w as u16;
}
}
fn render_braille(
data: &[f32; 32],
area: Rect,
buf: &mut Buffer,
gain: f32,
filled: bool,
peaks: Option<&[f32; 32]>,
) {
let colors = theme::get();
let width = area.width as usize;
let height = area.height as usize;
let fine_w = width * 2;
let fine_h = height * 4;
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
// Interpolate 32 bands across fine_w columns
for fx in 0..fine_w {
let band_f = fx as f32 * 31.0 / (fine_w - 1).max(1) as f32;
let lo = band_f as usize;
let hi = (lo + 1).min(31);
let t = band_f - lo as f32;
let mag = ((data[lo] * (1.0 - t) + data[hi] * t) * gain).min(1.0);
let fy = ((1.0 - mag) * (fine_h - 1) as f32).round() as usize;
let fy = fy.min(fine_h - 1);
if filled {
for y in fy..fine_h {
let cy = y / 4;
let dy = y % 4;
let cx = fx / 2;
let dx = fx % 2;
patterns[cy * width + cx] |= braille_bit(dx, dy);
}
} else {
let cy = fy / 4;
let dy = fy % 4;
let cx = fx / 2;
let dx = fx % 2;
patterns[cy * width + cx] |= braille_bit(dx, dy);
}
// Peak dots
if let Some(pk) = peaks {
let pv = (pk[lo] * (1.0 - t) + pk[hi] * t).min(1.0);
let py = ((1.0 - pv) * (fine_h - 1) as f32).round() as usize;
let py = py.min(fine_h - 1);
let cy = py / 4;
let dy = py % 4;
let cx = fx / 2;
let dx = fx % 2;
patterns[cy * width + cx] |= braille_bit(dx, dy);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ratio = 1.0 - (cy as f32 / height as f32);
let color = band_color(ratio, &colors);
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
}
}

View File

@@ -1,3 +1,6 @@
//! Single-line text input modal with optional hint.
use crate::theme;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
@@ -6,11 +9,12 @@ use ratatui::Frame;
use super::ModalFrame;
/// Modal dialog with a single-line text input.
pub struct TextInputModal<'a> {
title: &'a str,
input: &'a str,
hint: Option<&'a str>,
border_color: Color,
border_color: Option<Color>,
width: u16,
}
@@ -20,7 +24,7 @@ impl<'a> TextInputModal<'a> {
title,
input,
hint: None,
border_color: Color::White,
border_color: None,
width: 50,
}
}
@@ -31,7 +35,7 @@ impl<'a> TextInputModal<'a> {
}
pub fn border_color(mut self, c: Color) -> Self {
self.border_color = c;
self.border_color = Some(c);
self
}
@@ -40,13 +44,15 @@ impl<'a> TextInputModal<'a> {
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
let height = if self.hint.is_some() { 6 } else { 5 };
let inner = ModalFrame::new(self.title)
.width(self.width)
.height(height)
.border_color(self.border_color)
.border_color(border_color)
.render_centered(frame, term);
if self.hint.is_some() {
@@ -56,15 +62,15 @@ impl<'a> TextInputModal<'a> {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
Span::styled(self.input, Style::new().fg(colors.input.text)),
Span::styled("", Style::new().fg(colors.input.cursor)),
])),
rows[0],
);
if let Some(hint) = self.hint {
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))),
Paragraph::new(Span::styled(hint, Style::new().fg(colors.input.hint))),
rows[1],
);
}
@@ -72,11 +78,13 @@ impl<'a> TextInputModal<'a> {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(self.input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
Span::styled(self.input, Style::new().fg(colors.input.text)),
Span::styled("", Style::new().fg(colors.input.cursor)),
])),
inner,
);
}
inner
}
}

View File

@@ -0,0 +1,277 @@
//! Derive [`ThemeColors`] from a [`Palette`].
use super::*;
use super::palette::{Palette, Rgb, darken, mid, rgb, tint};
/// Build a complete [`ThemeColors`] from a [`Palette`].
pub fn build(p: &Palette) -> ThemeColors {
let darker_bg = darken(p.bg, 0.15);
ThemeColors {
ui: UiColors {
bg: rgb(p.bg),
bg_rgb: p.bg,
text_primary: rgb(p.fg),
text_muted: rgb(p.fg_dim),
text_dim: rgb(p.fg_muted),
border: rgb(p.surface2),
header: rgb(p.cyan),
unfocused: rgb(p.fg_muted),
accent: rgb(p.accent),
surface: rgb(p.surface),
},
status: StatusColors {
playing_bg: rgb(tint(p.bg, p.green, 0.25)),
playing_fg: rgb(p.green),
stopped_bg: rgb(tint(p.bg, p.red, 0.25)),
stopped_fg: rgb(p.red),
fill_on: rgb(p.green),
fill_off: rgb(p.fg_muted),
fill_bg: rgb(p.surface),
},
selection: SelectionColors {
cursor_bg: rgb(p.accent),
cursor_fg: rgb(p.bg),
selected_bg: rgb(tint(p.bg, p.accent, 0.30)),
selected_fg: rgb(p.accent),
in_range_bg: rgb(tint(p.bg, p.accent, 0.20)),
in_range_fg: rgb(p.fg),
cursor: rgb(p.accent),
selected: rgb(tint(p.bg, p.accent, 0.30)),
in_range: rgb(tint(p.bg, p.accent, 0.20)),
},
tile: TileColors {
playing_active_bg: rgb(tint(p.bg, p.orange, 0.35)),
playing_active_fg: rgb(p.orange),
playing_inactive_bg: rgb(tint(p.bg, p.yellow, 0.30)),
playing_inactive_fg: rgb(p.yellow),
active_bg: rgb(tint(p.bg, p.cyan, 0.25)),
active_fg: rgb(p.cyan),
content_bg: rgb(tint(p.bg, p.cyan, 0.30)),
inactive_bg: rgb(p.surface),
inactive_fg: rgb(p.fg_dim),
active_selected_bg: rgb(tint(p.bg, p.accent, 0.35)),
active_in_range_bg: rgb(tint(p.bg, p.accent, 0.22)),
link_bright: p.link_bright,
link_dim: p.link_dim,
},
header: HeaderColors {
tempo_bg: rgb(tint(p.bg, p.tempo_color, 0.30)),
tempo_fg: rgb(p.tempo_color),
bank_bg: rgb(tint(p.bg, p.bank_color, 0.25)),
bank_fg: rgb(p.bank_color),
pattern_bg: rgb(tint(p.bg, p.pattern_color, 0.25)),
pattern_fg: rgb(p.pattern_color),
stats_bg: rgb(p.surface),
stats_fg: rgb(p.fg_dim),
},
modal: ModalColors {
border: rgb(p.cyan),
border_accent: rgb(p.accent),
border_warn: rgb(p.orange),
border_dim: rgb(p.fg_muted),
confirm: rgb(p.orange),
rename: rgb(p.purple),
input: rgb(p.cyan),
editor: rgb(p.cyan),
preview: rgb(p.fg_muted),
},
flash: FlashColors {
error_bg: rgb(tint(p.bg, p.red, 0.30)),
error_fg: rgb(p.red),
success_bg: rgb(tint(p.bg, p.green, 0.25)),
success_fg: rgb(p.green),
info_bg: rgb(p.surface),
info_fg: rgb(p.fg),
},
list: ListColors {
playing_bg: rgb(tint(p.bg, p.green, 0.25)),
playing_fg: rgb(p.green),
staged_play_bg: rgb(tint(p.bg, p.purple, 0.30)),
staged_play_fg: rgb(p.purple),
staged_stop_bg: rgb(tint(p.bg, p.red, 0.30)),
staged_stop_fg: rgb(p.red),
edit_bg: rgb(tint(p.bg, p.cyan, 0.25)),
edit_fg: rgb(p.cyan),
hover_bg: rgb(p.surface2),
hover_fg: rgb(p.fg),
muted_bg: rgb(tint(p.bg, p.surface, 0.30)),
muted_fg: rgb(p.fg_muted),
soloed_bg: rgb(tint(p.bg, p.yellow, 0.30)),
soloed_fg: rgb(p.yellow),
},
link_status: LinkStatusColors {
disabled: rgb(p.red),
connected: rgb(p.green),
listening: rgb(p.yellow),
},
syntax: syntax_colors(p, darker_bg),
table: TableColors {
row_even: rgb(darker_bg),
row_odd: rgb(p.bg),
},
values: ValuesColors {
tempo: rgb(p.orange),
value: rgb(p.fg_dim),
},
hint: HintColors {
key: rgb(p.orange),
text: rgb(p.fg_muted),
},
view_badge: ViewBadgeColors {
bg: rgb(p.fg),
fg: rgb(p.bg),
},
nav: NavColors {
selected_bg: rgb(tint(p.bg, p.accent, 0.35)),
selected_fg: rgb(p.fg),
unselected_bg: rgb(p.surface),
unselected_fg: rgb(p.fg_muted),
},
editor_widget: EditorWidgetColors {
cursor_bg: rgb(p.fg),
cursor_fg: rgb(p.bg),
selection_bg: rgb(tint(p.bg, p.accent, 0.30)),
completion_bg: rgb(p.surface),
completion_fg: rgb(p.fg),
completion_selected: rgb(p.orange),
completion_example: rgb(p.cyan),
},
browser: BrowserColors {
directory: rgb(p.blue),
project_file: rgb(p.purple),
selected: rgb(p.orange),
file: rgb(p.fg),
focused_border: rgb(p.orange),
unfocused_border: rgb(p.fg_muted),
root: rgb(p.fg),
file_icon: rgb(p.fg_muted),
folder_icon: rgb(p.blue),
empty_text: rgb(p.fg_muted),
},
input: InputColors {
text: rgb(p.cyan),
cursor: rgb(p.fg),
hint: rgb(p.fg_muted),
},
search: SearchColors {
active: rgb(p.orange),
inactive: rgb(p.fg_muted),
match_bg: rgb(p.yellow),
match_fg: rgb(p.bg),
},
markdown: MarkdownColors {
h1: rgb(p.cyan),
h2: rgb(p.orange),
h3: rgb(p.purple),
code: rgb(p.green),
code_border: rgb(mid(p.surface2, p.fg_muted, 0.3)),
link: rgb(p.accent),
link_url: rgb(mid(p.fg_muted, p.fg_dim, 0.3)),
quote: rgb(p.fg_muted),
text: rgb(p.fg),
list: rgb(p.fg),
},
engine: engine_colors(p),
dict: dict_colors(p),
title: TitleColors {
big_title: rgb(p.title_accent),
author: rgb(p.title_author),
link: rgb(p.green),
license: rgb(p.orange),
prompt: rgb(mid(p.fg_dim, p.fg, 0.3)),
subtitle: rgb(p.fg),
},
meter: MeterColors {
low: rgb(p.green),
mid: rgb(p.yellow),
high: rgb(p.red),
low_rgb: p.meter[0],
mid_rgb: p.meter[1],
high_rgb: p.meter[2],
},
sparkle: SparkleColors {
colors: p.sparkle,
},
confirm: ConfirmColors {
border: rgb(p.orange),
button_selected_bg: rgb(p.orange),
button_selected_fg: rgb(p.bg),
},
}
}
fn syntax_colors(p: &Palette, darker_bg: Rgb) -> SyntaxColors {
let syn_bg = |accent: Rgb| -> Color { rgb(tint(p.bg, accent, 0.20)) };
let interval_fg = mid(p.green, p.fg, 0.3);
SyntaxColors {
gap_bg: rgb(darker_bg),
executed_bg: rgb(tint(p.bg, p.purple, 0.15)),
selected_bg: rgb(tint(p.bg, p.orange, 0.30)),
emit: (rgb(p.fg), syn_bg(p.accent)),
number: (rgb(p.purple), syn_bg(p.purple)),
string: (rgb(p.green), syn_bg(p.green)),
comment: (rgb(p.fg_muted), rgb(darker_bg)),
keyword: (rgb(p.accent), syn_bg(p.accent)),
stack_op: (rgb(p.blue), syn_bg(p.blue)),
operator: (rgb(p.red), syn_bg(p.red)),
sound: (rgb(p.cyan), syn_bg(p.cyan)),
param: (rgb(p.orange), syn_bg(p.orange)),
context: (rgb(p.orange), syn_bg(p.orange)),
note: (rgb(p.green), syn_bg(p.green)),
interval: (rgb(interval_fg), syn_bg(p.green)),
variable: (rgb(p.purple), syn_bg(p.purple)),
vary: (rgb(p.yellow), syn_bg(p.yellow)),
generator: (rgb(p.cyan), syn_bg(p.cyan)),
user_defined: (rgb(p.secondary), syn_bg(p.secondary)),
default: (rgb(p.fg_dim), rgb(darker_bg)),
}
}
fn engine_colors(p: &Palette) -> EngineColors {
let divider = mid(p.surface2, p.fg_muted, 0.2);
let label = mid(p.fg_muted, p.fg, 0.4);
EngineColors {
header: rgb(p.cyan),
header_focused: rgb(p.yellow),
divider: rgb(divider),
scroll_indicator: rgb(mid(divider, p.fg_muted, 0.3)),
label: rgb(label),
label_focused: rgb(mid(label, p.fg, 0.3)),
label_dim: rgb(mid(p.fg_muted, label, 0.3)),
value: rgb(mid(p.fg_dim, p.fg, 0.5)),
focused: rgb(p.yellow),
normal: rgb(p.fg),
dim: rgb(mid(divider, p.fg_muted, 0.3)),
path: rgb(label),
border_magenta: rgb(p.purple),
border_green: rgb(p.green),
border_cyan: rgb(p.cyan),
separator: rgb(divider),
hint_active: rgb(mid(p.orange, p.yellow, 0.5)),
hint_inactive: rgb(divider),
}
}
fn dict_colors(p: &Palette) -> DictColors {
let divider = mid(p.surface2, p.fg_muted, 0.2);
let label = mid(p.fg_muted, p.fg, 0.4);
DictColors {
word_name: rgb(p.green),
word_bg: rgb(tint(p.bg, p.cyan, 0.20)),
alias: rgb(p.fg_muted),
stack_sig: rgb(p.purple),
description: rgb(p.fg),
example: rgb(label),
category_focused: rgb(p.yellow),
category_selected: rgb(p.cyan),
category_normal: rgb(p.fg),
category_dimmed: rgb(mid(divider, p.fg_muted, 0.3)),
border_focused: rgb(p.yellow),
border_normal: rgb(divider),
header_desc: rgb(mid(label, p.fg, 0.3)),
}
}

View File

@@ -0,0 +1,41 @@
//! Catppuccin Latte palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (239, 241, 245),
surface: (204, 208, 218),
surface2: (188, 192, 204),
fg: (76, 79, 105),
fg_dim: (108, 111, 133),
fg_muted: (140, 143, 161),
accent: (136, 57, 239), // mauve
red: (210, 15, 57),
green: (64, 160, 43),
yellow: (223, 142, 29),
blue: (32, 159, 181), // sapphire
purple: (136, 57, 239),
cyan: (23, 146, 153), // teal
orange: (254, 100, 11), // peach
tempo_color: (136, 57, 239),
bank_color: (32, 159, 181),
pattern_color: (23, 146, 153),
title_accent: (136, 57, 239),
title_author: (114, 135, 253),
secondary: (230, 69, 83), // maroon
link_bright: [
(136, 57, 239), (234, 118, 203), (254, 100, 11),
(4, 165, 229), (64, 160, 43),
],
link_dim: [
(210, 200, 240), (240, 210, 230), (250, 220, 200),
(200, 230, 240), (210, 235, 210),
],
sparkle: [
(114, 135, 253), (254, 100, 11), (64, 160, 43),
(234, 118, 203), (136, 57, 239),
],
meter: [(50, 150, 40), (200, 140, 30), (200, 40, 50)],
}
}

View File

@@ -0,0 +1,41 @@
//! Catppuccin Mocha palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (30, 30, 46),
surface: (49, 50, 68),
surface2: (69, 71, 90),
fg: (205, 214, 244),
fg_dim: (166, 173, 200),
fg_muted: (127, 132, 156),
accent: (203, 166, 247), // mauve
red: (243, 139, 168),
green: (166, 227, 161),
yellow: (249, 226, 175),
blue: (116, 199, 236), // sapphire
purple: (203, 166, 247), // mauve
cyan: (148, 226, 213), // teal
orange: (250, 179, 135), // peach
tempo_color: (203, 166, 247),
bank_color: (116, 199, 236),
pattern_color: (148, 226, 213),
title_accent: (203, 166, 247),
title_author: (180, 190, 254),
secondary: (235, 160, 172), // maroon
link_bright: [
(203, 166, 247), (245, 194, 231), (250, 179, 135),
(137, 220, 235), (166, 227, 161),
],
link_dim: [
(70, 55, 85), (85, 65, 80), (85, 60, 45),
(45, 75, 80), (55, 80, 55),
],
sparkle: [
(200, 220, 255), (250, 179, 135), (166, 227, 161),
(245, 194, 231), (203, 166, 247),
],
meter: [(40, 180, 80), (220, 180, 40), (220, 60, 40)],
}
}

View File

@@ -0,0 +1,41 @@
//! Dracula palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (40, 42, 54),
surface: (68, 71, 90),
surface2: (55, 57, 70),
fg: (248, 248, 242),
fg_dim: (98, 114, 164),
fg_muted: (80, 85, 110),
accent: (189, 147, 249), // purple
red: (255, 85, 85),
green: (80, 250, 123),
yellow: (241, 250, 140),
blue: (139, 233, 253), // cyan
purple: (189, 147, 249),
cyan: (139, 233, 253),
orange: (255, 184, 108),
tempo_color: (189, 147, 249),
bank_color: (139, 233, 253),
pattern_color: (80, 250, 123),
title_accent: (189, 147, 249),
title_author: (255, 121, 198),
secondary: (255, 184, 108),
link_bright: [
(189, 147, 249), (255, 121, 198), (255, 184, 108),
(139, 233, 253), (80, 250, 123),
],
link_dim: [
(75, 60, 95), (95, 55, 80), (95, 70, 50),
(55, 90, 95), (40, 95, 55),
],
sparkle: [
(189, 147, 249), (255, 184, 108), (80, 250, 123),
(255, 121, 198), (139, 233, 253),
],
meter: [(70, 230, 110), (230, 240, 130), (240, 80, 80)],
}
}

View File

@@ -0,0 +1,41 @@
//! Eden palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (8, 12, 8),
surface: (16, 24, 16),
surface2: (32, 48, 32),
fg: (200, 216, 192),
fg_dim: (122, 144, 112),
fg_muted: (64, 88, 56),
accent: (64, 192, 64), // green
red: (192, 80, 64),
green: (96, 224, 96), // bright_green
yellow: (160, 160, 64),
blue: (80, 128, 160),
purple: (128, 104, 144),
cyan: (80, 168, 144),
orange: (176, 128, 48),
tempo_color: (128, 104, 144),
bank_color: (80, 128, 160),
pattern_color: (80, 168, 144),
title_accent: (64, 192, 64),
title_author: (80, 168, 144),
secondary: (176, 128, 48),
link_bright: [
(64, 192, 64), (80, 168, 144), (160, 160, 64),
(80, 128, 160), (192, 80, 64),
],
link_dim: [
(14, 38, 14), (16, 34, 28), (32, 32, 14),
(16, 26, 32), (38, 16, 14),
],
sparkle: [
(64, 192, 64), (96, 224, 96), (80, 168, 144),
(160, 160, 64), (48, 128, 48),
],
meter: [(64, 192, 64), (160, 160, 64), (192, 80, 64)],
}
}

View File

@@ -0,0 +1,41 @@
//! Ember palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (10, 8, 8),
surface: (20, 16, 16),
surface2: (42, 32, 32),
fg: (232, 221, 208),
fg_dim: (160, 144, 128),
fg_muted: (96, 80, 64),
accent: (224, 128, 48), // orange
red: (224, 80, 64),
green: (128, 160, 80),
yellow: (208, 160, 48),
blue: (96, 128, 176),
purple: (160, 112, 144),
cyan: (112, 160, 160),
orange: (224, 128, 48),
tempo_color: (160, 112, 144),
bank_color: (96, 128, 176),
pattern_color: (112, 160, 160),
title_accent: (224, 128, 48),
title_author: (224, 80, 64),
secondary: (160, 112, 144),
link_bright: [
(224, 128, 48), (224, 80, 64), (208, 160, 48),
(112, 160, 160), (128, 160, 80),
],
link_dim: [
(45, 28, 14), (45, 18, 14), (42, 32, 12),
(22, 32, 32), (26, 32, 18),
],
sparkle: [
(224, 128, 48), (224, 80, 64), (208, 160, 48),
(112, 160, 160), (128, 160, 80),
],
meter: [(128, 160, 80), (208, 160, 48), (224, 80, 64)],
}
}

View File

@@ -0,0 +1,41 @@
//! Everforest palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (45, 53, 59),
surface: (52, 62, 68),
surface2: (68, 80, 86),
fg: (211, 198, 170),
fg_dim: (135, 131, 116),
fg_muted: (80, 80, 68),
accent: (167, 192, 128),
red: (230, 126, 128),
green: (167, 192, 128),
yellow: (219, 188, 127),
blue: (127, 187, 179),
purple: (214, 153, 182),
cyan: (131, 192, 146),
orange: (230, 152, 117),
tempo_color: (214, 153, 182),
bank_color: (127, 187, 179),
pattern_color: (131, 192, 146),
title_accent: (167, 192, 128),
title_author: (127, 187, 179),
secondary: (230, 152, 117),
link_bright: [
(167, 192, 128), (214, 153, 182), (230, 152, 117),
(127, 187, 179), (219, 188, 127),
],
link_dim: [
(56, 66, 46), (70, 52, 62), (72, 52, 42),
(44, 64, 60), (70, 62, 44),
],
sparkle: [
(167, 192, 128), (230, 152, 117), (131, 192, 146),
(214, 153, 182), (219, 188, 127),
],
meter: [(148, 172, 110), (200, 170, 108), (210, 108, 110)],
}
}

View File

@@ -0,0 +1,41 @@
//! Fairyfloss palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (90, 84, 117),
surface: (113, 103, 153),
surface2: (130, 120, 165),
fg: (248, 248, 240),
fg_dim: (197, 163, 255),
fg_muted: (168, 164, 177),
accent: (255, 184, 209), // pink
red: (255, 133, 127), // coral
green: (194, 255, 223), // mint
yellow: (255, 243, 82),
blue: (197, 163, 255), // lavender
purple: (174, 129, 255),
cyan: (194, 255, 223), // mint
orange: (255, 133, 127), // coral
tempo_color: (255, 184, 209),
bank_color: (194, 255, 223),
pattern_color: (174, 129, 255),
title_accent: (255, 184, 209),
title_author: (194, 255, 223),
secondary: (255, 133, 127),
link_bright: [
(255, 184, 209), (174, 129, 255), (255, 133, 127),
(194, 255, 223), (255, 243, 82),
],
link_dim: [
(100, 75, 90), (85, 70, 105), (100, 65, 65),
(75, 100, 95), (100, 95, 55),
],
sparkle: [
(194, 255, 223), (255, 133, 127), (255, 243, 82),
(255, 184, 209), (174, 129, 255),
],
meter: [(194, 255, 223), (255, 243, 82), (255, 133, 127)],
}
}

View File

@@ -0,0 +1,41 @@
//! Fauve palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (28, 22, 18),
surface: (42, 33, 26),
surface2: (58, 46, 36),
fg: (240, 228, 210),
fg_dim: (170, 150, 130),
fg_muted: (100, 82, 66),
accent: (230, 60, 20),
red: (220, 38, 32),
green: (30, 170, 80),
yellow: (255, 210, 0),
blue: (20, 80, 200),
purple: (170, 40, 150),
cyan: (0, 150, 180),
orange: (240, 120, 0),
tempo_color: (230, 60, 20),
bank_color: (20, 80, 200),
pattern_color: (0, 150, 180),
title_accent: (230, 60, 20),
title_author: (20, 80, 200),
secondary: (170, 40, 150),
link_bright: [
(230, 60, 20), (20, 80, 200), (240, 120, 0),
(0, 150, 180), (30, 170, 80),
],
link_dim: [
(72, 24, 10), (10, 28, 65), (76, 40, 6),
(6, 48, 58), (14, 54, 28),
],
sparkle: [
(230, 60, 20), (255, 210, 0), (30, 170, 80),
(20, 80, 200), (170, 40, 150),
],
meter: [(26, 152, 72), (235, 190, 0), (200, 34, 28)],
}
}

View File

@@ -0,0 +1,41 @@
//! Georges palette (C64 colors on black).
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (0, 0, 0),
surface: (16, 16, 16),
surface2: (24, 24, 24),
fg: (187, 187, 187),
fg_dim: (119, 119, 119),
fg_muted: (51, 51, 51),
accent: (0, 136, 255), // lightblue
red: (255, 119, 119), // lightred
green: (0, 204, 85),
yellow: (238, 238, 119),
blue: (0, 136, 255), // lightblue
purple: (204, 68, 204), // violet
cyan: (170, 255, 238),
orange: (221, 136, 85),
tempo_color: (204, 68, 204),
bank_color: (0, 136, 255),
pattern_color: (0, 204, 85),
title_accent: (0, 136, 255),
title_author: (0, 204, 85),
secondary: (221, 136, 85),
link_bright: [
(0, 136, 255), (0, 204, 85), (238, 238, 119),
(204, 68, 204), (170, 255, 238),
],
link_dim: [
(0, 20, 40), (0, 30, 12), (34, 34, 16),
(30, 10, 30), (24, 36, 34),
],
sparkle: [
(0, 136, 255), (170, 255, 238), (0, 204, 85),
(238, 238, 119), (204, 68, 204),
],
meter: [(0, 204, 85), (238, 238, 119), (255, 119, 119)],
}
}

View File

@@ -0,0 +1,41 @@
//! Gruvbox Dark palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (40, 40, 40),
surface: (60, 56, 54),
surface2: (80, 73, 69),
fg: (235, 219, 178),
fg_dim: (189, 174, 147),
fg_muted: (168, 153, 132),
accent: (254, 128, 25), // orange
red: (251, 73, 52),
green: (184, 187, 38),
yellow: (250, 189, 47),
blue: (131, 165, 152),
purple: (211, 134, 155),
cyan: (142, 192, 124), // aqua
orange: (254, 128, 25),
tempo_color: (254, 128, 25),
bank_color: (131, 165, 152),
pattern_color: (142, 192, 124),
title_accent: (254, 128, 25),
title_author: (250, 189, 47),
secondary: (254, 128, 25),
link_bright: [
(254, 128, 25), (211, 134, 155), (250, 189, 47),
(131, 165, 152), (184, 187, 38),
],
link_dim: [
(85, 55, 35), (75, 55, 65), (80, 70, 40),
(50, 60, 60), (60, 65, 35),
],
sparkle: [
(250, 189, 47), (254, 128, 25), (184, 187, 38),
(211, 134, 155), (131, 165, 152),
],
meter: [(170, 175, 35), (235, 180, 45), (240, 70, 50)],
}
}

View File

@@ -0,0 +1,41 @@
//! Hot Dog Stand palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (255, 0, 0),
surface: (215, 0, 0),
surface2: (175, 0, 0),
fg: (255, 255, 0),
fg_dim: (255, 255, 95),
fg_muted: (255, 215, 0),
accent: (255, 255, 0),
red: (255, 255, 255),
green: (255, 255, 0),
yellow: (255, 215, 0),
blue: (255, 255, 0),
purple: (255, 255, 255),
cyan: (255, 255, 0),
orange: (255, 215, 0),
tempo_color: (255, 255, 0),
bank_color: (255, 255, 0),
pattern_color: (255, 255, 0),
title_accent: (255, 255, 0),
title_author: (255, 255, 255),
secondary: (255, 215, 0),
link_bright: [
(255, 255, 0), (255, 255, 255), (255, 215, 0),
(255, 255, 95), (255, 255, 0),
],
link_dim: [
(140, 140, 0), (140, 140, 140), (140, 120, 0),
(140, 140, 60), (140, 140, 0),
],
sparkle: [
(255, 255, 0), (255, 255, 255), (255, 215, 0),
(255, 255, 95), (255, 255, 0),
],
meter: [(255, 255, 0), (255, 215, 0), (255, 255, 255)],
}
}

View File

@@ -0,0 +1,41 @@
//! Iceberg palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (22, 24, 33),
surface: (30, 33, 46),
surface2: (45, 48, 64),
fg: (198, 200, 209),
fg_dim: (109, 112, 126),
fg_muted: (64, 66, 78),
accent: (132, 160, 198),
red: (226, 120, 120),
green: (180, 190, 130),
yellow: (226, 164, 120),
blue: (132, 160, 198),
purple: (160, 147, 199),
cyan: (137, 184, 194),
orange: (226, 164, 120),
tempo_color: (160, 147, 199),
bank_color: (132, 160, 198),
pattern_color: (137, 184, 194),
title_accent: (132, 160, 198),
title_author: (160, 147, 199),
secondary: (226, 164, 120),
link_bright: [
(132, 160, 198), (160, 147, 199), (226, 164, 120),
(137, 184, 194), (180, 190, 130),
],
link_dim: [
(45, 55, 70), (55, 50, 68), (70, 55, 42),
(46, 62, 66), (58, 62, 44),
],
sparkle: [
(132, 160, 198), (226, 164, 120), (180, 190, 130),
(160, 147, 199), (226, 120, 120),
],
meter: [(160, 175, 115), (210, 150, 105), (200, 105, 105)],
}
}

View File

@@ -0,0 +1,41 @@
//! Jaipur palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (30, 24, 22),
surface: (44, 36, 32),
surface2: (60, 48, 42),
fg: (238, 222, 200),
fg_dim: (165, 145, 125),
fg_muted: (95, 78, 65),
accent: (210, 90, 100),
red: (200, 44, 52),
green: (30, 160, 120),
yellow: (240, 180, 20),
blue: (60, 60, 180),
purple: (150, 50, 120),
cyan: (0, 155, 155),
orange: (220, 120, 50),
tempo_color: (210, 90, 100),
bank_color: (60, 60, 180),
pattern_color: (0, 155, 155),
title_accent: (210, 90, 100),
title_author: (60, 60, 180),
secondary: (220, 120, 50),
link_bright: [
(210, 90, 100), (60, 60, 180), (220, 120, 50),
(0, 155, 155), (30, 160, 120),
],
link_dim: [
(66, 30, 34), (22, 22, 58), (70, 40, 18),
(6, 48, 48), (12, 50, 38),
],
sparkle: [
(210, 90, 100), (240, 180, 20), (30, 160, 120),
(60, 60, 180), (150, 50, 120),
],
meter: [(26, 144, 106), (222, 164, 18), (184, 40, 46)],
}
}

View File

@@ -0,0 +1,41 @@
//! Kanagawa palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (31, 31, 40),
surface: (43, 43, 54),
surface2: (54, 54, 70),
fg: (220, 215, 186),
fg_dim: (160, 158, 140),
fg_muted: (114, 113, 105),
accent: (210, 126, 153), // sakura_pink
red: (195, 64, 67),
green: (118, 148, 106),
yellow: (230, 195, 132), // carp_yellow
blue: (126, 156, 216), // crystal_blue
purple: (149, 127, 184), // oni_violet
cyan: (127, 180, 202), // spring_blue
orange: (230, 195, 132), // carp_yellow
tempo_color: (149, 127, 184),
bank_color: (126, 156, 216),
pattern_color: (118, 148, 106),
title_accent: (210, 126, 153),
title_author: (126, 156, 216),
secondary: (210, 126, 153),
link_bright: [
(228, 104, 118), (149, 127, 184), (230, 195, 132),
(127, 180, 202), (118, 148, 106),
],
link_dim: [
(75, 45, 50), (55, 50, 70), (70, 60, 50),
(45, 60, 70), (45, 55, 45),
],
sparkle: [
(127, 180, 202), (230, 195, 132), (118, 148, 106),
(228, 104, 118), (149, 127, 184),
],
meter: [(118, 148, 106), (230, 195, 132), (228, 104, 118)],
}
}

View File

@@ -0,0 +1,41 @@
//! Letz Light palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (255, 255, 255),
surface: (235, 235, 240),
surface2: (210, 210, 215),
fg: (29, 29, 31),
fg_dim: (110, 110, 115),
fg_muted: (160, 160, 165),
accent: (0, 112, 243),
red: (209, 47, 27),
green: (112, 127, 52),
yellow: (200, 150, 20),
blue: (0, 112, 243),
purple: (173, 61, 164), // keyword
cyan: (62, 128, 135), // function
orange: (120, 73, 42), // preproc
tempo_color: (112, 61, 170),
bank_color: (0, 112, 243),
pattern_color: (62, 128, 135),
title_accent: (0, 112, 243),
title_author: (112, 61, 170),
secondary: (120, 73, 42),
link_bright: [
(173, 61, 164), (0, 112, 243), (120, 73, 42),
(62, 128, 135), (112, 127, 52),
],
link_dim: [
(235, 215, 235), (210, 225, 250), (240, 225, 210),
(215, 235, 240), (225, 235, 215),
],
sparkle: [
(0, 112, 243), (173, 61, 164), (112, 127, 52),
(62, 128, 135), (120, 73, 42),
],
meter: [(112, 127, 52), (200, 150, 20), (209, 47, 27)],
}
}

View File

@@ -0,0 +1,434 @@
//! Centralized color definitions for Cagire TUI.
//! Supports multiple color schemes with runtime switching.
pub mod palette;
pub mod build;
mod catppuccin_latte;
mod catppuccin_mocha;
mod dracula;
mod eden;
mod ember;
mod everforest;
mod georges;
mod fairyfloss;
mod gruvbox_dark;
mod hot_dog_stand;
mod iceberg;
mod jaipur;
mod kanagawa;
mod letz_light;
mod monochrome_black;
mod monochrome_white;
mod monokai;
mod nord;
mod fauve;
mod pitch_black;
mod tropicalia;
mod rose_pine;
mod tokyo_night;
pub mod transform;
use ratatui::style::Color;
use std::cell::RefCell;
/// Entry in the theme registry: id, display label, and palette constructor.
pub struct ThemeEntry {
pub id: &'static str,
pub label: &'static str,
pub palette: fn() -> palette::Palette,
}
/// All available themes.
pub const THEMES: &[ThemeEntry] = &[
ThemeEntry { id: "CatppuccinMocha", label: "Catppuccin Mocha", palette: catppuccin_mocha::palette },
ThemeEntry { id: "CatppuccinLatte", label: "Catppuccin Latte", palette: catppuccin_latte::palette },
ThemeEntry { id: "Nord", label: "Nord", palette: nord::palette },
ThemeEntry { id: "Dracula", label: "Dracula", palette: dracula::palette },
ThemeEntry { id: "GruvboxDark", label: "Gruvbox Dark", palette: gruvbox_dark::palette },
ThemeEntry { id: "Monokai", label: "Monokai", palette: monokai::palette },
ThemeEntry { id: "MonochromeBlack", label: "Monochrome (Black)", palette: monochrome_black::palette },
ThemeEntry { id: "MonochromeWhite", label: "Monochrome (White)", palette: monochrome_white::palette },
ThemeEntry { id: "PitchBlack", label: "Pitch Black", palette: pitch_black::palette },
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", palette: tokyo_night::palette },
ThemeEntry { id: "RosePine", label: "Rosé Pine", palette: rose_pine::palette },
ThemeEntry { id: "Kanagawa", label: "Kanagawa", palette: kanagawa::palette },
ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", palette: fairyfloss::palette },
ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", palette: hot_dog_stand::palette },
ThemeEntry { id: "LetzLight", label: "Letz Light", palette: letz_light::palette },
ThemeEntry { id: "Ember", label: "Ember", palette: ember::palette },
ThemeEntry { id: "Eden", label: "Eden", palette: eden::palette },
ThemeEntry { id: "Georges", label: "Georges", palette: georges::palette },
ThemeEntry { id: "Iceberg", label: "Iceberg", palette: iceberg::palette },
ThemeEntry { id: "Everforest", label: "Everforest", palette: everforest::palette },
ThemeEntry { id: "Fauve", label: "Fauve", palette: fauve::palette },
ThemeEntry { id: "Tropicalia", label: "Tropicalia", palette: tropicalia::palette },
ThemeEntry { id: "Jaipur", label: "Jaipur", palette: jaipur::palette },
];
thread_local! {
static CURRENT_THEME: RefCell<ThemeColors> = RefCell::new(build::build(&(THEMES[0].palette)()));
}
/// Return the current thread-local theme.
pub fn get() -> ThemeColors {
CURRENT_THEME.with(|t| t.borrow().clone())
}
/// Set the current thread-local theme.
pub fn set(theme: ThemeColors) {
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
}
/// Complete set of resolved colors for all UI components.
#[derive(Clone)]
pub struct ThemeColors {
pub ui: UiColors,
pub status: StatusColors,
pub selection: SelectionColors,
pub tile: TileColors,
pub header: HeaderColors,
pub modal: ModalColors,
pub flash: FlashColors,
pub list: ListColors,
pub link_status: LinkStatusColors,
pub syntax: SyntaxColors,
pub table: TableColors,
pub values: ValuesColors,
pub hint: HintColors,
pub view_badge: ViewBadgeColors,
pub nav: NavColors,
pub editor_widget: EditorWidgetColors,
pub browser: BrowserColors,
pub input: InputColors,
pub search: SearchColors,
pub markdown: MarkdownColors,
pub engine: EngineColors,
pub dict: DictColors,
pub title: TitleColors,
pub meter: MeterColors,
pub sparkle: SparkleColors,
pub confirm: ConfirmColors,
}
/// Core UI colors: background, text, borders.
#[derive(Clone)]
pub struct UiColors {
pub bg: Color,
pub bg_rgb: (u8, u8, u8),
pub text_primary: Color,
pub text_muted: Color,
pub text_dim: Color,
pub border: Color,
pub header: Color,
pub unfocused: Color,
pub accent: Color,
pub surface: Color,
}
/// Playback status bar colors.
#[derive(Clone)]
pub struct StatusColors {
pub playing_bg: Color,
pub playing_fg: Color,
pub stopped_bg: Color,
pub stopped_fg: Color,
pub fill_on: Color,
pub fill_off: Color,
pub fill_bg: Color,
}
/// Step grid selection and cursor colors.
#[derive(Clone)]
pub struct SelectionColors {
pub cursor_bg: Color,
pub cursor_fg: Color,
pub selected_bg: Color,
pub selected_fg: Color,
pub in_range_bg: Color,
pub in_range_fg: Color,
pub cursor: Color,
pub selected: Color,
pub in_range: Color,
}
/// Step tile colors for various states.
#[derive(Clone)]
pub struct TileColors {
pub playing_active_bg: Color,
pub playing_active_fg: Color,
pub playing_inactive_bg: Color,
pub playing_inactive_fg: Color,
pub active_bg: Color,
pub active_fg: Color,
pub content_bg: Color,
pub inactive_bg: Color,
pub inactive_fg: Color,
pub active_selected_bg: Color,
pub active_in_range_bg: Color,
pub link_bright: [(u8, u8, u8); 5],
pub link_dim: [(u8, u8, u8); 5],
}
/// Top header bar segment colors.
#[derive(Clone)]
pub struct HeaderColors {
pub tempo_bg: Color,
pub tempo_fg: Color,
pub bank_bg: Color,
pub bank_fg: Color,
pub pattern_bg: Color,
pub pattern_fg: Color,
pub stats_bg: Color,
pub stats_fg: Color,
}
/// Modal dialog border colors.
#[derive(Clone)]
pub struct ModalColors {
pub border: Color,
pub border_accent: Color,
pub border_warn: Color,
pub border_dim: Color,
pub confirm: Color,
pub rename: Color,
pub input: Color,
pub editor: Color,
pub preview: Color,
}
/// Flash notification colors.
#[derive(Clone)]
pub struct FlashColors {
pub error_bg: Color,
pub error_fg: Color,
pub success_bg: Color,
pub success_fg: Color,
pub info_bg: Color,
pub info_fg: Color,
}
/// Pattern list row state colors.
#[derive(Clone)]
pub struct ListColors {
pub playing_bg: Color,
pub playing_fg: Color,
pub staged_play_bg: Color,
pub staged_play_fg: Color,
pub staged_stop_bg: Color,
pub staged_stop_fg: Color,
pub edit_bg: Color,
pub edit_fg: Color,
pub hover_bg: Color,
pub hover_fg: Color,
pub muted_bg: Color,
pub muted_fg: Color,
pub soloed_bg: Color,
pub soloed_fg: Color,
}
/// Ableton Link status indicator colors.
#[derive(Clone)]
pub struct LinkStatusColors {
pub disabled: Color,
pub connected: Color,
pub listening: Color,
}
/// Syntax highlighting (fg, bg) pairs per token category.
#[derive(Clone)]
pub struct SyntaxColors {
pub gap_bg: Color,
pub executed_bg: Color,
pub selected_bg: Color,
pub emit: (Color, Color),
pub number: (Color, Color),
pub string: (Color, Color),
pub comment: (Color, Color),
pub keyword: (Color, Color),
pub stack_op: (Color, Color),
pub operator: (Color, Color),
pub sound: (Color, Color),
pub param: (Color, Color),
pub context: (Color, Color),
pub note: (Color, Color),
pub interval: (Color, Color),
pub variable: (Color, Color),
pub vary: (Color, Color),
pub generator: (Color, Color),
pub user_defined: (Color, Color),
pub default: (Color, Color),
}
/// Alternating table row colors.
#[derive(Clone)]
pub struct TableColors {
pub row_even: Color,
pub row_odd: Color,
}
/// Value display colors.
#[derive(Clone)]
pub struct ValuesColors {
pub tempo: Color,
pub value: Color,
}
/// Keyboard hint key/text colors.
#[derive(Clone)]
pub struct HintColors {
pub key: Color,
pub text: Color,
}
/// View badge pill colors.
#[derive(Clone)]
pub struct ViewBadgeColors {
pub bg: Color,
pub fg: Color,
}
/// Navigation minimap tile colors.
#[derive(Clone)]
pub struct NavColors {
pub selected_bg: Color,
pub selected_fg: Color,
pub unselected_bg: Color,
pub unselected_fg: Color,
}
/// Script editor colors.
#[derive(Clone)]
pub struct EditorWidgetColors {
pub cursor_bg: Color,
pub cursor_fg: Color,
pub selection_bg: Color,
pub completion_bg: Color,
pub completion_fg: Color,
pub completion_selected: Color,
pub completion_example: Color,
}
/// File and sample browser colors.
#[derive(Clone)]
pub struct BrowserColors {
pub directory: Color,
pub project_file: Color,
pub selected: Color,
pub file: Color,
pub focused_border: Color,
pub unfocused_border: Color,
pub root: Color,
pub file_icon: Color,
pub folder_icon: Color,
pub empty_text: Color,
}
/// Text input field colors.
#[derive(Clone)]
pub struct InputColors {
pub text: Color,
pub cursor: Color,
pub hint: Color,
}
/// Search bar and match highlight colors.
#[derive(Clone)]
pub struct SearchColors {
pub active: Color,
pub inactive: Color,
pub match_bg: Color,
pub match_fg: Color,
}
/// Markdown renderer colors.
#[derive(Clone)]
pub struct MarkdownColors {
pub h1: Color,
pub h2: Color,
pub h3: Color,
pub code: Color,
pub code_border: Color,
pub link: Color,
pub link_url: Color,
pub quote: Color,
pub text: Color,
pub list: Color,
}
/// Engine view panel colors.
#[derive(Clone)]
pub struct EngineColors {
pub header: Color,
pub header_focused: Color,
pub divider: Color,
pub scroll_indicator: Color,
pub label: Color,
pub label_focused: Color,
pub label_dim: Color,
pub value: Color,
pub focused: Color,
pub normal: Color,
pub dim: Color,
pub path: Color,
pub border_magenta: Color,
pub border_green: Color,
pub border_cyan: Color,
pub separator: Color,
pub hint_active: Color,
pub hint_inactive: Color,
}
/// Dictionary view colors.
#[derive(Clone)]
pub struct DictColors {
pub word_name: Color,
pub word_bg: Color,
pub alias: Color,
pub stack_sig: Color,
pub description: Color,
pub example: Color,
pub category_focused: Color,
pub category_selected: Color,
pub category_normal: Color,
pub category_dimmed: Color,
pub border_focused: Color,
pub border_normal: Color,
pub header_desc: Color,
}
/// Title screen colors.
#[derive(Clone)]
pub struct TitleColors {
pub big_title: Color,
pub author: Color,
pub link: Color,
pub license: Color,
pub prompt: Color,
pub subtitle: Color,
}
/// VU meter and spectrum level colors.
#[derive(Clone)]
pub struct MeterColors {
pub low: Color,
pub mid: Color,
pub high: Color,
pub low_rgb: (u8, u8, u8),
pub mid_rgb: (u8, u8, u8),
pub high_rgb: (u8, u8, u8),
}
/// Sparkle particle colors.
#[derive(Clone)]
pub struct SparkleColors {
pub colors: [(u8, u8, u8); 5],
}
/// Confirm dialog colors.
#[derive(Clone)]
pub struct ConfirmColors {
pub border: Color,
pub button_selected_bg: Color,
pub button_selected_fg: Color,
}

View File

@@ -0,0 +1,41 @@
//! Monochrome (black background) palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (0, 0, 0),
surface: (18, 18, 18),
surface2: (30, 30, 30),
fg: (255, 255, 255),
fg_dim: (180, 180, 180),
fg_muted: (120, 120, 120),
accent: (255, 255, 255),
red: (180, 180, 180),
green: (255, 255, 255),
yellow: (180, 180, 180),
blue: (180, 180, 180),
purple: (180, 180, 180),
cyan: (255, 255, 255),
orange: (255, 255, 255),
tempo_color: (255, 255, 255),
bank_color: (180, 180, 180),
pattern_color: (180, 180, 180),
title_accent: (255, 255, 255),
title_author: (180, 180, 180),
secondary: (120, 120, 120),
link_bright: [
(255, 255, 255), (200, 200, 200), (160, 160, 160),
(220, 220, 220), (180, 180, 180),
],
link_dim: [
(60, 60, 60), (50, 50, 50), (45, 45, 45),
(55, 55, 55), (48, 48, 48),
],
sparkle: [
(255, 255, 255), (200, 200, 200), (160, 160, 160),
(220, 220, 220), (180, 180, 180),
],
meter: [(120, 120, 120), (180, 180, 180), (255, 255, 255)],
}
}

View File

@@ -0,0 +1,41 @@
//! Monochrome (white background) palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (255, 255, 255),
surface: (240, 240, 240),
surface2: (225, 225, 225),
fg: (0, 0, 0),
fg_dim: (80, 80, 80),
fg_muted: (140, 140, 140),
accent: (0, 0, 0),
red: (140, 140, 140),
green: (0, 0, 0),
yellow: (80, 80, 80),
blue: (80, 80, 80),
purple: (80, 80, 80),
cyan: (0, 0, 0),
orange: (0, 0, 0),
tempo_color: (0, 0, 0),
bank_color: (80, 80, 80),
pattern_color: (80, 80, 80),
title_accent: (0, 0, 0),
title_author: (80, 80, 80),
secondary: (140, 140, 140),
link_bright: [
(0, 0, 0), (60, 60, 60), (100, 100, 100),
(40, 40, 40), (80, 80, 80),
],
link_dim: [
(200, 200, 200), (210, 210, 210), (215, 215, 215),
(205, 205, 205), (212, 212, 212),
],
sparkle: [
(0, 0, 0), (60, 60, 60), (100, 100, 100),
(40, 40, 40), (80, 80, 80),
],
meter: [(140, 140, 140), (80, 80, 80), (0, 0, 0)],
}
}

View File

@@ -0,0 +1,41 @@
//! Monokai palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (39, 40, 34),
surface: (53, 54, 47),
surface2: (70, 71, 62),
fg: (248, 248, 242),
fg_dim: (190, 190, 180),
fg_muted: (117, 113, 94),
accent: (249, 38, 114), // pink
red: (249, 38, 114),
green: (166, 226, 46),
yellow: (230, 219, 116),
blue: (102, 217, 239),
purple: (174, 129, 255),
cyan: (102, 217, 239),
orange: (253, 151, 31),
tempo_color: (249, 38, 114),
bank_color: (102, 217, 239),
pattern_color: (166, 226, 46),
title_accent: (249, 38, 114),
title_author: (102, 217, 239),
secondary: (253, 151, 31),
link_bright: [
(249, 38, 114), (174, 129, 255), (253, 151, 31),
(102, 217, 239), (166, 226, 46),
],
link_dim: [
(90, 40, 60), (70, 55, 90), (85, 60, 35),
(50, 75, 85), (60, 80, 40),
],
sparkle: [
(102, 217, 239), (253, 151, 31), (166, 226, 46),
(249, 38, 114), (174, 129, 255),
],
meter: [(155, 215, 45), (220, 210, 105), (240, 50, 110)],
}
}

View File

@@ -0,0 +1,41 @@
//! Nord palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (46, 52, 64),
surface: (59, 66, 82),
surface2: (67, 76, 94),
fg: (236, 239, 244),
fg_dim: (216, 222, 233),
fg_muted: (76, 86, 106),
accent: (136, 192, 208), // frost1
red: (191, 97, 106),
green: (163, 190, 140),
yellow: (235, 203, 139),
blue: (129, 161, 193), // frost2
purple: (180, 142, 173),
cyan: (143, 188, 187), // frost0
orange: (208, 135, 112),
tempo_color: (180, 142, 173),
bank_color: (129, 161, 193),
pattern_color: (143, 188, 187),
title_accent: (136, 192, 208),
title_author: (129, 161, 193),
secondary: (208, 135, 112),
link_bright: [
(136, 192, 208), (180, 142, 173), (208, 135, 112),
(143, 188, 187), (163, 190, 140),
],
link_dim: [
(55, 75, 85), (70, 60, 70), (75, 55, 50),
(55, 75, 75), (60, 75, 55),
],
sparkle: [
(136, 192, 208), (208, 135, 112), (163, 190, 140),
(180, 142, 173), (235, 203, 139),
],
meter: [(140, 180, 130), (220, 190, 120), (180, 90, 100)],
}
}

View File

@@ -0,0 +1,63 @@
//! Palette definition and color mixing utilities.
use ratatui::style::Color;
/// RGB color triple.
pub type Rgb = (u8, u8, u8);
/// Base color palette that themes are derived from.
pub struct Palette {
// Core
pub bg: Rgb,
pub surface: Rgb,
pub surface2: Rgb,
pub fg: Rgb,
pub fg_dim: Rgb,
pub fg_muted: Rgb,
// Semantic accents
pub accent: Rgb,
pub red: Rgb,
pub green: Rgb,
pub yellow: Rgb,
pub blue: Rgb,
pub purple: Rgb,
pub cyan: Rgb,
pub orange: Rgb,
// Role assignments
pub tempo_color: Rgb,
pub bank_color: Rgb,
pub pattern_color: Rgb,
pub title_accent: Rgb,
pub title_author: Rgb,
pub secondary: Rgb,
// Arrays
pub link_bright: [Rgb; 5],
pub link_dim: [Rgb; 5],
pub sparkle: [Rgb; 5],
pub meter: [Rgb; 3],
}
/// Convert an RGB triple to a ratatui [`Color`].
pub fn rgb(c: Rgb) -> Color {
Color::Rgb(c.0, c.1, c.2)
}
/// Blend `bg` toward `accent` by `amount` (0.01.0).
pub fn tint(bg: Rgb, accent: Rgb, amount: f32) -> Rgb {
let mix = |b: u8, a: u8| -> u8 {
let v = b as f32 + (a as f32 - b as f32) * amount;
v.clamp(0.0, 255.0) as u8
};
(mix(bg.0, accent.0), mix(bg.1, accent.1), mix(bg.2, accent.2))
}
/// Linearly interpolate between two colors.
pub fn mid(a: Rgb, b: Rgb, t: f32) -> Rgb {
tint(a, b, t)
}
/// Darken a color by reducing brightness.
pub fn darken(c: Rgb, amount: f32) -> Rgb {
let d = |v: u8| -> u8 { (v as f32 * (1.0 - amount)).clamp(0.0, 255.0) as u8 };
(d(c.0), d(c.1), d(c.2))
}

View File

@@ -0,0 +1,41 @@
//! Pitch Black palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (0, 0, 0),
surface: (10, 10, 10),
surface2: (21, 21, 21),
fg: (230, 230, 230),
fg_dim: (160, 160, 160),
fg_muted: (100, 100, 100),
accent: (80, 230, 230), // cyan
red: (255, 80, 80),
green: (80, 255, 120),
yellow: (255, 230, 80),
blue: (80, 180, 255),
purple: (200, 120, 255),
cyan: (80, 230, 230),
orange: (255, 160, 60),
tempo_color: (200, 120, 255),
bank_color: (80, 180, 255),
pattern_color: (80, 230, 230),
title_accent: (80, 230, 230),
title_author: (80, 180, 255),
secondary: (255, 160, 60),
link_bright: [
(80, 230, 230), (200, 120, 255), (255, 160, 60),
(80, 180, 255), (80, 255, 120),
],
link_dim: [
(25, 60, 60), (50, 35, 65), (60, 45, 20),
(25, 50, 70), (25, 65, 35),
],
sparkle: [
(80, 230, 230), (255, 160, 60), (80, 255, 120),
(200, 120, 255), (80, 180, 255),
],
meter: [(70, 240, 110), (245, 220, 75), (245, 75, 75)],
}
}

View File

@@ -0,0 +1,41 @@
//! Rose Pine palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (25, 23, 36),
surface: (33, 32, 46),
surface2: (42, 39, 63),
fg: (224, 222, 244),
fg_dim: (144, 140, 170),
fg_muted: (110, 106, 134),
accent: (235, 188, 186), // rose
red: (235, 111, 146), // love
green: (156, 207, 216), // foam
yellow: (246, 193, 119), // gold
blue: (49, 116, 143), // pine
purple: (196, 167, 231), // iris
cyan: (156, 207, 216), // foam
orange: (246, 193, 119), // gold
tempo_color: (196, 167, 231),
bank_color: (156, 207, 216),
pattern_color: (49, 116, 143),
title_accent: (235, 188, 186),
title_author: (156, 207, 216),
secondary: (235, 111, 146),
link_bright: [
(235, 111, 146), (196, 167, 231), (246, 193, 119),
(156, 207, 216), (49, 116, 143),
],
link_dim: [
(75, 45, 55), (60, 50, 75), (75, 60, 45),
(50, 65, 70), (30, 50, 55),
],
sparkle: [
(156, 207, 216), (246, 193, 119), (49, 116, 143),
(235, 111, 146), (196, 167, 231),
],
meter: [(156, 207, 216), (246, 193, 119), (235, 111, 146)],
}
}

View File

@@ -0,0 +1,41 @@
//! Tokyo Night palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (26, 27, 38),
surface: (36, 40, 59),
surface2: (52, 59, 88),
fg: (169, 177, 214),
fg_dim: (130, 140, 180),
fg_muted: (86, 95, 137),
accent: (187, 154, 247), // purple
red: (247, 118, 142),
green: (158, 206, 106),
yellow: (224, 175, 104),
blue: (122, 162, 247),
purple: (187, 154, 247),
cyan: (125, 207, 255),
orange: (224, 175, 104),
tempo_color: (187, 154, 247),
bank_color: (122, 162, 247),
pattern_color: (158, 206, 106),
title_accent: (187, 154, 247),
title_author: (122, 162, 247),
secondary: (224, 175, 104),
link_bright: [
(247, 118, 142), (187, 154, 247), (224, 175, 104),
(125, 207, 255), (158, 206, 106),
],
link_dim: [
(80, 45, 55), (65, 55, 85), (75, 60, 40),
(45, 70, 85), (55, 70, 45),
],
sparkle: [
(125, 207, 255), (224, 175, 104), (158, 206, 106),
(247, 118, 142), (187, 154, 247),
],
meter: [(158, 206, 106), (224, 175, 104), (247, 118, 142)],
}
}

View File

@@ -0,0 +1,99 @@
//! Hue rotation for palette-wide color transforms.
use super::palette::{Palette, Rgb};
use super::build::build;
use super::ThemeColors;
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
let s = if max == 0.0 { 0.0 } else { delta / max };
(h, s, max)
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
fn rotate(c: Rgb, degrees: f32) -> Rgb {
let (h, s, v) = rgb_to_hsv(c.0, c.1, c.2);
let new_h = (h + degrees) % 360.0;
let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h };
hsv_to_rgb(new_h, s, v)
}
fn rotate5(arr: [Rgb; 5], d: f32) -> [Rgb; 5] {
[rotate(arr[0], d), rotate(arr[1], d), rotate(arr[2], d), rotate(arr[3], d), rotate(arr[4], d)]
}
fn rotate3(arr: [Rgb; 3], d: f32) -> [Rgb; 3] {
[rotate(arr[0], d), rotate(arr[1], d), rotate(arr[2], d)]
}
/// Build a [`ThemeColors`] with all palette hues rotated by `degrees`.
pub fn rotate_palette(palette: &Palette, degrees: f32) -> ThemeColors {
if degrees == 0.0 {
return build(palette);
}
let d = degrees;
build(&Palette {
bg: rotate(palette.bg, d),
surface: rotate(palette.surface, d),
surface2: rotate(palette.surface2, d),
fg: rotate(palette.fg, d),
fg_dim: rotate(palette.fg_dim, d),
fg_muted: rotate(palette.fg_muted, d),
accent: rotate(palette.accent, d),
red: rotate(palette.red, d),
green: rotate(palette.green, d),
yellow: rotate(palette.yellow, d),
blue: rotate(palette.blue, d),
purple: rotate(palette.purple, d),
cyan: rotate(palette.cyan, d),
orange: rotate(palette.orange, d),
tempo_color: rotate(palette.tempo_color, d),
bank_color: rotate(palette.bank_color, d),
pattern_color: rotate(palette.pattern_color, d),
title_accent: rotate(palette.title_accent, d),
title_author: rotate(palette.title_author, d),
secondary: rotate(palette.secondary, d),
link_bright: rotate5(palette.link_bright, d),
link_dim: rotate5(palette.link_dim, d),
sparkle: rotate5(palette.sparkle, d),
meter: rotate3(palette.meter, d),
})
}

View File

@@ -0,0 +1,41 @@
//! Tropicalia palette.
use super::palette::Palette;
pub fn palette() -> Palette {
Palette {
bg: (20, 26, 22),
surface: (30, 40, 34),
surface2: (44, 56, 48),
fg: (235, 225, 200),
fg_dim: (155, 145, 120),
fg_muted: (85, 80, 62),
accent: (230, 50, 120),
red: (240, 70, 70),
green: (80, 200, 50),
yellow: (255, 195, 0),
blue: (0, 160, 200),
purple: (180, 60, 180),
cyan: (0, 200, 170),
orange: (255, 140, 30),
tempo_color: (230, 50, 120),
bank_color: (0, 160, 200),
pattern_color: (0, 200, 170),
title_accent: (230, 50, 120),
title_author: (0, 160, 200),
secondary: (255, 140, 30),
link_bright: [
(230, 50, 120), (0, 160, 200), (255, 140, 30),
(0, 200, 170), (80, 200, 50),
],
link_dim: [
(72, 20, 40), (6, 50, 64), (80, 44, 12),
(6, 62, 54), (26, 62, 18),
],
sparkle: [
(230, 50, 120), (255, 195, 0), (80, 200, 50),
(0, 160, 200), (180, 60, 180),
],
meter: [(70, 182, 44), (236, 178, 0), (220, 62, 62)],
}
}

View File

@@ -1,3 +1,6 @@
//! Stereo VU meter with dB-scaled level display.
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -7,6 +10,7 @@ const DB_MIN: f32 = -48.0;
const DB_MAX: f32 = 3.0;
const DB_RANGE: f32 = DB_MAX - DB_MIN;
/// Stereo VU meter displaying left/right levels in dB.
pub struct VuMeter {
left: f32,
right: f32,
@@ -29,13 +33,13 @@ impl VuMeter {
(db - DB_MIN) / DB_RANGE
}
fn row_to_color(row_position: f32) -> Color {
fn row_to_color(row_position: f32, colors: &theme::ThemeColors) -> Color {
if row_position > 0.9 {
Color::Red
colors.meter.high
} else if row_position > 0.75 {
Color::Yellow
colors.meter.mid
} else {
Color::Green
colors.meter.low
}
}
}
@@ -46,6 +50,7 @@ impl Widget for VuMeter {
return;
}
let colors = theme::get();
let height = area.height as usize;
let half_width = area.width / 2;
let gap = 1u16;
@@ -61,7 +66,7 @@ impl Widget for VuMeter {
for row in 0..height {
let y = area.y + area.height - 1 - row as u16;
let row_position = (row as f32 + 0.5) / height as f32;
let color = Self::row_to_color(row_position);
let color = Self::row_to_color(row_position, &colors);
for col in 0..half_width.saturating_sub(gap) {
let x = area.x + col;

View File

@@ -0,0 +1,194 @@
//! Filled waveform display using braille characters.
use crate::scope::Orientation;
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
/// Filled waveform renderer using braille dot plotting.
pub struct Waveform<'a> {
data: &'a [f32],
orientation: Orientation,
color: Option<Color>,
gain: f32,
}
impl<'a> Waveform<'a> {
pub fn new(data: &'a [f32]) -> Self {
Self {
data,
orientation: Orientation::Horizontal,
color: None,
gain: 1.0,
}
}
pub fn orientation(mut self, o: Orientation) -> Self {
self.orientation = o;
self
}
pub fn color(mut self, c: Color) -> Self {
self.color = Some(c);
self
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
}
impl Widget for Waveform<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.data.is_empty() {
return;
}
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
match self.orientation {
Orientation::Horizontal => {
render_horizontal(self.data, area, buf, color, self.gain)
}
Orientation::Vertical => render_vertical(self.data, area, buf, color, self.gain),
}
}
}
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
}
}
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
for fine_x in 0..fine_width {
let start = fine_x * len / fine_width;
let end = ((fine_x + 1) * len / fine_width).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fy_top = ((1.0 - max_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_bot = ((1.0 - min_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_top = fy_top.min(fine_height - 1);
let fy_bot = fy_bot.min(fine_height - 1);
let char_x = fine_x / 2;
let dot_x = fine_x % 2;
for fy in fy_top..=fy_bot {
let char_y = fy / 4;
let dot_y = fy % 4;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
for fine_y in 0..fine_height {
let start = fine_y * len / fine_height;
let end = ((fine_y + 1) * len / fine_height).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fx_left = ((min_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_right = ((max_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_left = fx_left.min(fine_width - 1);
let fx_right = fx_right.min(fine_width - 1);
let char_y = fine_y / 4;
let dot_y = fine_y % 4;
for fx in fx_left..=fx_right {
let char_x = fx / 2;
let dot_x = fx % 2;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}

8398
demos/01.cagire Normal file

File diff suppressed because it is too large Load Diff

8375
demos/02.cagire Normal file

File diff suppressed because it is too large Load Diff

8370
demos/03.cagire Normal file

File diff suppressed because it is too large Load Diff

1
demos/04.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/05.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/06.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

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