diff --git a/.dart_tool/package_config.json b/.dart_tool/package_config.json new file mode 100644 index 0000000..f76fd69 --- /dev/null +++ b/.dart_tool/package_config.json @@ -0,0 +1,298 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "_fe_analyzer_shared", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-93.0.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "analyzer", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/analyzer-10.0.1", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "args", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/args-2.7.0", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "async", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/async-2.13.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "cli_config", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/cli_config-0.2.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "collection", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "convert", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/convert-3.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "coverage", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/coverage-1.15.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "crypto", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/crypto-3.0.7", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "file", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/file-7.0.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "frontend_server_client", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/frontend_server_client-4.0.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "glob", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/glob-2.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "http_multi_server", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "http_parser", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "io", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/io-1.0.5", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "logging", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/logging-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "matcher", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/matcher-0.12.18", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "meta", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/meta-1.18.1", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "mime", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/mime-2.0.0", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "node_preamble", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/node_preamble-2.0.2", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "package_config", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/package_config-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pool", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/pool-1.5.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pub_semver", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/pub_semver-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shelf", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/shelf-1.4.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "shelf_packages_handler", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/shelf_packages_handler-3.0.2", + "packageUri": "lib/", + "languageVersion": "2.17" + }, + { + "name": "shelf_static", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/shelf_static-1.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "shelf_web_socket", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/shelf_web_socket-3.0.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "source_map_stack_trace", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/source_map_stack_trace-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "source_maps", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/source_maps-0.10.13", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "source_span", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/source_span-1.10.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "string_scanner", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/test-1.29.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "test_api", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/test_api-0.7.9", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "test_core", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/test_core-0.6.15", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "typed_data", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vm_service", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/vm_service-15.0.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "watcher", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/watcher-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/web_socket-1.0.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "web_socket_channel", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/web_socket_channel-3.0.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "webkit_inspection_protocol", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/webkit_inspection_protocol-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "yaml", + "rootUri": "file:///Users/anowak/.pub-cache/hosted/pub.dev/yaml-3.1.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "tui", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.6" + } + ], + "generator": "pub", + "generatorVersion": "3.10.4", + "flutterRoot": "file:///Users/anowak/fvm/versions/3.38.5", + "flutterVersion": "3.38.5", + "pubCache": "file:///Users/anowak/.pub-cache" +} diff --git a/.dart_tool/package_graph.json b/.dart_tool/package_graph.json new file mode 100644 index 0000000..616c810 --- /dev/null +++ b/.dart_tool/package_graph.json @@ -0,0 +1,444 @@ +{ + "roots": [ + "tui" + ], + "packages": [ + { + "name": "tui", + "version": "0.0.1", + "dependencies": [ + "path" + ], + "devDependencies": [ + "test" + ] + }, + { + "name": "test", + "version": "1.29.0", + "dependencies": [ + "analyzer", + "async", + "boolean_selector", + "collection", + "coverage", + "http_multi_server", + "io", + "matcher", + "node_preamble", + "package_config", + "path", + "pool", + "shelf", + "shelf_packages_handler", + "shelf_static", + "shelf_web_socket", + "source_span", + "stack_trace", + "stream_channel", + "test_api", + "test_core", + "typed_data", + "web_socket_channel", + "webkit_inspection_protocol", + "yaml" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "yaml", + "version": "3.1.3", + "dependencies": [ + "collection", + "source_span", + "string_scanner" + ] + }, + { + "name": "webkit_inspection_protocol", + "version": "1.2.1", + "dependencies": [ + "logging" + ] + }, + { + "name": "web_socket_channel", + "version": "3.0.3", + "dependencies": [ + "async", + "crypto", + "stream_channel", + "web", + "web_socket" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "test_core", + "version": "0.6.15", + "dependencies": [ + "analyzer", + "args", + "async", + "boolean_selector", + "collection", + "coverage", + "frontend_server_client", + "glob", + "io", + "meta", + "package_config", + "path", + "pool", + "source_map_stack_trace", + "source_maps", + "source_span", + "stack_trace", + "stream_channel", + "test_api", + "vm_service", + "yaml" + ] + }, + { + "name": "test_api", + "version": "0.7.9", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "source_span", + "version": "1.10.1", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "shelf_web_socket", + "version": "3.0.0", + "dependencies": [ + "shelf", + "stream_channel", + "web_socket_channel" + ] + }, + { + "name": "shelf_static", + "version": "1.1.3", + "dependencies": [ + "convert", + "http_parser", + "mime", + "path", + "shelf" + ] + }, + { + "name": "shelf_packages_handler", + "version": "3.0.2", + "dependencies": [ + "path", + "shelf", + "shelf_static" + ] + }, + { + "name": "shelf", + "version": "1.4.2", + "dependencies": [ + "async", + "collection", + "http_parser", + "path", + "stack_trace", + "stream_channel" + ] + }, + { + "name": "pool", + "version": "1.5.2", + "dependencies": [ + "async", + "stack_trace" + ] + }, + { + "name": "package_config", + "version": "2.2.0", + "dependencies": [ + "path" + ] + }, + { + "name": "node_preamble", + "version": "2.0.2", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.18", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "io", + "version": "1.0.5", + "dependencies": [ + "meta", + "path", + "string_scanner" + ] + }, + { + "name": "http_multi_server", + "version": "3.2.2", + "dependencies": [ + "async" + ] + }, + { + "name": "coverage", + "version": "1.15.0", + "dependencies": [ + "args", + "cli_config", + "glob", + "logging", + "meta", + "package_config", + "path", + "source_maps", + "stack_trace", + "vm_service", + "yaml" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "async", + "version": "2.13.0", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "analyzer", + "version": "10.0.1", + "dependencies": [ + "_fe_analyzer_shared", + "collection", + "convert", + "crypto", + "glob", + "meta", + "package_config", + "path", + "pub_semver", + "source_span", + "watcher", + "yaml" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "logging", + "version": "1.3.0", + "dependencies": [] + }, + { + "name": "web_socket", + "version": "1.0.1", + "dependencies": [ + "web" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "crypto", + "version": "3.0.7", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "vm_service", + "version": "15.0.2", + "dependencies": [] + }, + { + "name": "source_maps", + "version": "0.10.13", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_map_stack_trace", + "version": "2.1.2", + "dependencies": [ + "path", + "source_maps", + "stack_trace" + ] + }, + { + "name": "meta", + "version": "1.18.1", + "dependencies": [] + }, + { + "name": "glob", + "version": "2.1.3", + "dependencies": [ + "async", + "collection", + "file", + "path", + "string_scanner" + ] + }, + { + "name": "frontend_server_client", + "version": "4.0.0", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "args", + "version": "2.7.0", + "dependencies": [] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "mime", + "version": "2.0.0", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "cli_config", + "version": "0.2.0", + "dependencies": [ + "args", + "yaml" + ] + }, + { + "name": "watcher", + "version": "1.2.1", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "pub_semver", + "version": "2.2.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "_fe_analyzer_shared", + "version": "93.0.0", + "dependencies": [ + "meta", + "source_span" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/.dart_tool/pub/bin/test/test.dart-3.10.4.snapshot b/.dart_tool/pub/bin/test/test.dart-3.10.4.snapshot new file mode 100644 index 0000000..4e37719 Binary files /dev/null and b/.dart_tool/pub/bin/test/test.dart-3.10.4.snapshot differ diff --git a/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjY= b/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjY= new file mode 100644 index 0000000..f9b862d Binary files /dev/null and b/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjY= differ diff --git a/README.md b/README.md index cd324a7..c86e480 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,287 @@ -# Text-based User Interfaces +# tui -TUI is a framework for building text-based user interfaces. \ No newline at end of file +A Dart library for building terminal user interfaces. + +## What's in here + +- **Widgets**: Input, TextArea, Select, Confirm, Table, Spinner, ProgressBar +- **Layout**: SplitView, Frame, Box +- **BigText**: ASCII art banners with gradient support +- **Animations**: Typewriter, glitch, matrix rain, shimmer, and more +- **Audio**: Programmatic synthesis, melody DSL, SFX presets — no sound files needed +- **Focus management**: Tab between focusable widgets +- **60 FPS render loop** with optional FPS meter + +## Quick start + +```dart +import 'package:tui/tui.dart'; + +class MyApp extends Window { + MyApp() { + var input = Input(placeholder: 'Your name') + ..onSubmit = (name) { + print('Hello, $name!'); + stop(); + }; + + children = [ + Frame(title: 'Welcome')..children = [input] + ]; + } +} + +void main() => MyApp().start(); +``` + +## Widgets + +### Input + +```dart +var input = Input(placeholder: 'Type here...') + ..prompt = '> ' + ..onSubmit = (value) => print(value); +``` + +### Select + +```dart +var select = Select( + options: ['Apple', 'Banana', 'Cherry'], + header: 'Pick a fruit:', + onSelect: (fruit) => print(fruit), +); + +// Multi-select +var multi = Select( + options: ['Red', 'Green', 'Blue'], + multiSelect: true, + onSelectMultiple: (colors) => print(colors), +); +``` + +### Table + +```dart +var table = Table>( + columns: [ + TableColumn('Name', width: 15), + TableColumn('Status', width: 10), + ], + rows: [ + ['Alice', 'Online'], + ['Bob', 'Away'], + ], + rowBuilder: (row) => row, + onSelect: (row) => print(row), +); +``` + +### Spinner + +```dart +var spinner = Spinner( + label: 'Loading...', + style: SpinnerStyle.dots, // dots, circle, line, arrow, bounce, moon, etc. +)..start(); + +// Later: +spinner.stop(); +``` + +### ProgressBar + +```dart +var bar = ProgressBar() + ..label = 'Download' + ..value = 0.75; // 0.0 to 1.0 +``` + +## Layout + +### SplitView + +```dart +// Horizontal split (side by side) +SplitView(horizontal: true) + ..children = [leftPanel, rightPanel]; + +// With custom ratios +SplitView(horizontal: true, ratios: [2, 1]) + ..children = [mainPanel, sidebar]; + +// Vertical split +SplitView(horizontal: false) + ..children = [topPanel, bottomPanel]; +``` + +### Frame + +```dart +Frame(title: 'My Panel', color: '36', focusColor: '33') + ..children = [someWidget]; +``` + +The frame uses light borders normally, double borders when focused. + +## BigText + +ASCII art text with optional gradients. + +```dart +var banner = BigText('HELLO', font: BigTextFont.shadow) + ..gradient = Gradients.rainbow; + +children = [banner]; +``` + +Fonts: `block`, `slim`, `chunky`, `shadow` + +Gradients: `rainbow`, `sunset`, `ocean`, `fire`, `forest`, `purple`, `cyan`, `gemini`, `matrix` + +## Animations + +```dart +var controller = AnimationController()..fps = 60; + +// Typewriter +controller.add(TypewriterAnimation( + text: 'Hello world', + duration: Duration(seconds: 2), + onUpdate: (visible) => currentText = visible, +)); + +// Glitch +controller.add(GlitchAnimation( + text: 'ERROR', + duration: Duration(milliseconds: 1500), + intensity: 0.5, + onUpdate: (glitched) => currentText = glitched, +)); + +// Looping pulse +controller.add(ValueAnimation( + from: 0, + to: 1, + duration: Duration(milliseconds: 500), + repeatMode: RepeatMode.pingPong, + onUpdate: (value) => opacity = value, +)); +``` + +Repeat modes: `once`, `reverse`, `loop`, `pingPong` + +## FPS meter + +```dart +class MyApp extends Window { + MyApp() { + showFps = true; // Shows FPS in top-right corner + } +} +``` + +## Audio + +Programmatic audio synthesis — generate sounds from code, no files needed. + +```dart +final audio = Audio(); + +// Quick beep +audio.beep(); + +// SFX presets +SFX.success.playOn(audio.channel('sfx')); +SFX.coin.playOn(audio.channel('sfx')); +``` + +### Melody builder + +```dart +final melody = Melody(bpm: 120, waveform: Waveform.square) + ..note(Note.C4, Dur.quarter) + ..note(Note.E4, Dur.quarter) + ..note(Note.G4, Dur.half) + ..rest(Dur.quarter) + ..note(Note.C5, Dur.whole); + +audio.channel('music').playMelody(melody); +``` + +### Melody DSL + +Compact string notation for quick melodies: + +```dart +audio.channel('music').play( + 'E4.q E4.q F4.q G4.q | G4.q F4.q E4.q D4.q | C4.q C4.q D4.q E4.q | E4.dq D4.e D4.h', + bpm: 120, +); +``` + +Note format: `{Name}{Octave}.{Duration}` — e.g. `C4.q`, `Fs5.e`, `R.h` (rest) + +Durations: `w` whole, `h` half, `q` quarter, `e` eighth, `s` sixteenth, `t` triplet, `d` prefix for dotted (`dh`, `dq`, `de`) + +### Waveforms and envelopes + +```dart +// 5 waveform types +audio.channel('sfx').tone( + frequency: Note.A4, + waveform: Waveform.sawtooth, // sine, square, triangle, sawtooth, noise + duration: 0.5, +); + +// Custom ADSR envelope +final staccato = Envelope(attack: 0.005, decay: 0.1, sustain: 0.0, release: 0.01); +audio.channel('sfx').tone(frequency: Note.C5, envelope: staccato); +``` + +### Channels + +Named channels — each plays one sound at a time (new replaces old): + +```dart +audio.channel('sfx'); // sound effects +audio.channel('music'); // background melody +audio.channel('ui'); // UI feedback +audio.muted = true; // mute everything +``` + +### SFX presets + +`beep`, `click`, `success`, `error`, `powerUp`, `explosion`, `coin`, `jump` + +### Low-level synthesis + +```dart +// Generate raw samples +final samples = Synthesizer.tone(frequency: 440.0, duration: 0.5, waveform: Waveform.sine); + +// Mix multiple buffers into a chord +final chord = Synthesizer.mix([ + Synthesizer.tone(frequency: Note.C4, duration: 1.0, volume: 0.5), + Synthesizer.tone(frequency: Note.E4, duration: 1.0, volume: 0.5), + Synthesizer.tone(frequency: Note.G4, duration: 1.0, volume: 0.5), +]); + +// Encode to WAV +final wav = WavWriter.encode(chord); +``` + +## Running the demos + +```bash +dart run example/widgets_demo.dart +dart run example/animation_demo.dart +dart run example/split_view.dart +dart run example/bigtext_demo.dart +dart run example/audio_demo.dart +``` + +## License + +Apache 2.0 diff --git a/TODO b/TODO deleted file mode 100644 index e6a3a3b..0000000 --- a/TODO +++ /dev/null @@ -1,19 +0,0 @@ -1. bouncing boxes example - -2. file reader - -3. top - -4. file browser (incorporates file read) - -5. Implement paint program, saves as dart file that can then render in view - Press on keyboard to select key to paint - Left click to select font color - Right click font background - -6. postgresql admin tool, possibly make it a separate project - -7. laser!!! - -make some videos showing the demos: -http://askubuntu.com/questions/107726/how-to-create-animated-gif-images-of-a-screencast \ No newline at end of file diff --git a/docs/superpowers/plans/2026-03-27-focus-clipping.md b/docs/superpowers/plans/2026-03-27-focus-clipping.md new file mode 100644 index 0000000..100a58f --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-focus-clipping.md @@ -0,0 +1,596 @@ +# Focus System + Canvas Clipping — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix canvas clipping so content can't bleed outside widget bounds, and add automatic focus propagation to ancestor Frames. + +**Architecture:** Canvas gets clip bounds (absolute screen coordinates) that are intersected when creating child canvases. View gets a parent reference set during resize(). FocusManager propagates focused state to ancestor Frames when focus changes. + +**Tech Stack:** Dart, `package:test` + +--- + +### Task 1: Canvas Clipping — Tests + +**Files:** +- Modify: `test/canvas_test.dart` + +- [ ] **Step 1: Add clipping test group to canvas_test.dart** + +Add these tests at the end of the `'Canvas'` group, before the closing `});`: + +```dart +group('clipping', () { + test('rejects write at negative local x', () { + var child = screen.canvas(Size(10, 5), Position(5, 5)); + child.write(-1, 0, 'X'); + expect(screen.stringAt(4, 5), equals('')); + }); + + test('rejects write at negative local y', () { + var child = screen.canvas(Size(10, 5), Position(5, 5)); + child.write(0, -1, 'X'); + expect(screen.stringAt(5, 4), equals('')); + }); + + test('rejects write at x >= width', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + child.write(5, 0, 'X'); + expect(screen.stringAt(5, 0), equals('')); + }); + + test('rejects write at y >= height', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + child.write(0, 5, 'X'); + expect(screen.stringAt(0, 5), equals('')); + }); + + test('allows write within local bounds', () { + var child = screen.canvas(Size(5, 5), Position(2, 2)); + child.write(4, 4, 'V'); + expect(screen.stringAt(6, 6), equals('V')); + }); + + test('child canvas clips to parent bounds', () { + // Parent occupies screen columns 2-11 (width 10 at offset 2) + var parent = screen.canvas(Size(10, 5), Position(2, 0)); + // Child thinks it has width 8 starting at offset 5 within parent + // Absolute: 2+5=7, so child occupies screen columns 7-14 + // But parent ends at column 11, so writes at x>=5 should be clipped + var child = parent.canvas(Size(8, 3), Position(5, 0)); + child.write(0, 0, 'A'); // abs 7 — within parent, OK + child.write(4, 0, 'B'); // abs 11 — within parent (< clipRight 12), OK + child.write(5, 0, 'C'); // abs 12 — clipped (>= clipRight 12) + + expect(screen.stringAt(7, 0), equals('A')); + expect(screen.stringAt(11, 0), equals('B')); + expect(screen.stringAt(12, 0), equals('')); + }); + + test('nested canvases intersect clip bounds', () { + // Screen is 20x10 + // Level 1: width 15 at offset 0 → clipRight=15 + var level1 = screen.canvas(Size(15, 8), Position(0, 0)); + // Level 2: width 12 at offset 5 within level1 → abs 5, clipRight=min(17,15)=15 + var level2 = level1.canvas(Size(12, 6), Position(5, 0)); + // Level 3: width 10 at offset 3 within level2 → abs 8, clipRight=min(18,15)=15 + var level3 = level2.canvas(Size(10, 4), Position(3, 0)); + + level3.write(0, 0, 'X'); // abs 8 — OK + level3.write(6, 0, 'Y'); // abs 14 — OK (< 15) + level3.write(7, 0, 'Z'); // abs 15 — clipped (>= 15) + + expect(screen.stringAt(8, 0), equals('X')); + expect(screen.stringAt(14, 0), equals('Y')); + expect(screen.stringAt(15, 0), equals('')); + }); + + test('occluded returns true for out-of-bounds local coords', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + expect(child.occluded(-1, 0), isTrue); + expect(child.occluded(0, -1), isTrue); + expect(child.occluded(5, 0), isTrue); + expect(child.occluded(0, 5), isTrue); + }); + + test('occluded respects clip bounds', () { + var parent = screen.canvas(Size(10, 5), Position(0, 0)); + var child = parent.canvas(Size(15, 3), Position(5, 0)); + // child at abs 5, clipRight=min(20,10)=10 + // Local x=5 → abs 10, which is >= clipRight + expect(child.occluded(5, 0), isTrue); + // Local x=4 → abs 9, which is < clipRight + expect(child.occluded(4, 0), isFalse); + }); + + test('stringAt returns empty for out-of-bounds local coords', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + expect(child.stringAt(-1, 0), equals('')); + expect(child.stringAt(5, 0), equals('')); + expect(child.stringAt(0, -1), equals('')); + expect(child.stringAt(0, 5), equals('')); + }); +}); +``` + +- [ ] **Step 2: Update existing test that relies on negative local coords** + +The test `'negative coordinates with positive offset still work'` in the `'bounds safety with offset'` group writes at (-2, -2) and expects it to work. With clipping, negative local coords are correctly rejected. Update this test: + +Replace the entire `'bounds safety with offset'` group: + +```dart +group('bounds safety with offset', () { + test('negative local coordinates are clipped', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(5, 5)); + offsetCanvas.write(-2, -2, 'V'); + // Negative local coords should be rejected + expect(screen.stringAt(3, 3), equals('')); + }); + + test('out of screen bounds is handled gracefully', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(18, 8)); + // Writing at 5,5 would be 23,13 - out of bounds, should be ignored + offsetCanvas.write(5, 5, 'X'); + // Should not throw + expect(screen.occluded(19, 9), isFalse); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `dart test test/canvas_test.dart -v` + +Expected: New clipping tests fail (Canvas doesn't clip yet). The updated negative-coords test should actually pass already since Screen.write rejects out-of-screen-bounds. + +--- + +### Task 2: Canvas Clipping — Implementation + +**Files:** +- Modify: `lib/src/canvas.dart` + +- [ ] **Step 1: Implement clipping in Canvas** + +Replace the entire content of `lib/src/canvas.dart` with: + +```dart +part of tui; + +/// A Canvas provides constrained read/write access to the [Screen]. +/// +/// All writes are clipped to the canvas bounds AND the inherited +/// clip region from the parent canvas. +class Canvas with Sizable, Positionable { + final Screen _scr; + int _clipRight; + int _clipBottom; + + Canvas(Size size, Position offset, this._scr, [int? clipRight, int? clipBottom]) { + this.size = size; + this.position = offset; + _clipRight = clipRight ?? (offset.x + size.width); + _clipBottom = clipBottom ?? (offset.y + size.height); + } + + Canvas canvas(Size size, Position offset) { + var combinedOffset = Position(offset.x + x, offset.y + y); + return Canvas( + size, + combinedOffset, + _scr, + min(combinedOffset.x + size.width, _clipRight), + min(combinedOffset.y + size.height, _clipBottom), + ); + } + + bool occluded(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return true; + var absX = position.x + x; + var absY = position.y + y; + if (absX >= _clipRight || absY >= _clipBottom) return true; + return _scr.occluded(absX, absY); + } + + void write(int x, int y, String char) { + if (x < 0 || y < 0 || x >= width || y >= height) return; + var absX = position.x + x; + var absY = position.y + y; + if (absX >= _clipRight || absY >= _clipBottom) return; + _scr.write(absX, absY, char); + } + + String stringAt(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return ''; + return _scr.stringAt(position.x + x, position.y + y); + } +} +``` + +- [ ] **Step 2: Run canvas tests to verify they pass** + +Run: `dart test test/canvas_test.dart -v` + +Expected: All tests pass, including new clipping tests. + +- [ ] **Step 3: Run all tests to verify nothing broke** + +Run: `dart test` + +Expected: All 68 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add lib/src/canvas.dart test/canvas_test.dart +git commit -m "add canvas clipping" +``` + +--- + +### Task 3: Parent Reference — Tests + +**Files:** +- Modify: `test/view_test.dart` + +- [ ] **Step 1: Add parent reference tests to view_test.dart** + +Add this group inside the `'View'` group, after the `'children'` group: + +```dart +group('parent reference', () { + test('parent is null by default', () { + var view = TestView(); + expect(view.parent, isNull); + }); + + test('resize sets parent on children', () { + var parent = TestView(); + var child1 = TestView(); + var child2 = TestView(); + parent.children = [child1, child2]; + + parent.resize(Size(80, 40), Position(0, 0)); + + expect(child1.parent, same(parent)); + expect(child2.parent, same(parent)); + }); + + test('resize updates parent when children change', () { + var parent1 = TestView(); + var parent2 = TestView(); + var child = TestView(); + + parent1.children = [child]; + parent1.resize(Size(80, 40), Position(0, 0)); + expect(child.parent, same(parent1)); + + parent2.children = [child]; + parent2.resize(Size(80, 40), Position(0, 0)); + expect(child.parent, same(parent2)); + }); + + test('nested resize sets parent chain', () { + var grandparent = TestView(); + var parent = TestView(); + var child = TestView(); + + parent.children = [child]; + grandparent.children = [parent]; + + grandparent.resize(Size(80, 40), Position(0, 0)); + + expect(parent.parent, same(grandparent)); + expect(child.parent, same(parent)); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dart test test/view_test.dart -v --name "parent reference"` + +Expected: Fail — `View` has no `parent` field yet. + +--- + +### Task 4: Parent Reference — Implementation + +**Files:** +- Modify: `lib/src/view.dart` + +- [ ] **Step 1: Add parent field and set it in resize** + +In `lib/src/view.dart`, add `View? parent;` field after line 8 (`List children = [];`) and update `resize()` to set parent on children: + +Add after `List children = [];`: + +```dart +View? parent; +``` + +Replace the `resize` method: + +```dart +void resize(Size size, Position position) { + this.size = size; + this.position = position; + for (var child in children) { + child.parent = this; + } + resizeChildren(); + update(); +} +``` + +- [ ] **Step 2: Run view tests to verify they pass** + +Run: `dart test test/view_test.dart -v` + +Expected: All tests pass including new parent reference tests. + +- [ ] **Step 3: Run all tests to verify nothing broke** + +Run: `dart test` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add lib/src/view.dart test/view_test.dart +git commit -m "add parent reference to View" +``` + +--- + +### Task 5: Focus Propagation — Tests + +**Files:** +- Modify: `test/focus_test.dart` + +- [ ] **Step 1: Add focus propagation tests to focus_test.dart** + +Add this group inside `main()`, after the `'FocusManager mixin'` group: + +```dart +group('Focus propagation to Frame', () { + late NonFocusableView root; + late TestFocusContainer manager; + + test('Frame.focused is true when child inside it is focused', () { + root = NonFocusableView(); + var frame = Frame(); + var child = FocusableView(); + frame.children = [child]; + root.children = [frame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + + expect(child.focused, isTrue); + expect(frame.focused, isTrue); + }); + + test('Frame.focused is false when focus moves outside', () { + root = NonFocusableView(); + var frame = Frame(); + var child = FocusableView(); + var outside = FocusableView(); + frame.children = [child]; + root.children = [frame, outside]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + expect(frame.focused, isTrue); + + manager.focus(outside); + expect(frame.focused, isFalse); + }); + + test('Frame stays focused when focus moves between its children', () { + root = NonFocusableView(); + var frame = Frame(); + var child1 = FocusableView(); + var child2 = FocusableView(); + frame.children = [child1, child2]; + root.children = [frame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child1); + expect(frame.focused, isTrue); + + manager.focus(child2); + expect(frame.focused, isTrue); + }); + + test('nested Frames: both ancestor Frames get focused', () { + root = NonFocusableView(); + var outerFrame = Frame(); + var innerFrame = Frame(); + var child = FocusableView(); + innerFrame.children = [child]; + outerFrame.children = [innerFrame]; + root.children = [outerFrame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + + expect(innerFrame.focused, isTrue); + expect(outerFrame.focused, isTrue); + }); + + test('nested Frames: inner unfocuses when focus moves to outer-only child', () { + root = NonFocusableView(); + var outerFrame = Frame(); + var innerFrame = Frame(); + var innerChild = FocusableView(); + var outerChild = FocusableView(); + innerFrame.children = [innerChild]; + outerFrame.children = [innerFrame, outerChild]; + root.children = [outerFrame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(innerChild); + expect(innerFrame.focused, isTrue); + expect(outerFrame.focused, isTrue); + + manager.focus(outerChild); + expect(innerFrame.focused, isFalse); + expect(outerFrame.focused, isTrue); + }); + + test('focusNext propagates focus to Frames correctly', () { + root = NonFocusableView(); + var frame = Frame(); + var child1 = FocusableView(); + var child2 = FocusableView(); + var outside = FocusableView(); + frame.children = [child1, child2]; + root.children = [frame, outside]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focusFirst(); // child1 + expect(frame.focused, isTrue); + + manager.focusNext(); // child2 + expect(frame.focused, isTrue); + + manager.focusNext(); // outside + expect(frame.focused, isFalse); + + manager.focusNext(); // back to child1 + expect(frame.focused, isTrue); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dart test test/focus_test.dart -v --name "Focus propagation"` + +Expected: Fail — `FocusManager.focus()` doesn't propagate to Frames yet. + +--- + +### Task 6: Focus Propagation — Implementation + +**Files:** +- Modify: `lib/src/focus.dart` + +- [ ] **Step 1: Update FocusManager with focus propagation** + +Replace the entire content of `lib/src/focus.dart` with: + +```dart +part of tui; + +/// Mixin that provides focus management for a container of Views. +mixin FocusManager { + View? _focused; + View? get focusedView => _focused; + + View get focusRoot; + + void focusFirst() { + var views = focusRoot.focusableViews; + if (views.isNotEmpty) { + focus(views.first); + } + } + + void focus(View view) { + if (!view.focusable) return; + if (_focused == view) return; + + var oldFrames = _ancestorFrames(_focused); + + _focused?.focused = false; + _focused?.onBlur(); + + _focused = view; + view.focused = true; + view.onFocus(); + + var newFrames = _ancestorFrames(view); + + for (var frame in oldFrames) { + if (!newFrames.contains(frame)) frame.focused = false; + } + for (var frame in newFrames) { + if (!oldFrames.contains(frame)) frame.focused = true; + } + } + + void focusNext() { + var views = focusRoot.focusableViews; + if (views.isEmpty) return; + + if (_focused == null) { + focus(views.first); + return; + } + + var idx = views.indexOf(_focused!); + var nextIdx = (idx + 1) % views.length; + focus(views[nextIdx]); + } + + void focusPrev() { + var views = focusRoot.focusableViews; + if (views.isEmpty) return; + + if (_focused == null) { + focus(views.last); + return; + } + + var idx = views.indexOf(_focused!); + var prevIdx = (idx - 1 + views.length) % views.length; + focus(views[prevIdx]); + } + + bool routeKeyToFocused(String key) { + if (_focused != null && _focused!.onKey(key)) { + return true; + } + return false; + } + + Set _ancestorFrames(View? view) { + var frames = {}; + var current = view?.parent; + while (current != null) { + if (current is Frame) frames.add(current); + current = current.parent; + } + return frames; + } +} +``` + +- [ ] **Step 2: Run focus tests to verify they pass** + +Run: `dart test test/focus_test.dart -v` + +Expected: All tests pass including new focus propagation tests. + +- [ ] **Step 3: Run all tests to verify nothing broke** + +Run: `dart test` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add lib/src/focus.dart test/focus_test.dart +git commit -m "add focus propagation to ancestor Frames" +``` diff --git a/docs/superpowers/specs/2026-03-27-focus-clipping-design.md b/docs/superpowers/specs/2026-03-27-focus-clipping-design.md new file mode 100644 index 0000000..4a33475 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-focus-clipping-design.md @@ -0,0 +1,211 @@ +# Focus System + Canvas Clipping — Design Spec + +**Date:** 2026-03-27 +**Scope:** Fix the two biggest pain points from user feedback — focus propagation in Frame and content clipping. +**Approach:** Canvas-level clipping + hierarchical focus traversal (Approach 1) +**Files changed:** `canvas.dart`, `focus.dart`, `view.dart` (3 files total) +**Files unchanged:** `window.dart`, `widgets.dart`, all widgets + +--- + +## 1. Canvas Clipping + +### Problem + +`Canvas.write()` delegates directly to `Screen.write()`, which only checks screen bounds. Content longer than a widget's allocated space bleeds through Frame borders and corrupts adjacent widgets. Example: a markdown table with pipe `|` characters or long text lines inside a Frame. + +### Root Cause + +`Canvas` has `width`/`height` (from `Sizable`) but never checks writes against them. Child canvases created via `canvas(Size, Position)` inherit the parent's `Screen` reference with a combined offset, but no clipping region. + +### Solution + +Canvas stores absolute clip bounds (`_clipRight`, `_clipBottom`). Every `write()`, `occluded()`, and `stringAt()` checks both local bounds AND clip bounds. Child canvases inherit the intersection of parent clip bounds and their own bounds. + +### Design + +```dart +class Canvas with Sizable, Positionable { + final Screen _scr; + int _clipRight; // absolute screen X limit (exclusive) + int _clipBottom; // absolute screen Y limit (exclusive) + + Canvas(Size size, Position offset, this._scr, [int? clipRight, int? clipBottom]) { + this.size = size; + this.position = offset; + _clipRight = clipRight ?? (offset.x + size.width); + _clipBottom = clipBottom ?? (offset.y + size.height); + } + + Canvas canvas(Size size, Position offset) { + var combined = Position(offset.x + x, offset.y + y); + return Canvas( + size, combined, _scr, + min(combined.x + size.width, _clipRight), + min(combined.y + size.height, _clipBottom), + ); + } + + void write(int x, int y, String char) { + if (x < 0 || y < 0 || x >= width || y >= height) return; + var absX = position.x + x; + var absY = position.y + y; + if (absX >= _clipRight || absY >= _clipBottom) return; + _scr.write(absX, absY, char); + } + + bool occluded(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return true; + var absX = position.x + x; + var absY = position.y + y; + if (absX >= _clipRight || absY >= _clipBottom) return true; + return _scr.occluded(absX, absY); + } + + String stringAt(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return ''; + return _scr.stringAt(position.x + x, position.y + y); + } +} +``` + +### Behavior + +- Root canvas (from `Screen.canvas()`) has clip bounds = screen size +- Frame creates child canvas → child inherits `min(own bounds, Frame bounds)` +- Nested Frames correctly intersect clip regions +- Text that overflows a widget silently stops rendering at the boundary +- Zero changes to any widget code — clipping is enforced at the Canvas layer + +--- + +## 2. Focus System + +### Problem 1: No Focus Propagation + +When a child inside a Frame receives focus, `Frame.focused` stays `false`. The border doesn't change to double-line style. Users must manually sync `frame.focused = child.focused`. + +### Problem 2: No Focus Group + +Frame + its children can't be treated as a single focus unit. There's no way to say "highlight this Frame when anything inside it is focused." + +### Root Cause + +- `View` has no `parent` reference — the tree is traversable only top-down +- `FocusManager.focus()` sets `focused = true` only on the target view, not on ancestor containers + +### Solution + +Two changes: +1. Add `View? parent` — set automatically during `resize()` before `resizeChildren()` +2. `FocusManager.focus()` propagates focus state to ancestor Frames via parent chain + +### Design + +#### Parent Reference (view.dart) + +```dart +abstract class View with Sizable, Positionable { + View? parent; + // ... existing fields unchanged + + void resize(Size size, Position position) { + this.size = size; + this.position = position; + for (var child in children) { + child.parent = this; + } + resizeChildren(); + update(); + } +} +``` + +Setting parent in `resize()` means: +- No API changes — `frame.children = [a, b]` still works +- Parent is refreshed every frame, so dynamic child changes are handled +- No need to modify any `resizeChildren()` override + +#### Focus Propagation (focus.dart) + +When focus moves from viewA to viewB: +- Ancestor Frames of viewA that are NOT ancestors of viewB → `focused = false` +- Ancestor Frames of viewB that were NOT ancestors of viewA → `focused = true` + +This correctly handles: nested Frames, focus moving within the same Frame (border doesn't flicker), and focus leaving a Frame. + +```dart +mixin FocusManager { + View? _focused; + View? get focusedView => _focused; + View get focusRoot; + + void focus(View view) { + if (!view.focusable) return; + if (_focused == view) return; + + var oldFrames = _ancestorFrames(_focused); + + _focused?.focused = false; + _focused?.onBlur(); + + _focused = view; + view.focused = true; + view.onFocus(); + + var newFrames = _ancestorFrames(view); + + for (var frame in oldFrames) { + if (!newFrames.contains(frame)) frame.focused = false; + } + for (var frame in newFrames) { + if (!oldFrames.contains(frame)) frame.focused = true; + } + } + + Set _ancestorFrames(View? view) { + var frames = {}; + var current = view?.parent; + while (current != null) { + if (current is Frame) frames.add(current); + current = current.parent; + } + return frames; + } + + // focusFirst(), focusNext(), focusPrev(), routeKeyToFocused() — unchanged +} +``` + +### Focus Traversal + +Tab traversal is unchanged. `focusableViews` already returns children in depth-first order, which gives natural tab flow: + +- Frame with children A, B + standalone C → tab order: A → B → C +- Focus on A: Frame.focused = true (double border) +- Focus on B: Frame stays focused (same ancestor) +- Focus on C: Frame.focused = false (single border) + +### Behavior Summary + +| Action | Before | After | +|--------|--------|-------| +| Focus child in Frame | Frame.focused = false, must sync manually | Frame.focused = true automatically | +| Tab through Frame children | Works (flat list) | Works (unchanged) | +| Tab out of Frame | Works | Works + Frame.focused auto-resets | +| Nested Frames | Not supported | Inner Frame focused when its child focused, outer Frame also focused | + +--- + +## Backward Compatibility + +- **Canvas clipping:** Fully backward compatible. Existing widgets render the same, they just can't overflow anymore. +- **Parent reference:** New `View? parent` field, defaults to null. Set automatically. No API breakage. +- **Focus propagation:** Automatic. Code that manually syncs `frame.focused` will still work but becomes unnecessary. + +## Testing Strategy + +- **Clipping:** Unit test that writes beyond canvas bounds and verifies Screen cells outside bounds are empty. +- **Focus propagation:** Unit test that focuses a child in a Frame and verifies Frame.focused is true; then focuses outside and verifies it's false. +- **Nested clipping:** Test with Frame-in-Frame, verify inner content doesn't bleed through outer Frame. +- **Nested focus:** Test with Frame-in-Frame, verify both ancestor Frames get focused state. diff --git a/example/.claude/settings.local.json b/example/.claude/settings.local.json new file mode 100644 index 0000000..552fc97 --- /dev/null +++ b/example/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(timeout 3 dart run:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/example/animation_demo.dart b/example/animation_demo.dart new file mode 100644 index 0000000..4e87f18 --- /dev/null +++ b/example/animation_demo.dart @@ -0,0 +1,533 @@ +import 'package:tui/tui.dart'; +import 'dart:async'; + +class AnimationDemo extends Window { + late AnimationController controller; + String currentEffect = ''; + String statusText = ''; + int demoIndex = 0; + + final demos = [ + 'Typewriter', + 'Glitch', + 'Pulse (loop)', + 'Pulse (pingPong)', + 'Reveal Lines', + 'Reveal Fade', + 'Shimmer', + 'Blink', + 'BigText Typewriter', + 'BigText Glitch', + 'BigText Matrix', + 'BigText Mixed', + 'Matrix Rain', + 'Particle Burst', + 'Countdown', + 'Scrolling Banner', + 'Cascade', + ]; + + AnimationDemo() { + controller = AnimationController(selfTick: false); + showFps = true; + } + + @override + void start() { + super.start(); + _showMenu(); + } + + void _showMenu() { + currentEffect = ''; + statusText = 'UP/DOWN = select, ENTER = run, F = toggle FPS, Q = quit'; + } + + void _runDemo(int index) { + controller.clear(); + currentEffect = ''; + + switch (demos[index]) { + case 'Typewriter': + _runTypewriter(); + case 'Glitch': + _runGlitch(); + case 'Pulse (loop)': + _runPulseLoop(); + case 'Pulse (pingPong)': + _runPulsePingPong(); + case 'Reveal Lines': + _runRevealLines(); + case 'Reveal Fade': + _runRevealFade(); + case 'Shimmer': + _runShimmer(); + case 'Blink': + _runBlink(); + case 'BigText Typewriter': + _runBigTextTypewriter(); + case 'BigText Glitch': + _runBigTextGlitch(); + case 'BigText Matrix': + _runBigTextMatrix(); + case 'BigText Mixed': + _runBigTextMixed(); + case 'Matrix Rain': + _runMatrixRain(); + case 'Particle Burst': + _runParticleBurst(); + case 'Countdown': + _runCountdown(); + case 'Scrolling Banner': + _runScrollingBanner(); + case 'Cascade': + _runCascade(); + } + } + + void _runTypewriter() { + statusText = 'Typewriter animation...'; + controller.add(TypewriterAnimation( + text: + 'Hello, this is a typewriter effect! Each character appears one by one.', + duration: Duration(milliseconds: 2000), + easing: Easing.linear, + onUpdate: (visible) { + currentEffect = visible; + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runGlitch() { + statusText = 'Glitch animation...'; + currentEffect = 'SYSTEM MALFUNCTION DETECTED'; + controller.add(GlitchAnimation( + text: 'SYSTEM MALFUNCTION DETECTED', + duration: Duration(milliseconds: 1500), + intensity: 0.5, + onUpdate: (glitched) { + currentEffect = glitched; + }, + onComplete: () { + currentEffect = 'SYSTEM MALFUNCTION DETECTED'; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runPulseLoop() { + statusText = 'Pulse LOOP (runs forever, press any key to stop)...'; + currentEffect = '>>> ALERT <<<'; + controller.add(ValueAnimation( + from: 0, + to: 1, + duration: Duration(milliseconds: 500), + repeatMode: RepeatMode.loop, + onUpdate: (value) { + var bright = value > 0.5; + currentEffect = bright + ? '${Colors.brightRed}>>> ALERT <<<${Colors.reset}' + : '${Colors.dim}>>> ALERT <<<${Colors.reset}'; + }, + )); + } + + void _runPulsePingPong() { + statusText = 'Pulse PING-PONG (3 cycles)...'; + currentEffect = '>>> ALERT <<<'; + controller.add(ValueAnimation( + from: 0, + to: 1, + duration: Duration(milliseconds: 400), + repeatMode: RepeatMode.pingPong, + repeatCount: 3, + easing: Easing.easeInOut, + onUpdate: (value) { + // Smooth brightness transition + var r = (255 * value).round(); + var g = (50 * (1 - value)).round(); + currentEffect = '${Colors.fgRgb(r, g, 0)}>>> ALERT <<<${Colors.reset}'; + }, + onComplete: () { + currentEffect = '>>> ALERT <<<'; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + List get _revealLines { + final w = 33; + final content = [ + ' DART TUI ANIMATION SYSTEM ', + ' ', + ' Reveal animation demo ', + ' Line by line effect ', + ]; + return [ + '${BoxChars.doubleTL}${BoxChars.doubleH * (w - 2)}${BoxChars.doubleTR}', + ...content.map((c) => '${BoxChars.doubleV}$c${BoxChars.doubleV}'), + '${BoxChars.doubleBL}${BoxChars.doubleH * (w - 2)}${BoxChars.doubleBR}', + ]; + } + + void _runRevealLines() { + statusText = 'Reveal lines (top to bottom)...'; + currentEffect = ''; + controller.add(RevealAnimation( + lines: _revealLines, + style: RevealStyle.linesDown, + duration: Duration(milliseconds: 1500), + easing: Easing.easeOut, + onUpdate: (visible, _) { + currentEffect = + visible.map((l) => '${Colors.cyan}$l${Colors.reset}').join('\n'); + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runRevealFade() { + statusText = 'Reveal FADE (forward then reverse)...'; + controller.add(RevealAnimation( + lines: _revealLines, + style: RevealStyle.fade, + duration: Duration(milliseconds: 1000), + repeatMode: RepeatMode.reverse, + onUpdate: (lines, opacity) { + // Simulate fade with dim/bright colors + var color = opacity < 0.3 + ? Colors.dim + : opacity < 0.7 + ? Colors.cyan + : Colors.brightCyan; + currentEffect = lines.map((l) => '$color$l${Colors.reset}').join('\n'); + }, + onComplete: () { + currentEffect = ''; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runShimmer() { + statusText = 'Shimmer effect (infinite loop)...'; + final text = '████████████████████████████████████████'; + controller.add(ShimmerAnimation( + width: text.length, + duration: Duration(milliseconds: 1500), + repeatMode: RepeatMode.loop, + onUpdate: (pos) { + var buf = StringBuffer(); + for (var i = 0; i < text.length; i++) { + var dist = (i - pos).abs(); + if (dist < 3) { + buf.write('${Colors.brightWhite}█'); + } else if (dist < 6) { + buf.write('${Colors.white}█'); + } else { + buf.write('${Colors.dim}█'); + } + } + buf.write(Colors.reset); + currentEffect = buf.toString(); + }, + )); + } + + void _runBlink() { + statusText = 'Blink animation (cursor style, infinite)...'; + controller.add(BlinkAnimation( + blinkRate: Duration(milliseconds: 500), + repeatMode: RepeatMode.loop, + onUpdate: (visible) { + currentEffect = visible + ? '${Colors.brightGreen}█${Colors.reset} Cursor visible' + : ' Cursor hidden'; + }, + )); + } + + void _runBigTextTypewriter() { + statusText = 'BigText with TYPEWRITER reveal...'; + final lines = BigText.generateLines('TUI', font: BigTextFont.shadow); + controller.add(BigTextAnimation( + lines: lines, + lineDelay: Duration(milliseconds: 150), + defaultStyle: LineRevealConfig.typewriter, + onUpdate: (rendered) { + currentEffect = + rendered.map((l) => '${Colors.cyan}$l${Colors.reset}').join('\n'); + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runBigTextGlitch() { + statusText = 'BigText with GLITCH reveal...'; + final lines = BigText.generateLines('GLITCH', font: BigTextFont.shadow); + controller.add(BigTextAnimation( + lines: lines, + lineDelay: Duration(milliseconds: 100), + defaultStyle: LineRevealConfig.glitch, + onUpdate: (rendered) { + currentEffect = rendered + .map((l) => '${Colors.brightRed}$l${Colors.reset}') + .join('\n'); + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runBigTextMatrix() { + statusText = 'BigText with MATRIX reveal...'; + final lines = BigText.generateLines('MATRIX', font: BigTextFont.shadow); + final maxLen = lines.map((l) => l.length).reduce((a, b) => a > b ? a : b); + controller.add(BigTextAnimation( + lines: lines, + lineDelay: Duration(milliseconds: 120), + defaultStyle: LineRevealConfig.matrix, + onUpdate: (rendered) { + currentEffect = rendered.join('\n'); + }, + onComplete: () { + // Pad lines to clear any leftover matrix chars + currentEffect = lines + .map((l) => + '${Colors.brightGreen}${l.padRight(maxLen + 10)}${Colors.reset}') + .join('\n'); + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runBigTextMixed() { + statusText = 'BigText with MIXED styles per line...'; + final lines = BigText.generateLines('MIXED', font: BigTextFont.shadow); + controller.add(BigTextAnimation( + lines: lines, + lineDelay: Duration(milliseconds: 200), + lineStyles: [ + LineRevealConfig.glitch, // Line 1: glitch + LineRevealConfig.matrix, // Line 2: matrix + LineRevealConfig.typewriter, // Line 3: typewriter + LineRevealConfig.fade, // Line 4: fade + LineRevealConfig.glitch, // Line 5: glitch + LineRevealConfig.matrix, // Line 6: matrix + ], + onUpdate: (rendered) { + var buf = StringBuffer(); + var colors = [ + Colors.red, + Colors.green, + Colors.cyan, + Colors.yellow, + Colors.magenta, + Colors.brightGreen + ]; + for (var i = 0; i < rendered.length; i++) { + buf.write( + '${colors[i % colors.length]}${rendered[i]}${Colors.reset}\n'); + } + currentEffect = buf.toString().trimRight(); + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runMatrixRain() { + statusText = 'Matrix Rain effect...'; + controller.add(MatrixRainAnimation( + width: 60, + duration: Duration(milliseconds: 3000), + onUpdate: (line) { + currentEffect = line; + }, + onComplete: () { + currentEffect = ''; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runParticleBurst() { + statusText = 'Particle Burst effect...'; + controller.add(ParticleBurstAnimation( + width: 60, + duration: Duration(milliseconds: 2000), + density: 0.2, + onUpdate: (line) { + currentEffect = line; + }, + onComplete: () { + currentEffect = + '${Colors.brightGreen}${Colors.bold}SUCCESS!${Colors.reset}'; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runCountdown() { + statusText = 'Countdown animation...'; + controller.add(CountdownAnimation( + from: 5, + onUpdate: (remaining, display) { + currentEffect = + '${Colors.brightCyan}${Colors.bold}$display${Colors.reset}'; + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runScrollingBanner() { + statusText = 'Scrolling Banner animation...'; + controller.add(ScrollingBannerAnimation( + text: ' DART TUI ANIMATION SYSTEM ', + width: 40, + iterations: 2, + onUpdate: (visible) { + currentEffect = '${Colors.cyan}$visible${Colors.reset}'; + }, + onComplete: () { + currentEffect = + '${Colors.cyan} DART TUI ANIMATION SYSTEM ${Colors.reset}'; + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + void _runCascade() { + statusText = 'Cascade animation...'; + var items = [ + 'Loading modules...', + 'Initializing system...', + 'Connecting to server...', + 'Ready!' + ]; + controller.add(CascadeAnimation( + items: items, + itemDelay: Duration(milliseconds: 500), + onUpdate: (count, visible) { + currentEffect = + visible.map((s) => '${Colors.green}> $s${Colors.reset}').join('\n'); + }, + onComplete: () { + statusText = 'Done! Press any key to continue.'; + }, + )); + } + + @override + bool onKey(String key) { + if (key == 'q' || key == KeyCode.ESCAPE) { + controller.clear(); + stop(); + return true; + } + + if (key == 'f' || key == 'F') { + showFps = !showFps; + return true; + } + + if (key == KeyCode.UP) { + demoIndex = (demoIndex - 1) % demos.length; + if (demoIndex < 0) demoIndex = demos.length - 1; + return true; + } + + if (key == KeyCode.DOWN) { + demoIndex = (demoIndex + 1) % demos.length; + return true; + } + + if (key == KeyCode.ENTER) { + _runDemo(demoIndex); + return true; + } + + return super.onKey(key); + } + + @override + void render(Canvas canvas) { + controller.tick(); + super.render(canvas); + } + + @override + void update() { + text = []; + var y = 0; + + // Title + text.add(Text( + '${Colors.brightCyan}${Colors.bold}Animation System Demo${Colors.reset}') + ..position = Position(0, y++)); + text.add(Text('${BoxChars.lightH * 50}')..position = Position(0, y++)); + y++; + + // Menu + text.add(Text('${Colors.dim}Select animation:${Colors.reset}') + ..position = Position(0, y++)); + for (var i = 0; i < demos.length; i++) { + var prefix = i == demoIndex ? '${Colors.brightGreen}> ' : ' '; + var suffix = i == demoIndex ? '${Colors.reset}' : ''; + text.add(Text('$prefix${demos[i]}$suffix')..position = Position(0, y++)); + } + y++; + + // Divider + text.add(Text('${BoxChars.lightH * 50}')..position = Position(0, y++)); + y++; + + // Effect display area + text.add(Text('${Colors.dim}Output:${Colors.reset}') + ..position = Position(0, y++)); + + // Split effect by newlines for cascade + var effectLines = currentEffect.split('\n'); + for (var line in effectLines) { + text.add(Text(line)..position = Position(0, y++)); + } + + // Ensure some space + while (y < 22) y++; + + // Status + text.add(Text('${BoxChars.lightH * 50}')..position = Position(0, y++)); + text.add(Text('${Colors.dim}$statusText${Colors.reset}') + ..position = Position(0, y)); + } +} + +void main() { + print('Animation System Demo'); + print('${BoxChars.lightH * 40}'); + print(''); + print('Repeat modes: once, reverse, loop, pingPong'); + print('New: Reveal, Shimmer, Blink animations'); + print(''); + print('UP/DOWN = select animation'); + print('ENTER = run animation'); + print('F = toggle FPS meter'); + print('Q/ESC = quit'); + print('${BoxChars.lightH * 40}'); + print('Starting in 1 second...'); + + Future.delayed(Duration(seconds: 1), () { + AnimationDemo().start(); + }); +} diff --git a/example/arkanoid.dart b/example/arkanoid.dart new file mode 100644 index 0000000..8433962 --- /dev/null +++ b/example/arkanoid.dart @@ -0,0 +1,342 @@ +import 'package:tui/tui.dart'; +import 'dart:math'; + +class Brick { + int x, y, width; + int hits; // how many hits to destroy + bool destroyed = false; + + Brick(this.x, this.y, this.width, this.hits); +} + +class ArkanoidGame extends Window { + // Game constants + static const int paddleWidth = 12; + static const int paddleY = 3; // from bottom + static const String paddleChar = BoxChars.fullBlock; + static const String ballChar = '●'; + static const int brickHeight = 1; + static const int brickRows = 5; + static const int brickCols = 10; + + // Game state + int paddleX = 0; + double ballX = 0; + double ballY = 0; + double ballDx = 1; + double ballDy = -1; + int score = 0; + int lives = 3; + bool gameRunning = false; + bool gameOver = false; + int level = 1; + + List bricks = []; + + // Frame counter for ball speed control + int frameCount = 0; + int ballUpdateInterval = 2; + + bool _initialized = false; + + ArkanoidGame() { + showFps = true; + } + + @override + void resize(Size size, Position position) { + super.resize(size, position); + if (!_initialized) { + _initialized = true; + _initGame(); + } + } + + void _initGame() { + score = 0; + lives = 3; + level = 1; + gameOver = false; + _initLevel(); + } + + void _initLevel() { + _initBricks(); + _resetBall(); + _resetPaddle(); + gameRunning = false; + } + + void _initBricks() { + bricks.clear(); + final brickWidth = (width - 4) ~/ brickCols; + final startX = (width - brickWidth * brickCols) ~/ 2; + final startY = 4; + + for (var row = 0; row < brickRows; row++) { + final hits = (brickRows - row) <= 2 ? 1 : (row < 2 ? 2 : 1); + for (var col = 0; col < brickCols; col++) { + bricks.add(Brick( + startX + col * brickWidth, + startY + row * (brickHeight + 1), + brickWidth - 1, + hits, + )); + } + } + } + + void _resetBall() { + ballX = width / 2; + ballY = height - paddleY - 2.0; + final random = Random(); + ballDx = (random.nextDouble() - 0.5) * 2; + ballDy = -1; + } + + void _resetPaddle() { + paddleX = (width - paddleWidth) ~/ 2; + } + + @override + bool onKey(String key) { + switch (key) { + case KeyCode.LEFT: + case 'a': + case 'A': + if (paddleX > 1) paddleX -= 2; + return true; + case KeyCode.RIGHT: + case 'd': + case 'D': + if (paddleX < width - paddleWidth - 2) paddleX += 2; + return true; + case KeyCode.SPACE: + if (gameOver) { + _initGame(); + } else { + gameRunning = !gameRunning; + } + return true; + case 'r': + case 'R': + _initGame(); + return true; + case 'q': + case KeyCode.ESCAPE: + stop(); + return true; + } + return false; + } + + @override + void update() { + if (!gameRunning || gameOver) return; + + frameCount++; + if (frameCount < ballUpdateInterval) return; + frameCount = 0; + + // Update ball position + ballX += ballDx; + ballY += ballDy; + + // Bounce off walls + if (ballX <= 1) { + ballX = 1; + ballDx = ballDx.abs(); + } + if (ballX >= width - 2) { + ballX = width - 2.0; + ballDx = -ballDx.abs(); + } + if (ballY <= 1) { + ballY = 1; + ballDy = ballDy.abs(); + } + + // Ball falls below paddle + if (ballY >= height - 1) { + lives--; + if (lives <= 0) { + gameOver = true; + gameRunning = false; + } else { + _resetBall(); + gameRunning = false; + } + return; + } + + // Paddle collision + final paddleTop = height - paddleY - 1; + if (ballY >= paddleTop - 1 && ballY <= paddleTop) { + if (ballX >= paddleX && ballX <= paddleX + paddleWidth) { + ballY = paddleTop - 1.0; + ballDy = -ballDy.abs(); + // Add spin based on hit position + final hitPos = (ballX - paddleX) / paddleWidth; + ballDx = (hitPos - 0.5) * 3; + } + } + + // Brick collisions + _checkBrickCollisions(); + + // Check level complete + if (bricks.every((b) => b.destroyed)) { + level++; + ballUpdateInterval = max(1, ballUpdateInterval - 1); + _initLevel(); + } + } + + void _checkBrickCollisions() { + final bx = ballX.round(); + final by = ballY.round(); + + for (var brick in bricks) { + if (brick.destroyed) continue; + + if (bx >= brick.x && bx <= brick.x + brick.width && + by >= brick.y && by <= brick.y + brickHeight) { + brick.hits--; + if (brick.hits <= 0) { + brick.destroyed = true; + score += 10 * level; + } + + // Determine bounce direction + final brickCenterX = brick.x + brick.width / 2; + final brickCenterY = brick.y + brickHeight / 2; + + if ((ballX - brickCenterX).abs() > (ballY - brickCenterY).abs() * 2) { + ballDx = -ballDx; + } else { + ballDy = -ballDy; + } + return; + } + } + } + + @override + void render(Canvas canvas) { + _drawBorder(canvas); + _drawBricks(canvas); + _drawPaddle(canvas); + _drawBall(canvas); + _drawHUD(canvas); + + if (!gameRunning && !gameOver) { + _drawMessage(canvas, 'SPACE to launch'); + } + if (gameOver) { + _drawMessage(canvas, 'GAME OVER - SPACE to restart'); + } + } + + void _drawBorder(Canvas canvas) { + for (var x = 0; x < width; x++) { + canvas.write(x, 0, BoxChars.lightH); + canvas.write(x, height - 1, BoxChars.lightH); + } + for (var y = 0; y < height; y++) { + canvas.write(0, y, BoxChars.lightV); + canvas.write(width - 1, y, BoxChars.lightV); + } + canvas.write(0, 0, BoxChars.roundedTL); + canvas.write(width - 1, 0, BoxChars.roundedTR); + canvas.write(0, height - 1, BoxChars.roundedBL); + canvas.write(width - 1, height - 1, BoxChars.roundedBR); + } + + void _drawBricks(Canvas canvas) { + for (var brick in bricks) { + if (brick.destroyed) continue; + + final char = brick.hits > 1 ? BoxChars.fullBlock : BoxChars.mediumShade; + for (var x = 0; x < brick.width; x++) { + canvas.write(brick.x + x, brick.y, char); + } + } + } + + void _drawPaddle(Canvas canvas) { + final y = height - paddleY - 1; + for (var x = 0; x < paddleWidth; x++) { + canvas.write(paddleX + x, y, paddleChar); + } + } + + void _drawBall(Canvas canvas) { + if (!gameOver) { + canvas.write(ballX.round(), ballY.round(), ballChar); + } + } + + void _drawHUD(Canvas canvas) { + final hud = ' Score: $score ${BoxChars.lightV} Lives: ${'♥' * lives} ${BoxChars.lightV} Level: $level '; + final hudX = (width - hud.length) ~/ 2; + for (var i = 0; i < hud.length; i++) { + canvas.write(hudX + i, 0, hud[i]); + } + + final controls = ' ←/→ or A/D: move ${BoxChars.lightV} SPACE: launch/pause ${BoxChars.lightV} R: restart ${BoxChars.lightV} Q: quit '; + final ctrlX = (width - controls.length) ~/ 2; + if (controls.length < width - 2) { + for (var i = 0; i < controls.length; i++) { + canvas.write(ctrlX + i, height - 1, controls[i]); + } + } + } + + void _drawMessage(Canvas canvas, String message) { + final msgX = (width - message.length) ~/ 2; + final msgY = height ~/ 2 + 4; + + // Draw box around message + final boxWidth = message.length + 4; + final boxX = msgX - 2; + + canvas.write(boxX, msgY - 1, BoxChars.roundedTL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, msgY - 1, BoxChars.lightH); + } + canvas.write(boxX + boxWidth - 1, msgY - 1, BoxChars.roundedTR); + + canvas.write(boxX, msgY, BoxChars.lightV); + canvas.write(boxX + 1, msgY, ' '); + for (var i = 0; i < message.length; i++) { + canvas.write(msgX + i, msgY, message[i]); + } + canvas.write(boxX + boxWidth - 2, msgY, ' '); + canvas.write(boxX + boxWidth - 1, msgY, BoxChars.lightV); + + canvas.write(boxX, msgY + 1, BoxChars.roundedBL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, msgY + 1, BoxChars.lightH); + } + canvas.write(boxX + boxWidth - 1, msgY + 1, BoxChars.roundedBR); + } +} + +void main() { + print('ARKANOID'); + print(BoxChars.lightH * 40); + print(''); + print('Controls:'); + print(' ← / → or A / D : Move paddle'); + print(' SPACE : Launch ball / Pause'); + print(' R : Restart game'); + print(' Q / ESC : Quit'); + print(''); + print('Destroy all bricks to advance!'); + print(BoxChars.lightH * 40); + print('Starting in 2 seconds...'); + + Future.delayed(Duration(seconds: 2), () { + ArkanoidGame().start(); + }); +} diff --git a/example/audio_demo.dart b/example/audio_demo.dart new file mode 100644 index 0000000..cf1b7dc --- /dev/null +++ b/example/audio_demo.dart @@ -0,0 +1,200 @@ +import 'package:tui/tui.dart'; + +Future _wait(double seconds) => Future.delayed(Duration(milliseconds: (seconds * 1000).round())); + +// Für Elise — opening motif, Section A +const _furEliseA = ''' + E5.e Ds5.e E5.e Ds5.e E5.e B4.e D5.e C5.e | + A4.q R.e C4.e E4.e A4.e | + B4.q R.e E4.e Gs4.e B4.e | + C5.q R.e E4.e E5.e Ds5.e | + E5.e Ds5.e E5.e B4.e D5.e C5.e | + A4.q R.e C4.e E4.e A4.e | + B4.q R.e E4.e C5.e B4.e | + A4.h +'''; + +// Section B — contrasting theme +const _furEliseB = ''' + B4.e C5.e D5.e E5.e | + F5.q R.e D5.e F5.e E5.e | + E5.q R.e C5.e E5.e D5.e | + D5.q R.e B4.e D5.e C5.e +'''; + +void main() async { + final audio = Audio(); + if (!audio.isAvailable) { + print('No audio player found.'); + return; + } + + final sfx = audio.channel('sfx'); + final music = audio.channel('music'); + + print(''); + print(' ╔══════════════════════════════════════════════════════╗'); + print(' ║ TUI Audio Engine — Feature Showcase ║'); + print(' ╚══════════════════════════════════════════════════════╝'); + print(''); + + // ── 1. SFX Presets ────────────────────────────────────────── + print(' ▸ SFX Presets'); + for (final preset in SFX.values) { + print(' ${preset.name}'); + preset.playOn(sfx); + await _wait(0.6); + } + print(''); + + // ── 2. Waveform Gallery ───────────────────────────────────── + print(' ▸ Waveform Gallery (A4 = 440 Hz)'); + for (final wf in Waveform.values.where((w) => w != Waveform.noise)) { + print(' ${wf.name}'); + sfx.tone(frequency: Note.A4, duration: 0.4, waveform: wf); + await _wait(0.6); + } + print(''); + + // ── 3. ADSR Envelope Comparison ───────────────────────────── + print(' ▸ Envelope Comparison (same note, different ADSR)'); + + print(' staccato (short attack, no sustain)'); + sfx.tone( + frequency: Note.C5, + duration: 0.3, + waveform: Waveform.square, + envelope: Envelope(attack: 0.005, decay: 0.1, sustain: 0.0, release: 0.01), + ); + await _wait(0.5); + + print(' legato (slow attack, full sustain)'); + sfx.tone( + frequency: Note.C5, + duration: 0.6, + waveform: Waveform.sine, + envelope: Envelope(attack: 0.15, decay: 0.05, sustain: 0.8, release: 0.2), + ); + await _wait(0.9); + + print(' pluck (fast attack, quick decay)'); + sfx.tone( + frequency: Note.C5, + duration: 0.4, + waveform: Waveform.triangle, + envelope: Envelope(attack: 0.001, decay: 0.2, sustain: 0.1, release: 0.15), + ); + await _wait(0.6); + + print(' pad (very slow everything)'); + sfx.tone( + frequency: Note.C5, + duration: 1.0, + waveform: Waveform.sawtooth, + envelope: Envelope(attack: 0.3, decay: 0.2, sustain: 0.5, release: 0.3), + ); + await _wait(1.3); + print(''); + + // ── 4. Synthesizer.mix — Layered chord ────────────────────── + print(' ▸ Synthesizer.mix — C major chord (3 tones layered)'); + final env = Envelope(attack: 0.02, decay: 0.1, sustain: 0.4, release: 0.2); + final c = Synthesizer.tone(frequency: Note.C4, duration: 1.0, waveform: Waveform.sine, envelope: env, volume: 0.5); + final e = Synthesizer.tone(frequency: Note.E4, duration: 1.0, waveform: Waveform.sine, envelope: env, volume: 0.5); + final g = Synthesizer.tone(frequency: Note.G4, duration: 1.0, waveform: Waveform.sine, envelope: env, volume: 0.5); + final chord = Synthesizer.mix([c, e, g]); + sfx.playMelody(Melody(bpm: 60)..note(Note.C4, Dur.whole)); // dummy, we play raw below + sfx.stop(); + // play the raw mixed chord directly + final player = AudioPlayer(); + await player.playWav(WavWriter.encode(chord)); + print(''); + + // ── 5. Melody Builder — Für Elise theme ───────────────────── + print(' ▸ Melody Builder — Für Elise, Section A (square wave, chiptune)'); + final eliseBuilder = Melody(bpm: 180, waveform: Waveform.square, volume: 0.6); + // The famous motif + void motif(Melody m) { + m.note(Note.E5, Dur.eighth); + m.note(Note.Ds5, Dur.eighth); + m.note(Note.E5, Dur.eighth); + m.note(Note.Ds5, Dur.eighth); + m.note(Note.E5, Dur.eighth); + m.note(Note.B4, Dur.eighth); + m.note(Note.D5, Dur.eighth); + m.note(Note.C5, Dur.eighth); + } + + motif(eliseBuilder); + eliseBuilder.note(Note.A4, Dur.quarter); + eliseBuilder.rest(Dur.eighth); + eliseBuilder.note(Note.C4, Dur.eighth); + eliseBuilder.note(Note.E4, Dur.eighth); + eliseBuilder.note(Note.A4, Dur.eighth); + eliseBuilder.note(Note.B4, Dur.quarter); + eliseBuilder.rest(Dur.eighth); + eliseBuilder.note(Note.E4, Dur.eighth); + eliseBuilder.note(Note.Gs4, Dur.eighth); + eliseBuilder.note(Note.B4, Dur.eighth); + eliseBuilder.note(Note.C5, Dur.quarter); + eliseBuilder.rest(Dur.eighth); + eliseBuilder.note(Note.E4, Dur.eighth); + eliseBuilder.note(Note.E5, Dur.eighth); + eliseBuilder.note(Note.Ds5, Dur.eighth); + motif(eliseBuilder); + eliseBuilder.note(Note.A4, Dur.quarter); + eliseBuilder.rest(Dur.eighth); + eliseBuilder.note(Note.C4, Dur.eighth); + eliseBuilder.note(Note.E4, Dur.eighth); + eliseBuilder.note(Note.A4, Dur.eighth); + eliseBuilder.note(Note.B4, Dur.quarter); + eliseBuilder.rest(Dur.eighth); + eliseBuilder.note(Note.E4, Dur.eighth); + eliseBuilder.note(Note.C5, Dur.eighth); + eliseBuilder.note(Note.B4, Dur.eighth); + eliseBuilder.note(Note.A4, Dur.half); + + print(' ${eliseBuilder.totalBeats} beats, ${eliseBuilder.totalSeconds.toStringAsFixed(1)}s'); + music.playMelody(eliseBuilder); + await _wait(eliseBuilder.totalSeconds + 0.3); + print(''); + + // ── 6. DSL String — Für Elise again, sine wave ───────────── + print(' ▸ DSL String — Für Elise, Section A (sine wave, softer)'); + final eliseDsl = Melody.parse(_furEliseA, bpm: 180, waveform: Waveform.sine); + print(' ${eliseDsl.totalBeats} beats, ${eliseDsl.totalSeconds.toStringAsFixed(1)}s'); + music.playMelody(eliseDsl); + await _wait(eliseDsl.totalSeconds + 0.3); + print(''); + + // ── 7. DSL — Section B, triangle wave ────────────────────── + print(' ▸ DSL String — Für Elise, Section B (triangle wave)'); + final eliseB = Melody.parse(_furEliseB, bpm: 180, waveform: Waveform.triangle); + print(' ${eliseB.totalBeats} beats, ${eliseB.totalSeconds.toStringAsFixed(1)}s'); + music.playMelody(eliseB); + await _wait(eliseB.totalSeconds + 0.3); + print(''); + + // ── 8. Full Für Elise A+B, sawtooth ──────────────────────── + print(' ▸ Full Für Elise A+B (sawtooth, custom envelope)'); + final full = Melody.parse( + '$_furEliseA $_furEliseB', + bpm: 160, + waveform: Waveform.sawtooth, + ); + full.envelope = Envelope(attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.08); + print(' ${full.totalBeats} beats, ${full.totalSeconds.toStringAsFixed(1)}s'); + music.playMelody(full); + await _wait(full.totalSeconds + 0.3); + print(''); + + // ── Finale ────────────────────────────────────────────────── + print(' ▸ Finale'); + SFX.success.playOn(sfx); + await _wait(1.0); + + audio.dispose(); + print(''); + print(' Done!'); + print(''); +} diff --git a/example/bigtext_demo.dart b/example/bigtext_demo.dart new file mode 100644 index 0000000..0aa58da --- /dev/null +++ b/example/bigtext_demo.dart @@ -0,0 +1,222 @@ +import 'package:tui/tui.dart'; + +class BigTextDemo extends Window { + int currentPage = 0; + final pages = [ + 'Fonts', + 'Gradients', + 'Borders', + 'Showcase', + 'Animated' + ]; + late Frame mainFrame; + + BigTextDemo() { + showFps = true; + mainFrame = Frame(title: 'BigText Demo - Fonts [1/4]', color: '36'); + children = [mainFrame]; + _showPage(); + } + + void _showPage() { + mainFrame.title = + 'BigText Demo - ${pages[currentPage]} [${currentPage + 1}/${pages.length}]'; + + switch (pages[currentPage]) { + case 'Fonts': + _showFonts(); + case 'Gradients': + _showGradients(); + case 'Borders': + _showBorders(); + case 'Showcase': + _showShowcase(); + case 'Animated': + _showAnimated(); + } + } + + void _showFonts() { + var block = BigText('BLOCK', font: BigTextFont.block) + ..gradient = Gradients.cyan; + + var slim = BigText('SLIM', font: BigTextFont.slim) + ..gradient = Gradients.purple; + + var chunky = BigText('CHUNKY', font: BigTextFont.chunky) + ..gradient = Gradients.fire; + + var shadow = BigText('SHADOW', font: BigTextFont.shadow) + ..gradient = Gradients.matrix; + + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 1, 1, 1]) + ..children = [block, slim, chunky, shadow], + ]; + } + + void _showGradients() { + var rainbow = BigText('RAINBOW', font: BigTextFont.block) + ..gradient = Gradients.rainbow; + + var sunset = BigText('SUNSET', font: BigTextFont.block) + ..gradient = Gradients.sunset; + + var ocean = BigText('OCEAN', font: BigTextFont.slim) + ..gradient = Gradients.ocean; + + var matrix = BigText('MATRIX', font: BigTextFont.shadow) + ..gradient = Gradients.matrix; + + var fire = BigText('FIRE', font: BigTextFont.chunky) + ..gradient = Gradients.fire; + + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 1, 1, 1, 1]) + ..children = [rainbow, sunset, ocean, matrix, fire], + ]; + } + + void _showBorders() { + // Simple border + var simple = BigText('HELLO', font: BigTextFont.block) + ..gradient = Gradients.cyan + ..showBorder = true + ..borderColor = '36'; + + // Border with subtitle + var withSubtitle = BigText('DART', font: BigTextFont.shadow) + ..gradient = Gradients.gemini + ..showBorder = true + ..borderColor = '35' + ..subtitle = 'Terminal User Interface Library' + ..subtitleColor = '37'; + + // Another with subtitle + var welcome = BigText('WELCOME', font: BigTextFont.slim) + ..gradient = Gradients.rainbow + ..showBorder = true + ..borderColor = '33' + ..subtitle = 'Press any key to continue...' + ..subtitleColor = '8'; + + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 1, 1]) + ..children = [simple, withSubtitle, welcome], + ]; + } + + void _showShowcase() { + // Epic banner with everything + var banner = BigText('TUI', font: BigTextFont.shadow) + ..gradient = Gradients.gemini + ..showBorder = true + ..borderColor = '38;2;100;130;255' + ..subtitle = 'Beautiful Terminal Interfaces in Dart' + ..subtitleColor = '38;2;150;120;200'; + + // Matrix style + var matrix = BigText('MATRIX', font: BigTextFont.shadow) + ..gradient = Gradients.matrix + ..showBorder = true + ..borderColor = '32' + ..subtitle = 'Follow the white rabbit' + ..subtitleColor = '32'; + + // Fire warning + var warning = BigText('WARNING', font: BigTextFont.chunky) + ..gradient = Gradients.fire + ..showBorder = true + ..borderColor = '31' + ..subtitle = 'System overheating!' + ..subtitleColor = '33'; + + mainFrame.children = [ + SplitView(horizontal: false, ratios: [2, 2, 1]) + ..children = [banner, matrix, warning], + ]; + } + + void _showAnimated() { + var typewriter = AnimatedBigText( + 'HELLO', + font: BigTextFont.shadow, + gradient: Gradients.cyan, + defaultStyle: LineRevealConfig.typewriter, + lineDelay: Duration(milliseconds: 120), + ); + + var glitch = AnimatedBigText( + 'GLITCH', + font: BigTextFont.shadow, + gradient: Gradients.fire, + defaultStyle: LineRevealConfig.glitch, + lineDelay: Duration(milliseconds: 100), + ); + + var matrix = AnimatedBigText( + 'MATRIX', + font: BigTextFont.shadow, + gradient: Gradients.matrix, + defaultStyle: LineRevealConfig.matrix, + lineDelay: Duration(milliseconds: 150), + ); + + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 1, 1]) + ..children = [typewriter, glitch, matrix], + ]; + } + + @override + bool onKey(String key) { + if (key == 'q' || key == KeyCode.ESCAPE) { + stop(); + return true; + } + + if (key == KeyCode.LEFT || key == 'h') { + currentPage = (currentPage - 1) % pages.length; + if (currentPage < 0) currentPage = pages.length - 1; + _showPage(); + return true; + } + + if (key == KeyCode.RIGHT || key == 'l') { + currentPage = (currentPage + 1) % pages.length; + _showPage(); + return true; + } + + if (key == 'f' || key == 'F') { + showFps = !showFps; + return true; + } + + return super.onKey(key); + } +} + +void main() { + print('BigText Demo - ASCII Art Banners'); + print('${BoxChars.lightH * 40}'); + print(''); + print('Pages:'); + print(' 1. Fonts - block, slim, chunky, shadow'); + print(' 2. Gradients - rainbow, sunset, ocean, matrix, fire'); + print(' 3. Borders - with subtitle support'); + print(' 4. Showcase - combined features'); + print(' 5. Animated - AnimatedBigText reveal effects'); + print(''); + print('Controls:'); + print(' LEFT/RIGHT or H/L = switch pages'); + print(' F = toggle FPS'); + print(' Q/ESC = quit'); + print(''); + print('${BoxChars.lightH * 40}'); + print('Starting in 1 second...'); + + Future.delayed(Duration(seconds: 1), () { + BigTextDemo().start(); + }); +} diff --git a/example/bouncing_boxes.dart b/example/bouncing_boxes.dart index d54760f..9b02913 100644 --- a/example/bouncing_boxes.dart +++ b/example/bouncing_boxes.dart @@ -1,47 +1,47 @@ import "package:tui/tui.dart"; - class BouncingBoxes extends Window { - - Box box1, box2, box3; + late Box box1, box2, box3; BouncingBoxes() { - box1 = new Box("1", "1") - ..size=new Size(18,8) - ..position=new Position(0,0); - box2 = new Box("2", "2") - ..size=new Size(18,8) - ..position=new Position(8,2); - box3 = new Box("3", "31") - ..size=new Size(18,8) - ..position=new Position(4,4); + box1 = Box("1", "1") + ..size = Size(18, 8) + ..position = Position(0, 0); + box2 = Box("2", "2") + ..size = Size(18, 8) + ..position = Position(8, 2); + box3 = Box("3", "31") + ..size = Size(18, 8) + ..position = Position(4, 4); children.addAll([box1, box2, box3]); - box1.children.add(new CenteredText("box 1")); - + box1.children.add(CenteredText("box 1")); } - void onKey(String key) { + @override + bool onKey(String key) { switch (key) { case KeyCode.UP: - box1.position = new Position(box1.x, box1.y-1); - break; + box1.position = Position(box1.x, box1.y - 1); + return true; case KeyCode.LEFT: - box1.position = new Position(box1.x-1, box1.y); - break; + box1.position = Position(box1.x - 1, box1.y); + return true; case KeyCode.DOWN: - box1.position = new Position(box1.x, box1.y+1); - break; + box1.position = Position(box1.x, box1.y + 1); + return true; case KeyCode.RIGHT: - box1.position = new Position(box1.x+1, box1.y); - break; + box1.position = Position(box1.x + 1, box1.y); + return true; case "q": stop(); + return true; } + return false; } } -main() { - new BouncingBoxes().start(); -} \ No newline at end of file +void main() { + BouncingBoxes().start(); +} diff --git a/example/build_wizard.dart b/example/build_wizard.dart new file mode 100644 index 0000000..2accd70 --- /dev/null +++ b/example/build_wizard.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'package:tui/tui.dart'; + +/// Simple choice page +class ChoicePage extends Page { + final String title; + final List options; + int selected = 0; + + ChoicePage(this.title, this.options); + + @override + void update() { + text = [ + Text(title)..color = '33', + Text(BoxChars.lightH * 40)..position = Position(0, 1), + ]; + + for (var i = 0; i < options.length; i++) { + var prefix = selected == i ? '► ' : ' '; + var color = selected == i ? '32' : '7'; + text.add(Text('$prefix${options[i]}') + ..color = color + ..position = Position(2, 3 + i)); + } + + text.add(Text('↑↓ select, Enter continue') + ..color = '90' + ..position = Position(0, height - 1)); + } + + @override + bool onKey(String key) { + switch (key) { + case KeyCode.UP: + selected = (selected - 1 + options.length) % options.length; + return true; + case KeyCode.DOWN: + selected = (selected + 1) % options.length; + return true; + case KeyCode.ENTER: + pageView.goNext(); + return true; + } + return false; + } + + @override + dynamic getData() => options[selected]; +} + +/// Left panel showing build status (content for Frame) +class BuildStatusContent extends View { + String status = 'Waiting...'; + String statusColor = '90'; + late ProgressBar progressBar; + + BuildStatusContent() { + progressBar = ProgressBar() + ..color = '33' + ..showPercent = true; + children = [progressBar]; + } + + void setStatus(String newStatus, String color) { + status = newStatus; + statusColor = color; + } + + void setProgress(double value) { + progressBar.value = value; + } + + @override + void update() { + text = [ + Text(status)..color = statusColor, + ]; + + if (progressBar.value >= 1.0) { + text.add(Text('✓ Complete') + ..color = '32' + ..position = Position(0, 2)); + } else { + text.add(Text('${(progressBar.value * 100).toInt()}%') + ..color = '33' + ..position = Position(0, 2)); + } + } + + @override + void resizeChildren() { + progressBar.resize(Size(width, 1), Position(0, 1)); + } +} + +/// Right panel showing build log (content for Frame) +class BuildLogContent extends View { + List logs = []; + List colors = []; + + void addLog(String message, {String color = '7'}) { + logs.add(message); + colors.add(color); + if (logs.length > 50) { + logs.removeAt(0); + colors.removeAt(0); + } + } + + void clear() { + logs.clear(); + colors.clear(); + } + + @override + void update() { + text = []; + + var visibleLines = height; + var startIdx = logs.length > visibleLines ? logs.length - visibleLines : 0; + + for (var i = startIdx; i < logs.length; i++) { + var line = logs[i]; + if (line.length > width) { + line = line.substring(0, width); + } + text.add(Text(line) + ..color = colors[i] + ..position = Position(0, i - startIdx)); + } + } +} + +/// Build page with SplitView: status on left, log on right +class BuildPage extends Page { + late BuildStatusContent statusContent; + late BuildLogContent logContent; + late Frame statusFrame; + late Frame logFrame; + late SplitView splitView; + + Timer? _timer; + bool _complete = false; + int _step = 0; + + final List> _buildSteps = [ + {'msg': 'Initializing build environment...', 'duration': 800}, + {'msg': 'Checking dependencies...', 'duration': 600}, + {'msg': 'Resolving packages...', 'duration': 1200}, + {'msg': 'Compiling src/main.dart...', 'duration': 900}, + {'msg': 'Compiling src/utils.dart...', 'duration': 700}, + {'msg': 'Compiling src/models.dart...', 'duration': 800}, + {'msg': 'Compiling src/services.dart...', 'duration': 1100}, + {'msg': 'Linking objects...', 'duration': 600}, + {'msg': 'Optimizing binary...', 'duration': 1500}, + {'msg': 'Generating assets...', 'duration': 800}, + {'msg': 'Running post-build hooks...', 'duration': 500}, + {'msg': 'Finalizing build...', 'duration': 500}, + ]; + + @override + void onEnter() { + statusContent = BuildStatusContent(); + logContent = BuildLogContent(); + + statusFrame = Frame(title: 'Build Status', padding: 0); + statusFrame.children = [statusContent]; + + logFrame = Frame(title: 'Build Log', padding: 0); + logFrame.children = [logContent]; + + splitView = SplitView(horizontal: true, ratios: [1, 2]); + splitView.children = [statusFrame, logFrame]; + children = [splitView]; + + // Start build + _startBuild(); + } + + void _startBuild() { + _step = 0; + _complete = false; + logContent.clear(); + + statusContent.setStatus('Building...', '33'); + logContent.addLog('=== Build started ===', color: '36'); + logContent.addLog(''); + + _runNextStep(); + } + + void _runNextStep() { + if (_step >= _buildSteps.length) { + _finishBuild(); + return; + } + + var step = _buildSteps[_step]; + var msg = step['msg'] as String; + var duration = step['duration'] as int; + + logContent.addLog(' $msg', color: '7'); + statusContent.setProgress((_step + 0.5) / _buildSteps.length); + + _timer = Timer(Duration(milliseconds: duration), () { + logContent.addLog(' ✓ Done', color: '32'); + _step++; + statusContent.setProgress(_step / _buildSteps.length); + _runNextStep(); + }); + } + + void _finishBuild() { + logContent.addLog(''); + logContent.addLog('=== Build successful! ===', color: '32'); + logContent.addLog(''); + logContent.addLog('Press Enter to continue...', color: '36'); + + statusContent.setStatus('Success!', '32'); + statusContent.setProgress(1.0); + _complete = true; + } + + @override + void onLeave() { + _timer?.cancel(); + } + + @override + void update() { + text = [ + Text('Building Project') + ..color = '33' + ..position = Position(0, 0), + ]; + + if (!_complete) { + text.add(Text('Please wait...') + ..color = '90' + ..position = Position(0, height - 1)); + } else { + text.add(Text('Press Enter to continue') + ..color = '32' + ..position = Position(0, height - 1)); + } + } + + @override + void resizeChildren() { + splitView.resize(Size(width, height - 2), Position(0, 1)); + } + + @override + bool canProceed() => _complete; + + @override + bool onKey(String key) { + if (key == KeyCode.ENTER && _complete) { + pageView.goNext(); + return true; + } + return false; + } + + @override + dynamic getData() => {'buildTime': DateTime.now().toIso8601String()}; +} + +/// Summary page +class SummaryPage extends Page { + @override + void update() { + var target = pageView.getData(0) ?? '?'; + var buildData = pageView.getData(1) ?? {}; + + text = [ + Text(BoxChars.doubleH * 35)..color = '32', + Text(' Build Complete!') + ..color = '32' + ..position = Position(0, 1), + Text(BoxChars.doubleH * 35) + ..color = '32' + ..position = Position(0, 2), + Text('Target: $target')..position = Position(2, 4), + Text('Built at: ${buildData['buildTime'] ?? 'N/A'}') + ..color = '90' + ..position = Position(2, 5), + Text('Press q to exit') + ..color = '90' + ..position = Position(0, height - 1), + ]; + } +} + +/// Main build wizard +class BuildWizard extends Window { + late PageView pager; + late DotIndicator indicator; + + BuildWizard() { + indicator = DotIndicator(); + + pager = PageView( + indicator: indicator, + pages: [ + ChoicePage('Select Build Target', ['Debug', 'Release', 'Profile']), + BuildPage(), + SummaryPage(), + ], + ); + + var layout = SplitView(horizontal: false, ratios: [1, 15]); + layout.children = [indicator, pager]; + children = [layout]; + } + + @override + void start() { + super.start(); + pager.start(); + } + + @override + bool onKey(String key) { + if (key == 'q') { + stop(); + return true; + } + return false; + } +} + +void main() { + BuildWizard().start(); +} diff --git a/example/file_browser.dart b/example/file_browser.dart index 53ed84b..9e48c86 100644 --- a/example/file_browser.dart +++ b/example/file_browser.dart @@ -1,21 +1,21 @@ import 'dart:io'; -import 'dart:convert'; import 'package:tui/tui.dart'; import 'package:path/path.dart'; class FileNode extends TreeNode { FileNode(this.entity) { filename = basename(entity.path); - if (entity is File) - leaf = true; + if (entity is File) leaf = true; } - String filename; + late String filename; FileSystemEntity entity; bool _opened = false; + @override bool get opened => _opened; - void set opened(bool value) { + @override + set opened(bool value) { if (value && !leaf) { loadChildren(); _opened = true; @@ -25,71 +25,65 @@ class FileNode extends TreeNode { } void loadChildren() { - (entity as Directory).listSync().forEach((e) => add(new FileNode(e))); + try { + (entity as Directory).listSync().forEach((e) => add(FileNode(e))); + } catch (e) { + // Permission denied or other errors - ignore + } } - } class FileTree extends TreeModel { - FileTree() { - var path = Platform.environment['HOME']; - var dir = new Directory(path); - dir.listSync().forEach((e) => root.add(new FileNode(e))); + var path = Platform.environment['HOME']!; + var dir = Directory(path); + try { + dir.listSync().forEach((e) => root.add(FileNode(e))); + } catch (e) { + // Handle permission errors + } } - } -class FileBrowser extends TreeView { - - FileBrowser(model): super(model); +class FileBrowserView extends TreeView { + FileBrowserView(super.model); - String render_row(FileNode node) { - return ' '*node.depth + node.filename; + @override + String renderNode(covariant FileNode node) { + var prefix = node.leaf ? ' ' : (node.opened ? '▼ ' : '▶ '); + return ' ' * node.depth + prefix + node.filename; } - } -main() { - var tree = new FileTree(); +class FileBrowserWindow extends Window { + late FileBrowserView browser; - print(ANSI.ERASE_SCREEN); - - /* - var pos = 0; - var file = new File('head.txt'); - List text = file.readAsLinesSync(); - * - */ - - var treeView = new FileBrowser(tree) - ..col = 3 - ..row = 3 - ..height = 10 - ..width = 40; - treeView.render(); + FileBrowserWindow() { + var tree = FileTree(); + browser = FileBrowserView(tree); + children = [browser]; + } - stdin.echoMode = false; - stdin.lineMode = false; - stdin.transform(ASCII.decoder).listen((String key) { - switch (key) { - case KeyCode.UP: - treeView.move_up(); - break; - case KeyCode.LEFT: - treeView.move_left(); - break; - case KeyCode.DOWN: - treeView.move_down(); - break; - case KeyCode.RIGHT: - treeView.move_right(); - break; - case "q": - stdin.echoMode = true; - stdin.lineMode = true; - exit(0); - break; + @override + bool onKey(String key) { + if (key == 'q') { + stop(); + return true; } + return false; + } +} + +void main() { + print("File Browser"); + print(BoxChars.lightH * 37); + print("↑/↓ = navigate"); + print("← = collapse folder"); + print("→ = expand folder"); + print("q = quit"); + print(BoxChars.lightH * 37); + print("Starting in 2 seconds..."); + Future.delayed(Duration(seconds: 2), () { + FileBrowserWindow().start(); }); } diff --git a/example/input_demo.dart b/example/input_demo.dart new file mode 100644 index 0000000..528f6ef --- /dev/null +++ b/example/input_demo.dart @@ -0,0 +1,101 @@ +import "package:tui/tui.dart"; +import "dart:async"; + +class InputDemo extends Window { + late Input nameInput; + late Input emailInput; + late Input passwordInput; + String? submittedName; + String? submittedEmail; + + InputDemo() { + nameInput = Input(placeholder: 'Enter your name...') + ..prompt = 'Name: ' + ..promptColor = '36' + ..onSubmit = (value) => submittedName = value; + + emailInput = Input(placeholder: 'user@example.com') + ..prompt = 'Email: ' + ..promptColor = '33' + ..onSubmit = (value) => submittedEmail = value; + + passwordInput = Input(placeholder: 'Enter password...', password: true) + ..prompt = 'Password: ' + ..promptColor = '35' + ..maxLength = 20; + + var frame = Frame(title: 'Input Demo', color: '36') + ..children = [ + SplitView(horizontal: false, ratios: [1, 1, 1, 2]) + ..children = [ + nameInput, + emailInput, + passwordInput, + ResultPanel(this), + ], + ]; + + children = [frame]; + } + + @override + bool onKey(String key) { + if (key == 'q' || key == KeyCode.ESCAPE) { + stop(); + return true; + } + return super.onKey(key); + } +} + +class ResultPanel extends View { + final InputDemo demo; + + ResultPanel(this.demo); + + @override + void update() { + text = []; + if (width < 2 || height < 1) return; + + var y = 0; + text.add(Text(BoxChars.lightH * width) + ..color = '8' + ..position = Position(0, y++)); + + text.add(Text('Results:') + ..color = '1' + ..position = Position(0, y++)); + + if (demo.submittedName != null) { + text.add(Text(' Name: ${demo.submittedName}') + ..color = '2' + ..position = Position(0, y++)); + } + + if (demo.submittedEmail != null) { + text.add(Text(' Email: ${demo.submittedEmail}') + ..color = '2' + ..position = Position(0, y++)); + } + + y = height - 1; + text.add(Text('TAB=next field, ENTER=submit, q=quit') + ..color = '8' + ..position = Position(0, y)); + } +} + +void main() { + print("Input Widget Demo"); + print(BoxChars.lightH * 37); + print("TAB = next field"); + print("SHIFT+TAB = previous field"); + print("ENTER = submit current field"); + print("q/ESC = quit"); + print(BoxChars.lightH * 37); + print("Starting in 1 second..."); + Future.delayed(Duration(seconds: 1), () { + InputDemo().start(); + }); +} diff --git a/example/pong.dart b/example/pong.dart new file mode 100644 index 0000000..640d071 --- /dev/null +++ b/example/pong.dart @@ -0,0 +1,320 @@ +import 'package:tui/tui.dart'; +import 'dart:math'; + +class PongGame extends Window { + // Game constants + static const int paddleHeight = 5; + static const int paddleMargin = 2; + static const String paddleChar = BoxChars.fullBlock; + static const String ballChar = '●'; + static const String centerLineChar = '┊'; + + // Game state + int paddle1Y = 0; + int paddle2Y = 0; + double ballX = 0; + double ballY = 0; + double ballDx = 1; + double ballDy = 0.5; + int score1 = 0; + int score2 = 0; + bool gameRunning = true; + bool gameOver = false; + + // Frame counter for ball speed control + int frameCount = 0; + static const int ballUpdateInterval = 2; // Update ball every N frames + + PongGame() { + showFps = true; + } + + @override + void start() { + super.start(); + } + + void _resetGame() { + score1 = 0; + score2 = 0; + gameOver = false; + gameRunning = true; + _resetPaddles(); + _resetBall(); + } + + void _resetBall() { + ballX = width / 2; + ballY = height / 2; + + // Random initial direction + final random = Random(); + ballDx = random.nextBool() ? 1.0 : -1.0; + ballDy = (random.nextDouble() - 0.5) * 1.5; + } + + void _resetPaddles() { + paddle1Y = (height - paddleHeight) ~/ 2; + paddle2Y = (height - paddleHeight) ~/ 2; + } + + bool _initialized = false; + + @override + void resize(Size size, Position position) { + super.resize(size, position); + if (!_initialized) { + _initialized = true; + _resetPaddles(); + _resetBall(); + } + } + + @override + bool onKey(String key) { + switch (key) { + // Player 1 controls (W/S) + case 'w': + case 'W': + if (paddle1Y > 1) paddle1Y--; + return true; + case 's': + case 'S': + if (paddle1Y < height - paddleHeight - 2) paddle1Y++; + return true; + + // Player 2 controls (Arrow keys) + case KeyCode.UP: + if (paddle2Y > 1) paddle2Y--; + return true; + case KeyCode.DOWN: + if (paddle2Y < height - paddleHeight - 2) paddle2Y++; + return true; + + // Game controls + case KeyCode.SPACE: + if (gameOver) { + _resetGame(); + } else { + gameRunning = !gameRunning; + } + return true; + case 'r': + case 'R': + _resetGame(); + return true; + case 'q': + case KeyCode.ESCAPE: + stop(); + return true; + } + return false; + } + + @override + void update() { + if (!gameRunning || gameOver) return; + + frameCount++; + if (frameCount < ballUpdateInterval) return; + frameCount = 0; + + // Update ball position + ballX += ballDx; + ballY += ballDy; + + // Bounce off top/bottom walls + if (ballY <= 1) { + ballY = 1; + ballDy = -ballDy; + } + if (ballY >= height - 2) { + ballY = height - 2.0; + ballDy = -ballDy; + } + + // Check paddle collisions + final paddle1X = paddleMargin; + final paddle2X = width - paddleMargin - 1; + + // Ball hits left paddle + if (ballX <= paddle1X + 1 && ballX >= paddle1X) { + if (ballY >= paddle1Y && ballY < paddle1Y + paddleHeight) { + ballX = paddle1X + 1.0; + ballDx = -ballDx; + // Add spin based on where it hit the paddle + final hitPos = (ballY - paddle1Y) / paddleHeight; + ballDy = (hitPos - 0.5) * 2; + } + } + + // Ball hits right paddle + if (ballX >= paddle2X - 1 && ballX <= paddle2X) { + if (ballY >= paddle2Y && ballY < paddle2Y + paddleHeight) { + ballX = paddle2X - 1.0; + ballDx = -ballDx; + // Add spin based on where it hit the paddle + final hitPos = (ballY - paddle2Y) / paddleHeight; + ballDy = (hitPos - 0.5) * 2; + } + } + + // Check for scoring + if (ballX <= 0) { + score2++; + _checkWin(); + _resetBall(); + } + if (ballX >= width - 1) { + score1++; + _checkWin(); + _resetBall(); + } + } + + void _checkWin() { + if (score1 >= 11 || score2 >= 11) { + gameOver = true; + gameRunning = false; + } + } + + @override + void render(Canvas canvas) { + // Draw border + _drawBorder(canvas); + + // Draw center line + _drawCenterLine(canvas); + + // Draw score + _drawScore(canvas); + + // Draw paddles + _drawPaddle(canvas, paddleMargin, paddle1Y); + _drawPaddle(canvas, width - paddleMargin - 1, paddle2Y); + + // Draw ball + if (!gameOver) { + canvas.write(ballX.round(), ballY.round(), ballChar); + } + + // Draw status + _drawStatus(canvas); + + // Draw game over message + if (gameOver) { + _drawGameOver(canvas); + } + } + + void _drawBorder(Canvas canvas) { + // Top border + canvas.write(0, 0, BoxChars.roundedTL); + for (var x = 1; x < width - 1; x++) { + canvas.write(x, 0, BoxChars.lightH); + } + canvas.write(width - 1, 0, BoxChars.roundedTR); + + // Bottom border + canvas.write(0, height - 1, BoxChars.roundedBL); + for (var x = 1; x < width - 1; x++) { + canvas.write(x, height - 1, BoxChars.lightH); + } + canvas.write(width - 1, height - 1, BoxChars.roundedBR); + + // Side borders + for (var y = 1; y < height - 1; y++) { + canvas.write(0, y, BoxChars.lightV); + canvas.write(width - 1, y, BoxChars.lightV); + } + } + + void _drawCenterLine(Canvas canvas) { + final centerX = width ~/ 2; + for (var y = 1; y < height - 1; y++) { + if (y % 2 == 0) { + canvas.write(centerX, y, centerLineChar); + } + } + } + + void _drawScore(Canvas canvas) { + final scoreText = ' $score1 ${BoxChars.lightV} $score2 '; + final scoreX = (width - scoreText.length) ~/ 2; + for (var i = 0; i < scoreText.length; i++) { + canvas.write(scoreX + i, 0, scoreText[i]); + } + } + + void _drawPaddle(Canvas canvas, int x, int y) { + for (var i = 0; i < paddleHeight; i++) { + canvas.write(x, y + i, paddleChar); + } + } + + void _drawStatus(Canvas canvas) { + String status; + if (gameOver) { + status = ' GAME OVER - SPACE to restart '; + } else if (!gameRunning) { + status = ' PAUSED - SPACE to resume '; + } else { + status = ' W/S: P1 ${BoxChars.lightV} ↑/↓: P2 ${BoxChars.lightV} SPACE: pause ${BoxChars.lightV} R: restart ${BoxChars.lightV} Q: quit '; + } + final statusX = (width - status.length) ~/ 2; + for (var i = 0; i < status.length; i++) { + canvas.write(statusX + i, height - 1, status[i]); + } + } + + void _drawGameOver(Canvas canvas) { + final winner = score1 >= 11 ? 'Player 1' : 'Player 2'; + final message = ' $winner wins! '; + final messageX = (width - message.length) ~/ 2; + final messageY = height ~/ 2; + + // Draw message box + final boxWidth = message.length + 4; + final boxX = messageX - 2; + + canvas.write(boxX, messageY - 1, BoxChars.doubleTL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, messageY - 1, BoxChars.doubleH); + } + canvas.write(boxX + boxWidth - 1, messageY - 1, BoxChars.doubleTR); + + canvas.write(boxX, messageY, BoxChars.doubleV); + for (var i = 0; i < message.length; i++) { + canvas.write(messageX + i, messageY, message[i]); + } + canvas.write(boxX + boxWidth - 1, messageY, BoxChars.doubleV); + + canvas.write(boxX, messageY + 1, BoxChars.doubleBL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, messageY + 1, BoxChars.doubleH); + } + canvas.write(boxX + boxWidth - 1, messageY + 1, BoxChars.doubleBR); + } +} + +void main() { + print('PONG'); + print(BoxChars.lightH * 40); + print(''); + print('Controls:'); + print(' Player 1: W (up) / S (down)'); + print(' Player 2: ↑ (up) / ↓ (down)'); + print(''); + print(' SPACE: Pause/Resume'); + print(' R: Restart'); + print(' Q/ESC: Quit'); + print(''); + print('First to 11 points wins!'); + print(BoxChars.lightH * 40); + print('Starting in 2 seconds...'); + + Future.delayed(Duration(seconds: 2), () { + PongGame().start(); + }); +} diff --git a/example/split_view.dart b/example/split_view.dart new file mode 100644 index 0000000..48f9fc0 --- /dev/null +++ b/example/split_view.dart @@ -0,0 +1,316 @@ +import "package:tui/tui.dart"; +import "dart:async"; +import "dart:math"; + +class CounterPanel extends Frame { + int counter = 0; + + CounterPanel(String label) + : super(title: label, color: '8', focusColor: '36') { + focusable = true; + } + + @override + bool onKey(String key) { + switch (key) { + case KeyCode.UP: + counter++; + return true; + case KeyCode.DOWN: + counter--; + return true; + } + return false; + } + + @override + void update() { + super.update(); + + var displayText = '$counter'; + var cx = (width - displayText.length) ~/ 2; + var cy = height ~/ 2; + + var numColor = focused ? '96' : '36'; + text.add(Text(displayText) + ..color = numColor + ..position = Position(cx, cy)); + + if (focused) { + var hint = '↑/↓ to change'; + var hx = (width - hint.length) ~/ 2; + text.add(Text(hint) + ..color = '8' + ..position = Position(hx, cy + 1)); + } + } +} + +class ProgressPanel extends Frame { + List bars = []; + List speeds = []; + Random random = Random(); + Timer? timer; + bool paused = false; + + ProgressPanel() : super(title: 'PROGRESS', color: '8', focusColor: '33') { + focusable = true; + + var configs = [ + ('CPU', '31'), + ('Memory', '32'), + ('Disk', '33'), + ('Network', '34'), + ]; + + for (var (label, color) in configs) { + var bar = ProgressBar() + ..label = label.padRight(8) + ..color = color + ..value = random.nextDouble(); + bars.add(bar); + speeds.add((random.nextDouble() - 0.5) * 0.05); + } + } + + void startAnimation() { + timer = Timer.periodic(Duration(milliseconds: 100), (_) { + if (paused) return; + for (int i = 0; i < bars.length; i++) { + bars[i].value += speeds[i]; + if (bars[i].value >= 1.0 || bars[i].value <= 0.0) { + speeds[i] = -speeds[i]; + } + } + }); + } + + void stopAnimation() { + timer?.cancel(); + } + + @override + bool onKey(String key) { + if (key == KeyCode.SPACE || key == KeyCode.ENTER) { + paused = !paused; + title = paused ? 'PAUSED' : 'PROGRESS'; + return true; + } + return false; + } + + @override + void update() { + super.update(); + + children = []; + int barY = 2; + for (var bar in bars) { + if (barY < height - 2) { + bar.resize(Size(width - 4, 1), Position(2, barY)); + children.add(bar); + barY += 2; + } + } + + if (focused) { + var hint = 'SPACE = pause'; + text.add(Text(hint) + ..color = '8' + ..position = Position(2, height - 2)); + } + } +} + +class LogPanel extends Frame { + List logs = []; + int scrollOffset = 0; + Random random = Random(); + Timer? logTimer; + bool autoScroll = true; + + final List logMessages = [ + 'INFO Connected to server', + 'DEBUG Processing request #', + 'WARN High memory usage detected', + 'INFO User logged in', + 'DEBUG Cache hit ratio: ', + 'ERROR Connection timeout', + 'INFO Task completed', + 'DEBUG Garbage collection ran', + 'WARN Slow query detected', + 'INFO File saved', + 'DEBUG Heartbeat received', + 'ERROR Disk space low', + ]; + + LogPanel() : super(title: 'LOG', color: '8', focusColor: '32') { + focusable = true; + } + + void startLogging() { + logTimer = Timer.periodic(Duration(milliseconds: 400), (_) { + addLog(); + }); + } + + void stopLogging() { + logTimer?.cancel(); + } + + void addLog() { + var msg = logMessages[random.nextInt(logMessages.length)]; + var timestamp = DateTime.now().toString().substring(11, 19); + if (msg.contains('#')) { + msg = msg.replaceFirst('#', '${random.nextInt(9999)}'); + } else if (msg.contains(': ')) { + msg = '$msg${random.nextInt(100)}%'; + } + logs.add('[$timestamp] $msg'); + if (autoScroll && logs.length > height - 2) { + scrollOffset = logs.length - (height - 2); + } + } + + @override + bool onKey(String key) { + int visibleLines = height - 2; + int maxScroll = max(0, logs.length - visibleLines); + + switch (key) { + case KeyCode.UP: + if (scrollOffset > 0) { + scrollOffset--; + autoScroll = false; + } + return true; + case KeyCode.DOWN: + if (scrollOffset < maxScroll) { + scrollOffset++; + } + if (scrollOffset >= maxScroll) { + autoScroll = true; + } + return true; + case KeyCode.HOME: + scrollOffset = 0; + autoScroll = false; + return true; + case KeyCode.END: + scrollOffset = maxScroll; + autoScroll = true; + return true; + } + return false; + } + + @override + void update() { + title = autoScroll ? 'LOG' : 'LOG (scroll)'; + super.update(); + + int visibleLines = height - 2; + int start = scrollOffset; + int end = min(logs.length, scrollOffset + visibleLines); + + for (int i = start; i < end; i++) { + var line = logs[i]; + if (line.length > width - 2) { + line = line.substring(0, width - 2); + } + var t = Text(line)..position = Position(1, 1 + (i - start)); + if (line.contains('ERROR')) { + t.color = '91'; + } else if (line.contains('WARN')) { + t.color = '93'; + } else if (line.contains('DEBUG')) { + t.color = '90'; + } else { + t.color = '92'; + } + text.add(t); + } + + if (focused) { + var hint = '↑/↓ scroll'; + text.add(Text(hint) + ..color = '8' + ..position = Position(width - hint.length - 2, height - 2)); + } + } +} + +class SplitDemo extends Window { + late SplitView mainSplit; + late SplitView leftSplit; + late LogPanel logPanel; + late ProgressPanel progressPanel; + bool horizontalMain = true; + + SplitDemo() { + showFps = true; + + logPanel = LogPanel(); + progressPanel = ProgressPanel(); + + leftSplit = SplitView(horizontal: false) + ..children = [ + progressPanel, + CounterPanel('COUNTER'), + ]; + + mainSplit = SplitView(horizontal: true, ratios: [2, 1]) + ..children = [ + leftSplit, + logPanel, + ]; + + children = [mainSplit]; + } + + @override + void start() { + super.start(); + logPanel.startLogging(); + progressPanel.startAnimation(); + } + + @override + void stop() { + logPanel.stopLogging(); + progressPanel.stopAnimation(); + super.stop(); + } + + @override + bool onKey(String key) { + switch (key) { + case ' ': + horizontalMain = !horizontalMain; + mainSplit.horizontal = horizontalMain; + return true; + case 'f': + case 'F': + showFps = !showFps; + return true; + case 'q': + stop(); + return true; + } + return false; + } +} + +void main() { + print('SplitView Demo'); + print(BoxChars.lightH * 37); + print('TAB = cycle focus between panels'); + print('SPACE = toggle layout / pause progress'); + print('F = toggle FPS'); + print('q = quit'); + print(BoxChars.lightH * 37); + print('Starting in 2 seconds...'); + Future.delayed(Duration(seconds: 2), () { + SplitDemo().start(); + }); +} diff --git a/example/text_reader.dart b/example/text_reader.dart index 99cf7fc..92fcde8 100644 --- a/example/text_reader.dart +++ b/example/text_reader.dart @@ -1,30 +1,29 @@ import "package:tui/tui.dart"; import "dart:io"; -import "dart:async"; -import "dart:convert"; class Scrollable extends View { - Scrollable(List text) { - this.text = text.map((l)=>new Text(l)).toList(); + this.text = text.map((l) => Text(l)).toList(); } - } class FileReader extends Window { - - Scrollable scrollable; + late Scrollable scrollable; FileReader() { - scrollable = new Scrollable(["No file loaded."]); + scrollable = Scrollable(["No file loaded."]); children = [scrollable]; - new File('file_browser.dart').readAsLines().then((text) { + File('file_browser.dart').readAsLines().then((text) { int i = 1; - scrollable.text = text.map((l)=>new Text(l)..position=new Position(0,i++)).toList().sublist(0,12); + scrollable.text = text + .map((l) => Text(l)..position = Position(0, i++)) + .toList() + .sublist(0, 12); }); } - void onKey(String key) { + @override + bool onKey(String key) { switch (key) { case KeyCode.UP: break; @@ -36,10 +35,12 @@ class FileReader extends Window { break; case "q": stop(); + return true; } + return false; } } -main() { - new FileReader().start(); -} \ No newline at end of file +void main() { + FileReader().start(); +} diff --git a/example/typing_race.dart b/example/typing_race.dart new file mode 100644 index 0000000..20fb754 --- /dev/null +++ b/example/typing_race.dart @@ -0,0 +1,310 @@ +import 'package:tui/tui.dart'; +import 'dart:math'; + +class FallingWord { + String word; + int x; + double y; + double speed; + bool completed = false; + + FallingWord(this.word, this.x, this.y, this.speed); +} + +class TypingRace extends Window { + static const List wordList = [ + 'dart', 'flutter', 'code', 'type', 'fast', 'game', 'word', 'race', + 'speed', 'quick', 'jump', 'run', 'play', 'win', 'score', 'level', + 'terminal', 'console', 'keyboard', 'screen', 'pixel', 'render', + 'loop', 'frame', 'input', 'output', 'string', 'list', 'map', 'set', + 'class', 'function', 'method', 'widget', 'canvas', 'draw', 'write', + 'async', 'await', 'stream', 'future', 'null', 'void', 'bool', 'int', + ]; + + List words = []; + String currentInput = ''; + int score = 0; + int lives = 5; + int level = 1; + bool gameRunning = false; + bool gameOver = false; + + int frameCount = 0; + int spawnInterval = 60; + int wordsFallen = 0; + + final Random random = Random(); + bool _initialized = false; + + TypingRace() { + showFps = true; + } + + @override + void resize(Size size, Position position) { + super.resize(size, position); + if (!_initialized) { + _initialized = true; + _initGame(); + } + } + + void _initGame() { + words.clear(); + currentInput = ''; + score = 0; + lives = 5; + level = 1; + wordsFallen = 0; + spawnInterval = 60; + gameOver = false; + gameRunning = false; + } + + void _spawnWord() { + final word = wordList[random.nextInt(wordList.length)]; + final x = 2 + random.nextInt(width - word.length - 4); + final speed = 0.1 + level * 0.03 + random.nextDouble() * 0.05; + words.add(FallingWord(word, x, 2, speed)); + } + + @override + bool onKey(String key) { + if (gameOver) { + if (key == KeyCode.SPACE || key == 'r' || key == 'R') { + _initGame(); + return true; + } + } + + switch (key) { + case KeyCode.SPACE: + if (!gameRunning) { + gameRunning = true; + } + return true; + case KeyCode.ESCAPE: + if (gameRunning) { + gameRunning = false; // pause + } else { + stop(); + } + return true; + case KeyCode.BACKSPACE: + if (currentInput.isNotEmpty) { + currentInput = currentInput.substring(0, currentInput.length - 1); + } + return true; + case KeyCode.ENTER: + _checkWord(); + return true; + default: + // Add typed character + if (key.length == 1 && gameRunning) { + final code = key.codeUnitAt(0); + if (code >= 32 && code < 127) { + currentInput += key.toLowerCase(); + _checkPartialMatch(); + } + } + return true; + } + } + + void _checkPartialMatch() { + // Auto-complete if exact match + for (var word in words) { + if (!word.completed && word.word == currentInput) { + word.completed = true; + score += word.word.length * 10 * level; + currentInput = ''; + wordsFallen++; + _checkLevelUp(); + return; + } + } + } + + void _checkWord() { + for (var word in words) { + if (!word.completed && word.word == currentInput) { + word.completed = true; + score += word.word.length * 10 * level; + wordsFallen++; + _checkLevelUp(); + break; + } + } + currentInput = ''; + } + + void _checkLevelUp() { + if (wordsFallen >= level * 10) { + level++; + spawnInterval = (60 / (1 + level * 0.2)).round().clamp(20, 60); + } + } + + @override + void update() { + if (!gameRunning || gameOver) return; + + frameCount++; + + // Spawn new words + if (frameCount >= spawnInterval) { + frameCount = 0; + if (words.where((w) => !w.completed).length < 5 + level) { + _spawnWord(); + } + } + + // Update word positions + for (var word in words) { + if (!word.completed) { + word.y += word.speed; + + // Word reached bottom + if (word.y >= height - 4) { + word.completed = true; + lives--; + if (lives <= 0) { + gameOver = true; + gameRunning = false; + } + } + } + } + + // Remove old completed words + words.removeWhere((w) => w.completed && w.y >= height - 4); + } + + @override + void render(Canvas canvas) { + _drawBorder(canvas); + _drawWords(canvas); + _drawInput(canvas); + _drawHUD(canvas); + + if (!gameRunning && !gameOver) { + _drawMessage(canvas, 'SPACE to start typing!'); + } + if (gameOver) { + _drawMessage(canvas, 'GAME OVER! Score: $score - SPACE to restart'); + } + } + + void _drawBorder(Canvas canvas) { + for (var x = 0; x < width; x++) { + canvas.write(x, 0, BoxChars.lightH); + canvas.write(x, height - 1, BoxChars.lightH); + canvas.write(x, height - 3, BoxChars.lightH); + } + for (var y = 0; y < height; y++) { + canvas.write(0, y, BoxChars.lightV); + canvas.write(width - 1, y, BoxChars.lightV); + } + canvas.write(0, 0, BoxChars.roundedTL); + canvas.write(width - 1, 0, BoxChars.roundedTR); + canvas.write(0, height - 1, BoxChars.roundedBL); + canvas.write(width - 1, height - 1, BoxChars.roundedBR); + canvas.write(0, height - 3, BoxChars.lightTeeR); + canvas.write(width - 1, height - 3, BoxChars.lightTeeL); + } + + void _drawWords(Canvas canvas) { + for (var word in words) { + if (word.completed) continue; + + final y = word.y.round(); + if (y < 1 || y >= height - 3) continue; + + // Highlight matching prefix + for (var i = 0; i < word.word.length; i++) { + final char = word.word[i]; + final isMatched = i < currentInput.length && + currentInput[i] == char; + + if (isMatched) { + // Draw matched chars brighter + canvas.write(word.x + i, y, char.toUpperCase()); + } else { + canvas.write(word.x + i, y, char); + } + } + } + } + + void _drawInput(Canvas canvas) { + final prompt = '> '; + final inputY = height - 2; + + canvas.write(2, inputY, prompt); + for (var i = 0; i < currentInput.length && i < width - 6; i++) { + canvas.write(4 + i, inputY, currentInput[i]); + } + + // Cursor + final cursorX = 4 + currentInput.length; + if (cursorX < width - 2 && gameRunning) { + canvas.write(cursorX, inputY, '_'); + } + } + + void _drawHUD(Canvas canvas) { + final hud = ' Score: $score ${BoxChars.lightV} Lives: ${'♥' * lives} ${BoxChars.lightV} Level: $level '; + final hudX = (width - hud.length) ~/ 2; + for (var i = 0; i < hud.length; i++) { + canvas.write(hudX + i, 0, hud[i]); + } + } + + void _drawMessage(Canvas canvas, String message) { + final msgX = (width - message.length) ~/ 2; + final msgY = height ~/ 2; + + final boxWidth = message.length + 4; + final boxX = msgX - 2; + + canvas.write(boxX, msgY - 1, BoxChars.roundedTL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, msgY - 1, BoxChars.lightH); + } + canvas.write(boxX + boxWidth - 1, msgY - 1, BoxChars.roundedTR); + + canvas.write(boxX, msgY, BoxChars.lightV); + canvas.write(boxX + 1, msgY, ' '); + for (var i = 0; i < message.length; i++) { + canvas.write(msgX + i, msgY, message[i]); + } + canvas.write(boxX + boxWidth - 2, msgY, ' '); + canvas.write(boxX + boxWidth - 1, msgY, BoxChars.lightV); + + canvas.write(boxX, msgY + 1, BoxChars.roundedBL); + for (var i = 1; i < boxWidth - 1; i++) { + canvas.write(boxX + i, msgY + 1, BoxChars.lightH); + } + canvas.write(boxX + boxWidth - 1, msgY + 1, BoxChars.roundedBR); + } +} + +void main() { + print('TYPING RACE'); + print(BoxChars.lightH * 40); + print(''); + print('Type the falling words before they'); + print('reach the bottom!'); + print(''); + print('Controls:'); + print(' Type words as they fall'); + print(' SPACE : Start / Restart'); + print(' ESC : Pause / Quit'); + print(''); + print(BoxChars.lightH * 40); + print('Starting in 2 seconds...'); + + Future.delayed(Duration(seconds: 2), () { + TypingRace().start(); + }); +} diff --git a/example/vivaldi_four_seasons.dart b/example/vivaldi_four_seasons.dart new file mode 100644 index 0000000..d2d6df8 --- /dev/null +++ b/example/vivaldi_four_seasons.dart @@ -0,0 +1,475 @@ +import 'package:tui/tui.dart'; +import 'dart:typed_data'; + +Future _wait(double s) => + Future.delayed(Duration(milliseconds: (s * 1000).round())); + +// -- Instrument Envelopes (ADSR) ------------------------------------ + +const _violinEnv = Envelope( + attack: 0.02, + decay: 0.08, + sustain: 0.65, + release: 0.06, +); + +const _violaEnv = Envelope( + attack: 0.03, + decay: 0.1, + sustain: 0.5, + release: 0.08, +); + +const _celloEnv = Envelope( + attack: 0.05, + decay: 0.12, + sustain: 0.55, + release: 0.1, +); + +const _tremoloEnv = Envelope( + attack: 0.002, + decay: 0.02, + sustain: 0.5, + release: 0.02, +); + +const _staccatoEnv = Envelope( + attack: 0.003, + decay: 0.06, + sustain: 0.0, + release: 0.01, +); + +// -- Helpers -------------------------------------------------------- + +Float64List _scale(Float64List buf, double factor) { + final out = Float64List(buf.length); + for (var i = 0; i < buf.length; i++) { + out[i] = (buf[i] * factor).clamp(-1.0, 1.0); + } + return out; +} + +/// Render a DSL voice with timbre, vibrato, and optional tempo map. +Float64List _voice( + String dsl, { + int bpm = 120, + Envelope env = _violinEnv, + double vol = 0.5, + Vibrato? vibrato, + Timbre? timbre, + TempoMap? tempoMap, +}) { + final m = Melody.parse(dsl, bpm: bpm, vibrato: vibrato, timbre: timbre); + m.envelope = env; + m.tempoMap = tempoMap; + return _scale(m.toSamples(), vol); +} + +/// Mix mono voices into stereo with panning + church reverb. +StereoSamples _mixOrchestra(List<(Float64List, double)> voices) { + final stereo = StereoSamples.mix( + voices.map((v) => StereoSamples.fromMono(v.$1, pan: v.$2)).toList(), + ); + return Reverb.church.applyStereo(stereo); +} + +/// Play stereo samples. +Future _play(StereoSamples samples) async { + await AudioPlayer().playWav(WavWriter.encodeStereo(samples)); +} + +// === SPRING -- La Primavera, Allegro, E major ====================== + +StereoSamples _spring() { + const bpm = 140; + + // Violin I: birdsong trills + grace notes + final v1Melody = Melody.parse(''' + E5.e@f E5.e@f E5.q@f E5.e@mf Ds5.s E5.s E5.q | + E5.e E5.e E5.q~tr E5.e Ds5.s E5.s E5.q | + Fs5.e@f Fs5.e Fs5.q~tr Fs5.e E5.s Fs5.s Fs5.q | + Gs5.e@ff Gs5.e Gs5.q Gs5.e Fs5.s Gs5.s Gs5.q~mr | + A5.e@ff Gs5.e Fs5.e E5.e Fs5.e Ds5.e E5.q@mf | + E5.dh@mp R.q + ''', bpm: bpm, vibrato: Vibrato.violin, timbre: Timbre.violin); + v1Melody.envelope = _violinEnv; + // Add grace note birdsong at the start + final springBird = + Melody(bpm: bpm, vibrato: Vibrato.violin, timbre: Timbre.violin); + springBird.envelope = _violinEnv; + springBird.graceNote(Note.Ds5, Note.E5, Dur.quarter); + final v1 = _scale(v1Melody.toSamples(), 0.55); + + final v2 = _voice(''' + Gs4.e Gs4.e Gs4.q Gs4.e Fs4.s Gs4.s Gs4.q | + Gs4.e Gs4.e Gs4.q Gs4.e Fs4.s Gs4.s Gs4.q | + A4.e A4.e A4.q A4.e Gs4.s A4.s A4.q | + B4.e B4.e B4.q B4.e A4.s B4.s B4.q | + Cs5.e B4.e A4.e Gs4.e A4.e Fs4.e Gs4.q | + Gs4.dh R.q + ''', + bpm: bpm, + env: _violaEnv, + vol: 0.35, + vibrato: Vibrato.viola, + timbre: Timbre.viola); + + final bass = _voice(''' + E3.q@f B3.q E3.q B3.q | + E3.q B3.q E3.q B3.q | + Fs3.q Cs4.q Fs3.q Cs4.q | + E3.q B3.q E3.q B3.q | + A3.q E3.q Fs3.q B2.q | + E3.dh@mp R.q + ''', + bpm: bpm, + env: _celloEnv, + vol: 0.3, + vibrato: Vibrato.cello, + timbre: Timbre.cello); + + // Stereo: Violin I left, Violin II right, Cello center + return _mixOrchestra([(v1, -0.4), (v2, 0.35), (bass, 0.0)]); +} + +// === SUMMER -- L'Estate, Presto, G minor =========================== + +StereoSamples _summer() { + const bpm = 168; + const stormVib = Vibrato(rate: 7.0, depth: 15.0, delay: 0.0); + + final storm = _voice(''' + G5.s@ff G5.s G5.s G5.s G5.s G5.s G5.s G5.s | + G5.s G5.s G5.s G5.s G5.s G5.s G5.s G5.s | + G5.s G5.s G5.s G5.s G5.s G5.s G5.s G5.s | + G5.s G5.s G5.s G5.s G5.s G5.s G5.s G5.s | + Fs5.s G5.s A5.s As5.s A5.s G5.s Fs5.s G5.s | + D5.s D5.s D5.s D5.s Ds5.s E5.s F5.s Fs5.s | + G5.s A5.s As5.s A5.s G5.s Fs5.s G5.s D5.s | + Fs5.s G5.s A5.s G5.s Fs5.s E5.s D5.s Ds5.s | + G5.e Fs5.e G5.e D5.e Ds5.e C5.e D5.q@mf | + G4.w@mp + ''', + bpm: bpm, + env: _tremoloEnv, + vol: 0.5, + vibrato: stormVib, + timbre: Timbre.violin); + + // Drive: staccato articulation via DSL :st suffix + final drive = _voice(''' + G4.e@f:st D5.e:st G4.e:st D5.e:st G4.e:st D5.e:st G4.e:st D5.e:st | + G4.e:st D5.e:st G4.e:st D5.e:st G4.e:st D5.e:st G4.e:st D5.e:st | + G4.e:sp As4.e:sp G4.e:sp As4.e:sp G4.e:sp As4.e:sp G4.e:sp As4.e:sp | + G4.e:sp As4.e:sp G4.e:sp As4.e:sp G4.e:sp As4.e:sp G4.e:sp As4.e:sp | + G4.e:st D4.e:st As3.e:st D4.e:st G3.q R.q | + G3.w@p + ''', bpm: bpm, vol: 0.3, timbre: Timbre.viola); + + final thunder = _voice(''' + G2.h@ff G2.h | G2.h D3.h | G2.h D3.h | + G2.h D3.h | G2.q D3.q G2.q R.q | G2.w@mp + ''', + bpm: bpm, + env: _celloEnv, + vol: 0.35, + vibrato: Vibrato.cello, + timbre: Timbre.cello); + + return _mixOrchestra([(storm, -0.3), (drive, 0.4), (thunder, 0.0)]); +} + +// === AUTUMN -- L'Autunno, Allegro, F major ========================= + +StereoSamples _autumn() { + const bpm = 132; + + // Dance: legato ties for smooth phrase connections + final dance = _voice(''' + F5.q@f F5.e E5.s F5.s G5.q@ff_ A5.q | + G5.e_ F5.e_ E5.e_ F5.e D5.h@mf | + F5.q@f F5.e E5.s F5.s G5.q@ff_ A5.q | + As5.e_ A5.e_ G5.e_ F5.e E5.e_ F5.e G5.q~mr | + A5.e@f_ G5.e_ F5.e_ E5.e D5.e_ E5.e F5.q@mf:tn | + F5.w@mp + ''', + bpm: bpm, + env: _violinEnv, + vol: 0.5, + vibrato: Vibrato.violin, + timbre: Timbre.violin); + + final accomp = _voice(''' + F4.q A4.q C5.q A4.q | As4.q F4.q As4.q F4.q | + F4.q A4.q C5.q A4.q | F4.q A4.q C5.q A4.q | + F4.q A4.q D4.q G4.q | F4.w + ''', + bpm: bpm, + env: _violaEnv, + vol: 0.3, + vibrato: Vibrato.viola, + timbre: Timbre.viola); + + // Ritardando in last 2 bars + final bassTempoMap = TempoMap(bpm, [ + TempoChange(16.0, bpm), + TempoChange(20.0, 100), // gentle rit. + ]); + + final bass = _voice(''' + F3.q@f C3.q F3.q C3.q | As2.q F3.q As2.q F3.q | + F3.q C3.q F3.q C3.q | F3.q C3.q F3.q C3.q | + F3.q C3.q D3.q G3.q | F3.w@mp + ''', + bpm: bpm, + env: _celloEnv, + vol: 0.35, + vibrato: Vibrato.cello, + timbre: Timbre.cello, + tempoMap: bassTempoMap); + + return _mixOrchestra([(dance, -0.35), (accomp, 0.3), (bass, 0.0)]); +} + +// === WINTER -- L'Inverno, Allegro non molto, F minor =============== + +StereoSamples _winter() { + const bpm = 120; + + // Tempo map: rit. at chromatic descent, a tempo, final rit. + final winterTempo = TempoMap(bpm, [ + TempoChange(8.0, 110), // slightly slower for descent + TempoChange(16.0, 120), // back to tempo + TempoChange(22.0, 90), // ritardando to end + TempoChange(24.0, 70), // molto rit. + ]); + + final shiver = Melody( + bpm: bpm, + vibrato: const Vibrato(rate: 8.0, depth: 12.0, delay: 0.0), + timbre: Timbre.violin, + tempoMap: winterTempo, + ); + shiver.envelope = _tremoloEnv; + + // Bars 1-2: shivering tremolo (ff) + for (var i = 0; i < 32; i++) { + shiver.note(Note.F5, Dur.sixteenth, volume: 0.9); + } + + // Bars 3-4: chromatic descent (decrescendo) + final descent = [ + Note.F5, + Note.E5, + Note.Ds5, + Note.D5, + Note.C5, + Note.B4, + Note.As4, + Note.Gs4, + ]; + for (var i = 0; i < descent.length; i++) { + shiver.note(descent[i], Dur.quarter, volume: 0.9 - (i * 0.07)); + } + + // Bars 5: alternating tremolo + for (var i = 0; i < 16; i++) { + shiver.note(i.isEven ? Note.Gs4 : Note.F4, Dur.sixteenth, volume: 0.6); + } + + // Bar 6: dark resolution (pp, molto rit.) + shiver.note(Note.F4, Dur.whole, volume: 0.35); + + final v1 = _scale(shiver.toSamples(), 0.45); + + final pedal = _voice(''' + C5.s C5.s C5.s C5.s C5.s C5.s C5.s C5.s + C5.s C5.s C5.s C5.s C5.s C5.s C5.s C5.s | + C5.s C5.s C5.s C5.s C5.s C5.s C5.s C5.s + C5.s C5.s C5.s C5.s C5.s C5.s C5.s C5.s | + Gs4.q Gs4.q Gs4.q Gs4.q | + Gs4.q Gs4.q Gs4.q Gs4.q | + E4.s E4.s E4.s E4.s E4.s E4.s E4.s E4.s + E4.s E4.s E4.s E4.s E4.s E4.s E4.s E4.s | + C4.w@pp + ''', + bpm: bpm, + env: _tremoloEnv, + vol: 0.3, + vibrato: const Vibrato(rate: 7.0, depth: 10.0, delay: 0.0), + timbre: Timbre.viola); + + final bass = _voice(''' + F2.h@f F2.h | F2.h F2.h | + C3.h Gs2.h | C3.h Gs2.h | + Gs2.h F2.h | F2.w@pp + ''', + bpm: bpm, + env: _celloEnv, + vol: 0.4, + vibrato: Vibrato.cello, + timbre: Timbre.cello); + + // Wide stereo: shivering left, pedal right, bass center + return _mixOrchestra([(v1, -0.5), (pedal, 0.45), (bass, 0.0)]); +} + +// === MAIN ========================================================== + +void main() async { + final player = AudioPlayer(); + if (!player.isAvailable) { + print('Audio player not available on this platform.'); + return; + } + + print(''); + print(' +=========================================================+'); + print(' | Antonio Vivaldi -- Le Quattro Stagioni (1725) |'); + print(' | Synthesized with TUI Audio Engine |'); + print(' | |'); + print(' | Timbre: additive synthesis (8-harmonic profiles) |'); + print(' | Vibrato: pitch LFO per instrument preset |'); + print(' | Dynamics: per-note pp/mf/ff via DSL @-suffix |'); + print(' | Stereo: constant-power panning per voice |'); + print(' | Reverb: Schroeder church reverb (4 comb + 2 allpass) |'); + print(' | Tempo: TempoMap with ritardando (Winter) |'); + print(' | Trills: birdsong ornaments ~tr ~mr (Spring) |'); + print(' | Articulation: :st :sp :tn DSL (Summer drive) |'); + print(' | Legato: tied notes _ suffix (Autumn dance) |'); + print(' | Polyphony: 3-voice stereo mix per season |'); + print(' +=========================================================+'); + print(''); + + // -- Orchestra Tuning --------------------------------------------- + print(' * Orchestra tuning -- A4 = 440 Hz'); + final tuningMono = Synthesizer.tone( + frequency: Note.A4, + duration: 1.5, + volume: 0.5, + envelope: const Envelope( + attack: 0.1, + decay: 0.2, + sustain: 0.6, + release: 0.4, + ), + vibrato: Vibrato.violin, + timbre: Timbre.violin, + ); + final tuningStereo = StereoSamples.fromMono(tuningMono, pan: 0.0); + await _play(Reverb.church.applyStereo(tuningStereo)); + await _wait(0.3); + + // -- Opening Chord ------------------------------------------------ + print(' * E major chord -- strings enter'); + final chordFreqs = [Note.E3, Note.B3, Note.E4, Note.Gs4, Note.B4, Note.E5]; + final chordMono = Synthesizer.mix( + chordFreqs + .map((f) => Synthesizer.tone( + frequency: f, + duration: 2.5, + volume: 0.12, + envelope: const Envelope( + attack: 0.15, + decay: 0.3, + sustain: 0.4, + release: 0.5, + ), + vibrato: Vibrato.violin, + timbre: Timbre.violin, + )) + .toList(), + ); + final chordStereo = StereoSamples.fromMono(chordMono, pan: 0.0); + await _play(Reverb.church.applyStereo(chordStereo)); + await _wait(0.5); + + // -- The Four Seasons --------------------------------------------- + final seasons = <(String, String, StereoSamples Function())>[ + ('I. La Primavera', 'Allegro, E major -- birdsong & joy', _spring), + ("II. L'Estate", 'Presto, G minor -- the summer storm', _summer), + ("III. L'Autunno", 'Allegro, F major -- harvest dance', _autumn), + ("IV. L'Inverno", 'Allegro non molto, F minor -- shivering', _winter), + ]; + + for (final (name, desc, build) in seasons) { + print(''); + print(' -- $name --'); + print(' $desc'); + + final stereo = build(); + final wav = WavWriter.encodeStereo(stereo); + final durSec = stereo.length / 44100; + + print(' ${durSec.toStringAsFixed(1)}s | ' + '${stereo.length} frames | ' + 'stereo | ' + '${(wav.length / 1024).toStringAsFixed(0)} KB WAV'); + print(' Playing...'); + + await player.playWav(wav); + await _wait(0.4); + } + + // -- Finale ------------------------------------------------------- + print(''); + print(' * Finale -- F major resolution'); + final finaleFreqs = [ + Note.F2, + Note.C3, + Note.F3, + Note.A3, + Note.C4, + Note.F4, + Note.A4, + Note.C5, + ]; + final finaleMono = Synthesizer.mix( + finaleFreqs + .map((f) => Synthesizer.tone( + frequency: f, + duration: 3.0, + volume: 0.1, + envelope: const Envelope( + attack: 0.2, + decay: 0.4, + sustain: 0.5, + release: 0.8, + ), + vibrato: const Vibrato(rate: 4.5, depth: 15.0, delay: 0.3), + timbre: Timbre.cello, + )) + .toList(), + ); + final finaleStereo = StereoSamples.fromMono(finaleMono, pan: 0.0); + await _play(Reverb.church.applyStereo(finaleStereo)); + + _printFeatureReport(); +} + +void _printFeatureReport() { + print(''); + print(' +=========================================================+'); + print(' | Features USED in this performance: |'); + print(' +=========================================================+'); + print(' | [x] Timbre -- additive synthesis (violin/viola/cello) |'); + print(' | [x] Vibrato -- pitch LFO per instrument preset |'); + print(' | [x] Per-note dynamics -- @ff/@mf/@pp in DSL + builder |'); + print(' | [x] Stereo panning -- constant-power per voice |'); + print(' | [x] Reverb -- Schroeder church (4 comb + 2 allpass) |'); + print(' | [x] Tempo map -- ritardando in Winter chromatic desc. |'); + print(' | [x] Melody.parse() DSL -- all four seasons |'); + print(' | [x] Melody builder (programmatic) -- Winter shivering |'); + print(' | [x] Synthesizer.mix() -- 3-voice polyphony per season |'); + print(' | [x] WavWriter.encodeStereo() -- stereo 16-bit PCM WAV |'); + print(' | [x] 5 ADSR Envelopes: violin/viola/cello/trem/stacc |'); + print(' | [x] Trills/ornaments -- ~tr, ~mr, ~tn + graceNote() |'); + print(' | [x] Legato/ties -- _ suffix, smooth phrase envelopes |'); + print(' | [x] Articulation DSL -- :st/:sp/:pz/:tn per-note |'); + print(' +=========================================================+'); + print(''); + print(' Fine.'); + print(''); +} diff --git a/example/widgets_demo.dart b/example/widgets_demo.dart new file mode 100644 index 0000000..e7f10d3 --- /dev/null +++ b/example/widgets_demo.dart @@ -0,0 +1,319 @@ +import "package:tui/tui.dart"; +import "dart:async"; + +class WidgetsDemo extends Window { + late Select demoSelect; + late Frame mainFrame; + late BigText introBanner; + View? currentDemo; + + final demos = [ + 'Input - single line text', + 'TextArea - multi-line text', + 'Select - list selection', + 'Confirm - yes/no dialog', + 'Spinner - loading indicator', + 'Table - tabular data', + 'BigText - ASCII art banners', + ]; + + WidgetsDemo() { + introBanner = BigText('TUI', font: BigTextFont.block) + ..gradient = Gradients.gemini; + + demoSelect = Select( + options: demos, + header: 'Choose a demo:', + onSelect: _showDemo, + ); + + mainFrame = Frame(title: 'Dart TUI Widgets', color: '36') + ..children = [ + SplitView(horizontal: false, ratios: [1, 3]) + ..children = [introBanner, demoSelect], + ]; + + children = [mainFrame]; + } + + void _showDemo(String demo) { + _inDemo = true; + switch (demo) { + case 'Input - single line text': + _showInputDemo(); + case 'TextArea - multi-line text': + _showTextAreaDemo(); + case 'Select - list selection': + _showSelectDemo(); + case 'Confirm - yes/no dialog': + _showConfirmDemo(); + case 'Spinner - loading indicator': + _showSpinnerDemo(); + case 'Table - tabular data': + _showTableDemo(); + case 'BigText - ASCII art banners': + _showBigTextDemo(); + } + } + + void _showInputDemo() { + var input = Input(placeholder: 'Type something...') + ..prompt = 'Input: ' + ..onSubmit = (value) { + _backToMenu('You entered: $value'); + }; + + var frame = Frame(title: 'Input Demo (ENTER to submit, ESC to back)', color: '33') + ..children = [input]; + + mainFrame.children = [frame]; + focusFirst(); + } + + void _showTextAreaDemo() { + var textarea = TextArea(placeholder: 'Type multiple lines...') + ..showLineNumbers = true + ..onSubmit = (value) { + _backToMenu('Lines: ${value.split('\n').length}'); + }; + + var frame = Frame(title: 'TextArea Demo (Ctrl+D to submit, ESC to back)', color: '33') + ..children = [textarea]; + + mainFrame.children = [frame]; + focusFirst(); + } + + void _showSelectDemo() { + var select = Select( + options: ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'], + header: 'Pick a fruit:', + multiSelect: true, + onSelectMultiple: (values) { + _backToMenu('Selected: ${values.join(', ')}'); + }, + ); + + var frame = Frame(title: 'Select Demo (SPACE to toggle, ENTER to confirm)', color: '33') + ..children = [select]; + + mainFrame.children = [frame]; + focusFirst(); + } + + void _showConfirmDemo() { + var confirm = Confirm( + message: 'Do you like this demo?', + onConfirm: (result) { + _backToMenu(result ? 'Thanks!' : 'Sorry to hear that!'); + }, + ); + + var frame = Frame(title: 'Confirm Demo (LEFT/RIGHT to choose, ENTER to confirm)', color: '33') + ..children = [confirm]; + + mainFrame.children = [frame]; + focusFirst(); + } + + void _showSpinnerDemo() { + var spinner = Spinner(label: 'Processing...', style: SpinnerStyle.dots) + ..start(); + + var spinnerFrame = Frame(title: 'Spinner Demo (wait 3 seconds or ESC to back)', color: '33') + ..children = [ + SplitView(horizontal: false, ratios: [1, 2]) + ..children = [ + spinner, + _SpinnerShowcase(), + ], + ]; + + mainFrame.children = [spinnerFrame]; + + // Auto return after 3 seconds + Timer(Duration(seconds: 3), () { + spinner.stop(); + _backToMenu('Spinner completed!'); + }); + } + + void _showTableDemo() { + var table = Table>( + columns: [ + TableColumn('Name', width: 15), + TableColumn('Role', width: 12), + TableColumn('Status', width: 10), + ], + rows: [ + ['Alice', 'Developer', 'Active'], + ['Bob', 'Designer', 'Active'], + ['Charlie', 'Manager', 'Away'], + ['Diana', 'Tester', 'Active'], + ['Eve', 'DevOps', 'Busy'], + ], + rowBuilder: (row) => row, + border: TableBorder.rounded, + onSelect: (row) { + _backToMenu('Selected: ${row[0]}'); + }, + ); + + var frame = Frame(title: 'Table Demo (UP/DOWN to navigate, ENTER to select)', color: '33') + ..children = [table]; + + mainFrame.children = [frame]; + focusFirst(); + } + + void _showBigTextDemo() { + var frame = Frame(title: 'BigText Demo (ESC to back)', color: '33') + ..children = [_BigTextShowcase()]; + + mainFrame.children = [frame]; + } + + void _backToMenu(String message) { + _inDemo = false; + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 1, 4]) + ..children = [ + introBanner, + CenteredText(message), + demoSelect, + ], + ]; + demoSelect.selectedIndex = 0; + focusFirst(); + } + + void _showMenu() { + mainFrame.children = [ + SplitView(horizontal: false, ratios: [1, 3]) + ..children = [introBanner, demoSelect], + ]; + focusFirst(); + } + + bool _inDemo = false; + + @override + bool onKey(String key) { + if (key == KeyCode.ESCAPE) { + if (_inDemo) { + _inDemo = false; + _showMenu(); + return true; + } else { + stop(); + return true; + } + } + if (key == 'q') { + stop(); + return true; + } + return super.onKey(key); + } +} + +class _BigTextShowcase extends View { + @override + void update() { + text = []; + var y = 0; + + // Block font with rainbow gradient + var block = BigText('DART', font: BigTextFont.block) + ..gradient = Gradients.rainbow; + block.size = Size(width, 5); + block.update(); + for (var t in block.text) { + text.add(Text(t.text ?? '') + ..color = t.color + ..position = Position(t.x, y + t.y)); + } + y += 6; + + // Chunky font with fire gradient + var chunky = BigText('FIRE', font: BigTextFont.chunky) + ..gradient = Gradients.fire; + chunky.size = Size(width, 4); + chunky.update(); + for (var t in chunky.text) { + text.add(Text(t.text ?? '') + ..color = t.color + ..position = Position(t.x, y + t.y)); + } + y += 5; + + // Slim font with ocean gradient + var slim = BigText('OCEAN', font: BigTextFont.slim) + ..gradient = Gradients.ocean; + slim.size = Size(width, 5); + slim.update(); + for (var t in slim.text) { + text.add(Text(t.text ?? '') + ..color = t.color + ..position = Position(t.x, y + t.y)); + } + y += 6; + + // Info text + text.add(Text('Fonts: block, slim, chunky') + ..color = '8' + ..position = Position(0, y++)); + text.add(Text('Gradients: rainbow, sunset, ocean, purple, cyan, fire, forest, gemini') + ..color = '8' + ..position = Position(0, y)); + } +} + +class _SpinnerShowcase extends View { + final spinners = []; + + _SpinnerShowcase() { + var styles = [ + (SpinnerStyle.dots, 'dots'), + (SpinnerStyle.circle, 'circle'), + (SpinnerStyle.line, 'line'), + (SpinnerStyle.arrow, 'arrow'), + (SpinnerStyle.bounce, 'bounce'), + (SpinnerStyle.moon, 'moon'), + ]; + + for (var (style, name) in styles) { + var s = Spinner(label: name, style: style)..start(); + spinners.add(s); + } + } + + @override + void update() { + text = []; + var y = 0; + for (var spinner in spinners) { + spinner.update(); + for (var t in spinner.text) { + text.add(Text(t.text ?? '') + ..color = t.color + ..position = Position(t.x, y + t.y)); + } + y++; + } + } +} + +void main() { + print("TUI Widgets Demo"); + print(BoxChars.lightH * 37); + print("UP/DOWN = navigate"); + print("ENTER = select"); + print("ESC = back / quit"); + print("q = quit"); + print(BoxChars.lightH * 37); + print("Starting in 1 second..."); + Future.delayed(Duration(seconds: 1), () { + WidgetsDemo().start(); + }); +} diff --git a/example/wizard.dart b/example/wizard.dart new file mode 100644 index 0000000..e5d15c9 --- /dev/null +++ b/example/wizard.dart @@ -0,0 +1,179 @@ +import 'package:tui/tui.dart'; + +/// Simple choice page - select with arrows, Enter to continue +class ChoicePage extends Page { + final String title; + final List options; + int selected = 0; + + ChoicePage(this.title, this.options); + + @override + void update() { + text = [ + Text(title)..color = '33', + Text(BoxChars.lightH * 40)..position = Position(0, 1), + ]; + + for (var i = 0; i < options.length; i++) { + var prefix = selected == i ? '► ' : ' '; + var color = selected == i ? '32' : '7'; + text.add(Text('$prefix${options[i]}') + ..color = color + ..position = Position(2, 3 + i)); + } + + text.add(Text('↑↓ select, Enter continue') + ..color = '90' + ..position = Position(0, height - 1)); + } + + @override + bool onKey(String key) { + switch (key) { + case KeyCode.UP: + selected = (selected - 1 + options.length) % options.length; + return true; + case KeyCode.DOWN: + selected = (selected + 1) % options.length; + return true; + case KeyCode.ENTER: + pageView.goNext(); + return true; + } + return false; + } + + @override + dynamic getData() => options[selected]; +} + +/// Config page with checkboxes and NavigationBar +class ConfigPage extends Page { + final List options = ['Enable logging', 'Enable cache', 'Enable debug']; + final List values = [true, false, false]; + int selected = 0; + NavigationBar? nav; + + @override + void onEnter() { + nav = NavigationBar(this); + children = [nav!]; + } + + @override + void update() { + text = [ + Text('Configuration')..color = '33', + Text(BoxChars.lightH * 40)..position = Position(0, 1), + ]; + + for (var i = 0; i < options.length; i++) { + var check = values[i] ? '[✓]' : '[ ]'; + var prefix = selected == i ? '► ' : ' '; + var color = selected == i ? '32' : '7'; + text.add(Text('$prefix$check ${options[i]}') + ..color = color + ..position = Position(2, 3 + i)); + } + + text.add(Text('↑↓ select, Space toggle, Tab for buttons') + ..color = '90' + ..position = Position(0, height - 2)); + } + + @override + void resizeChildren() { + nav?.resize(Size(width, 1), Position(0, height - 1)); + } + + @override + bool onKey(String key) { + switch (key) { + case KeyCode.UP: + selected = (selected - 1 + options.length) % options.length; + return true; + case KeyCode.DOWN: + selected = (selected + 1) % options.length; + return true; + case ' ': + values[selected] = !values[selected]; + return true; + } + return false; + } + + @override + dynamic getData() => { + 'logging': values[0], + 'cache': values[1], + 'debug': values[2], + }; +} + +/// Summary page - shows collected data +class SummaryPage extends Page { + @override + void update() { + var env = pageView.getData(0) ?? '?'; + var config = pageView.getData(1) ?? {}; + var theme = pageView.getData(2) ?? '?'; + + text = [ + Text('Summary')..color = '32', + Text(BoxChars.doubleH * 40)..position = Position(0, 1), + Text('Environment: $env')..position = Position(2, 3), + Text('Logging: ${config['logging'] ?? false}')..position = Position(2, 4), + Text('Cache: ${config['cache'] ?? false}')..position = Position(2, 5), + Text('Debug: ${config['debug'] ?? false}')..position = Position(2, 6), + Text('Theme: $theme')..position = Position(2, 7), + Text('Press q to exit') + ..color = '90' + ..position = Position(0, height - 1), + ]; + } +} + +/// Main wizard +class SimpleWizard extends Window { + late PageView pager; + late StepIndicator indicator; + + SimpleWizard() { + indicator = StepIndicator(); + + pager = PageView( + indicator: indicator, + pages: [ + ChoicePage('Select Environment', ['Development', 'Staging', 'Production']), + ConfigPage(), // Page with NavigationBar - use Tab to focus buttons + ChoicePage('Select Theme', ['Dark', 'Light', 'System']), + SummaryPage(), + ], + ); + + // Layout: indicator at top, pager below + var layout = SplitView(horizontal: false, ratios: [1, 10]); + layout.children = [indicator, pager]; + children = [layout]; + } + + @override + void start() { + super.start(); + pager.start(); + } + + @override + bool onKey(String key) { + if (key == 'q') { + stop(); + return true; + } + return false; + } +} + +void main() { + SimpleWizard().start(); +} diff --git a/lib/flexbox.dart b/lib/flexbox.dart deleted file mode 100644 index 6f9a88b..0000000 --- a/lib/flexbox.dart +++ /dev/null @@ -1,5 +0,0 @@ -library tui.flexbox; - -import "tui.dart"; - -part "src/flexview.dart"; \ No newline at end of file diff --git a/lib/src/animation.dart b/lib/src/animation.dart new file mode 100644 index 0000000..ac49808 --- /dev/null +++ b/lib/src/animation.dart @@ -0,0 +1,1339 @@ +part of tui; + +/// Easing functions for animations. +abstract class Easing { + static double linear(double t) => t; + + static double easeIn(double t) => t * t; + static double easeOut(double t) => 1 - (1 - t) * (1 - t); + static double easeInOut(double t) => + t < 0.5 ? 2 * t * t : 1 - pow(-2 * t + 2, 2).toDouble() / 2; + + static double easeInCubic(double t) => t * t * t; + static double easeOutCubic(double t) => 1 - pow(1 - t, 3).toDouble(); + static double easeInOutCubic(double t) => + t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3).toDouble() / 2; + + static double easeInElastic(double t) { + if (t == 0 || t == 1) return t; + return -pow(2, 10 * t - 10).toDouble() * + sin((t * 10 - 10.75) * (2 * pi / 3)); + } + + static double easeOutElastic(double t) { + if (t == 0 || t == 1) return t; + return pow(2, -10 * t).toDouble() * sin((t * 10 - 0.75) * (2 * pi / 3)) + 1; + } + + static double easeOutBounce(double t) { + const n1 = 7.5625; + const d1 = 2.75; + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + t -= 1.5 / d1; + return n1 * t * t + 0.75; + } else if (t < 2.5 / d1) { + t -= 2.25 / d1; + return n1 * t * t + 0.9375; + } else { + t -= 2.625 / d1; + return n1 * t * t + 0.984375; + } + } +} + +/// How the animation should repeat. +enum RepeatMode { + /// Play once and stop. + once, + + /// Play forward, then reverse, then stop. + reverse, + + /// Loop forever in one direction. + loop, + + /// Loop forever, alternating forward and reverse. + pingPong, +} + +/// Base class for all animations. +abstract class Animation { + final Duration duration; + final double Function(double) easing; + final void Function()? onComplete; + + /// How the animation repeats. + final RepeatMode repeatMode; + + /// Number of times to repeat (0 = use repeatMode, >0 = repeat N times then stop). + final int repeatCount; + + bool _running = false; + bool _completed = false; + Stopwatch? _stopwatch; + int _currentIteration = 0; + bool _reversed = false; + + bool get running => _running; + bool get completed => _completed; + + /// Current iteration (0-based). + int get currentIteration => _currentIteration; + + /// Whether currently playing in reverse. + bool get reversed => _reversed; + + Animation({ + required this.duration, + this.easing = Easing.linear, + this.onComplete, + this.repeatMode = RepeatMode.once, + this.repeatCount = 0, + }); + + /// Start the animation. + void start() { + _running = true; + _completed = false; + _currentIteration = 0; + _reversed = false; + _stopwatch = Stopwatch()..start(); + } + + /// Stop the animation. + void stop() { + _running = false; + _stopwatch?.stop(); + } + + /// Reset the animation. + void reset() { + _running = false; + _completed = false; + _currentIteration = 0; + _reversed = false; + _stopwatch?.reset(); + } + + /// Get current progress (0.0 to 1.0), handling repeat modes. + double get progress { + if (_stopwatch == null || !_running) return 0; + + final elapsed = _stopwatch!.elapsedMilliseconds; + final totalDuration = duration.inMilliseconds; + + // Guard against zero duration - complete immediately + if (totalDuration <= 0) { + _complete(); + return easing(1.0); + } + + // Calculate raw progress including iterations + var totalElapsed = elapsed; + var iterationProgress = (totalElapsed % totalDuration) / totalDuration; + var iteration = totalElapsed ~/ totalDuration; + + // Handle different repeat modes + switch (repeatMode) { + case RepeatMode.once: + if (iteration >= 1) { + _complete(); + return easing(1.0); + } + return easing(iterationProgress); + + case RepeatMode.reverse: + // Forward then reverse = 2 iterations total + if (iteration >= 2) { + _complete(); + return easing(0.0); + } + _currentIteration = iteration; + _reversed = iteration == 1; + var t = _reversed ? 1.0 - iterationProgress : iterationProgress; + return easing(t); + + case RepeatMode.loop: + if (repeatCount > 0 && iteration >= repeatCount) { + _complete(); + return easing(1.0); + } + _currentIteration = iteration; + return easing(iterationProgress); + + case RepeatMode.pingPong: + if (repeatCount > 0 && iteration >= repeatCount * 2) { + _complete(); + return easing(0.0); + } + _currentIteration = iteration ~/ 2; + _reversed = iteration.isOdd; + var t = _reversed ? 1.0 - iterationProgress : iterationProgress; + return easing(t); + } + } + + void _complete() { + if (!_completed) { + _completed = true; + _running = false; + onComplete?.call(); + } + } + + /// Update the animation. Called each frame. + void update(); +} + +/// Interpolates between two values over time. +class Tween { + final T begin; + final T end; + final T Function(T, T, double) lerp; + + Tween({required this.begin, required this.end, required this.lerp}); + + T evaluate(double t) => lerp(begin, end, t); + + /// Common tweens. + static Tween number(double begin, double end) => Tween( + begin: begin, + end: end, + lerp: (a, b, t) => a + (b - a) * t, + ); + + static Tween integer(int begin, int end) => Tween( + begin: begin, + end: end, + lerp: (a, b, t) => (a + (b - a) * t).round(), + ); + + static Tween color(RGB begin, RGB end) => Tween( + begin: begin, + end: end, + lerp: (a, b, t) => a.lerp(b, t), + ); +} + +/// Animation controller to manage multiple animations. +/// +/// Two modes of operation: +/// - **Self-ticking** (default): owns a `Timer.periodic` that calls +/// [tick] automatically. Used when the controller lives outside +/// the render tree (e.g. as a field on a Window subclass). +/// - **Externally-ticked**: set `selfTick = false` and call [tick] +/// manually from `render()`. Used by the [Animated] mixin and +/// [AnimatedBigText] to stay synchronised with the frame clock. +class AnimationController { + final List _animations = []; + Timer? _timer; + int _fps; + final bool selfTick; + + int get fps => _fps; + set fps(int value) { + _fps = value.clamp(1, 120); + if (selfTick && _timer != null) { + _stopTimer(); + _startTimer(); + } + } + + /// Whether there are running animations. + bool get hasAnimations => _animations.isNotEmpty; + + AnimationController({int fps = 30, this.selfTick = true}) : _fps = fps; + + /// Add an animation. + void add(Animation animation) { + _animations.add(animation); + animation.start(); + if (selfTick) _ensureRunning(); + } + + /// Remove an animation. + void remove(Animation animation) { + _animations.remove(animation); + if (selfTick && _animations.isEmpty) _stopTimer(); + } + + /// Remove all animations. + void clear() { + for (var anim in _animations) { + anim.stop(); + } + _animations.clear(); + if (selfTick) _stopTimer(); + } + + /// Advance all animations by one frame. + /// + /// Returns true if any animation is still running. + bool tick() { + if (_animations.isEmpty) return false; + + var completed = []; + for (var anim in _animations) { + anim.update(); + if (anim.completed) { + completed.add(anim); + } + } + for (var anim in completed) { + _animations.remove(anim); + } + if (selfTick && _animations.isEmpty) _stopTimer(); + return _animations.isNotEmpty; + } + + void _ensureRunning() { + if (_timer == null && _animations.isNotEmpty) _startTimer(); + } + + void _startTimer() { + _timer = Timer.periodic( + Duration(milliseconds: 1000 ~/ _fps), + (_) => tick(), + ); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TEXT ANIMATIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/// Typewriter effect - reveals text character by character. +class TypewriterAnimation extends Animation { + final String text; + final void Function(String visible) onUpdate; + final String? color; + + int _visibleChars = 0; + + TypewriterAnimation({ + required this.text, + required this.onUpdate, + this.color, + Duration? duration, + super.easing, + super.onComplete, + }) : super(duration: duration ?? Duration(milliseconds: text.length * 30)); + + @override + void update() { + var p = progress; + var newVisible = (text.length * p).floor(); + if (newVisible != _visibleChars) { + _visibleChars = newVisible; + onUpdate(text.substring(0, _visibleChars)); + } + } +} + +/// Glitch effect - randomly replaces characters. +class GlitchAnimation extends Animation { + final String text; + final void Function(String glitched) onUpdate; + final String glitchChars; + final double intensity; + final Random _random = Random(); + + GlitchAnimation({ + required this.text, + required this.onUpdate, + this.glitchChars = '!@#\$%\^&*<>[]{}|/\\~`', + this.intensity = 0.3, + Duration duration = const Duration(milliseconds: 500), + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + // Intensity decreases as we approach the end + var currentIntensity = intensity * (1 - p); + + var glitched = StringBuffer(); + for (var i = 0; i < text.length; i++) { + if (_random.nextDouble() < currentIntensity) { + glitched.write(glitchChars[_random.nextInt(glitchChars.length)]); + } else { + glitched.write(text[i]); + } + } + onUpdate(glitched.toString()); + } +} + +/// Pulse effect - alternates between two states. +class PulseAnimation extends Animation { + final void Function(bool bright) onUpdate; + final int pulses; + + int _lastPulse = -1; + + PulseAnimation({ + required this.onUpdate, + this.pulses = 3, + Duration? duration, + super.onComplete, + }) : super(duration: duration ?? Duration(milliseconds: pulses * 400)); + + @override + void update() { + var p = progress; + var pulse = (p * pulses * 2).floor(); + if (pulse != _lastPulse) { + _lastPulse = pulse; + onUpdate(pulse.isEven); + } + } +} + +/// Fade animation - interpolates opacity/visibility. +class FadeAnimation extends Animation { + final void Function(double opacity) onUpdate; + final double from; + final double to; + + FadeAnimation({ + required this.onUpdate, + this.from = 0.0, + this.to = 1.0, + Duration duration = const Duration(milliseconds: 300), + super.easing = Easing.easeOut, + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + var opacity = from + (to - from) * p; + onUpdate(opacity); + } +} + +/// Slide animation - moves position over time. +class SlideAnimation extends Animation { + final void Function(int x, int y) onUpdate; + final int fromX, fromY; + final int toX, toY; + + SlideAnimation({ + required this.onUpdate, + required this.fromX, + required this.fromY, + required this.toX, + required this.toY, + Duration duration = const Duration(milliseconds: 300), + super.easing = Easing.easeOut, + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + var x = (fromX + (toX - fromX) * p).round(); + var y = (fromY + (toY - fromY) * p).round(); + onUpdate(x, y); + } +} + +/// Cascade animation - reveals items in sequence. +class CascadeAnimation extends Animation { + final List items; + final void Function(int visibleCount, List visibleItems) onUpdate; + final Duration itemDelay; + + int _lastCount = 0; + + CascadeAnimation({ + required this.items, + required this.onUpdate, + this.itemDelay = const Duration(milliseconds: 100), + super.onComplete, + }) : super( + duration: Duration( + milliseconds: items.length * itemDelay.inMilliseconds)); + + @override + void update() { + var p = progress; + var count = (items.length * p).ceil(); + if (count != _lastCount) { + _lastCount = count; + onUpdate(count, items.take(count).toList()); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// VISUAL EFFECTS +// ═══════════════════════════════════════════════════════════════════════════ + +/// Matrix rain effect characters. +class MatrixRainAnimation extends Animation { + final void Function(String line) onUpdate; + final int width; + final String chars; + final Random _random = Random(); + late List _columns; + + MatrixRainAnimation({ + required this.onUpdate, + this.width = 60, + this.chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789', + Duration duration = const Duration(milliseconds: 800), + super.onComplete, + }) : super(duration: duration) { + _columns = List.generate(width, (_) => _random.nextInt(5)); + } + + @override + void update() { + progress; // Advances time + + var line = StringBuffer(); + for (var i = 0; i < width; i++) { + if (_random.nextDouble() < 0.3) { + _columns[i] = (_columns[i] + 1) % 5; + } + var brightness = _columns[i]; + var char = chars[_random.nextInt(chars.length)]; + + // Encode brightness in output (0=bright, 1-2=medium, 3-4=dim) + if (brightness == 0) { + line.write('${Colors.brightGreen}$char'); + } else if (brightness < 3) { + line.write('${Colors.green}$char'); + } else { + line.write('${Colors.dim}$char'); + } + } + line.write(Colors.reset); + onUpdate(line.toString()); + } +} + +/// Particle burst effect. +class ParticleBurstAnimation extends Animation { + final void Function(String line) onUpdate; + final int width; + final List particles; + final List colors; + final double density; + final Random _random = Random(); + + ParticleBurstAnimation({ + required this.onUpdate, + this.width = 50, + this.particles = const ['✦', '✧', '★', '☆', '◆', '◇', '●', '○', '◈', '✴'], + this.colors = const [ + Colors.brightGreen, + Colors.brightCyan, + Colors.brightYellow, + Colors.brightMagenta + ], + this.density = 0.15, + Duration duration = const Duration(milliseconds: 500), + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + progress; // Advances time + + var line = StringBuffer(); + for (var i = 0; i < width; i++) { + if (_random.nextDouble() < density) { + var particle = particles[_random.nextInt(particles.length)]; + var color = colors[_random.nextInt(colors.length)]; + line.write('$color$particle${Colors.reset}'); + } else { + line.write(' '); + } + } + onUpdate(line.toString()); + } +} + +/// Countdown animation. +class CountdownAnimation extends Animation { + final void Function(int remaining, String display) onUpdate; + final int from; + + int _lastValue = -1; + + CountdownAnimation({ + required this.onUpdate, + this.from = 3, + super.onComplete, + }) : super(duration: Duration(seconds: from)); + + @override + void update() { + var p = progress; + var remaining = from - (from * p).floor(); + if (remaining < 0) remaining = 0; + + if (remaining != _lastValue) { + _lastValue = remaining; + if (remaining > 0) { + onUpdate(remaining, '$remaining'); + } else { + onUpdate(0, 'GO!'); + } + } + } +} + +/// Scrolling banner animation. +class ScrollingBannerAnimation extends Animation { + final String text; + final void Function(String visible) onUpdate; + final int width; + final int iterations; + + late String _paddedText; + + ScrollingBannerAnimation({ + required this.text, + required this.onUpdate, + this.width = 40, + this.iterations = 2, + super.onComplete, + }) : super( + duration: Duration( + milliseconds: (text.length + width * 2) * 50 * iterations)) { + _paddedText = '${'─' * width}$text${'─' * width}'; + } + + @override + void update() { + var p = progress; + var totalSteps = (_paddedText.length - width) * iterations; + var step = (totalSteps * p).floor() % (_paddedText.length - width); + var visible = _paddedText.substring(step, step + width); + onUpdate(visible); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ANIMATED VIEW WRAPPER +// ═══════════════════════════════════════════════════════════════════════════ + +/// Mixin that adds animation capabilities to a View. +/// +/// Ticks the animation controller during [render], so animations +/// advance in sync with the render loop — no independent timer. +mixin Animated on View { + final AnimationController _animController = + AnimationController(selfTick: false); + + AnimationController get animations => _animController; + + @override + void render(Canvas canvas) { + _animController.tick(); + super.render(canvas); + } + + /// Run a typewriter animation on text content. + void animateTypewriter(String text, + {Duration? charDelay, void Function()? onComplete}) { + var anim = TypewriterAnimation( + text: text, + duration: charDelay != null + ? Duration(milliseconds: text.length * charDelay.inMilliseconds) + : null, + onUpdate: (visible) { + // Subclass should override to update view + }, + onComplete: onComplete, + ); + _animController.add(anim); + } + + /// Run a glitch animation. + void animateGlitch(String text, + {Duration? duration, void Function()? onComplete}) { + var anim = GlitchAnimation( + text: text, + duration: duration ?? const Duration(milliseconds: 500), + onUpdate: (glitched) { + // Subclass should override to update view + }, + onComplete: onComplete, + ); + _animController.add(anim); + } + + /// Dispose animations when view is removed. + void disposeAnimations() { + _animController.clear(); + } +} + +/// Widget that displays animated text effects. +class AnimatedText extends View with Animated { + String _displayText = ''; + String _fullText; + String color; + AnimationType type; + bool autoStart; + void Function()? onComplete; + + String get fullText => _fullText; + set fullText(String value) { + _fullText = value; + if (autoStart) start(); + } + + AnimatedText( + this._fullText, { + this.color = '0', + this.type = AnimationType.typewriter, + this.autoStart = true, + this.onComplete, + }) { + if (autoStart) { + // Delay to allow widget to be added to tree + Future.microtask(() => start()); + } + } + + void start() { + _displayText = ''; + animations.clear(); + + switch (type) { + case AnimationType.typewriter: + animations.add(TypewriterAnimation( + text: _fullText, + onUpdate: (visible) { + _displayText = visible; + update(); + }, + onComplete: onComplete, + )); + case AnimationType.glitch: + _displayText = _fullText; // Start with full text + animations.add(GlitchAnimation( + text: _fullText, + onUpdate: (glitched) { + _displayText = glitched; + update(); + }, + onComplete: onComplete, + )); + case AnimationType.pulse: + _displayText = _fullText; + animations.add(PulseAnimation( + onUpdate: (bright) { + // Toggle color brightness + color = bright ? '1' : '8'; + update(); + }, + onComplete: onComplete, + )); + case AnimationType.cascade: + // For single text, just use typewriter + animations.add(TypewriterAnimation( + text: _fullText, + onUpdate: (visible) { + _displayText = visible; + update(); + }, + onComplete: onComplete, + )); + } + } + + void stop() { + animations.clear(); + _displayText = _fullText; + update(); + } + + @override + void update() { + text = [ + Text(_displayText) + ..color = color + ..position = Position(0, 0), + ]; + } +} + +/// Types of text animations. +enum AnimationType { + typewriter, + glitch, + pulse, + cascade, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// REVEAL ANIMATIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/// How to reveal multi-line content. +enum RevealStyle { + /// Reveal all lines at once, character by character. + characters, + + /// Reveal line by line from top. + linesDown, + + /// Reveal line by line from bottom. + linesUp, + + /// Reveal from center outward. + centerOut, + + /// Fade in (opacity simulation via brightness). + fade, +} + +/// Reveals multi-line content progressively. +class RevealAnimation extends Animation { + final List lines; + final void Function(List visibleLines, double opacity) onUpdate; + final RevealStyle style; + + int _lastLineCount = -1; + double _lastOpacity = -1; + + RevealAnimation({ + required this.lines, + required this.onUpdate, + this.style = RevealStyle.linesDown, + Duration duration = const Duration(milliseconds: 500), + super.easing = Easing.easeOut, + super.repeatMode, + super.repeatCount, + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + + switch (style) { + case RevealStyle.characters: + // Reveal all characters across all lines + var totalChars = lines.fold(0, (sum, line) => sum + line.length); + var visibleChars = (totalChars * p).round(); + var result = []; + var remaining = visibleChars; + + for (var line in lines) { + if (remaining <= 0) { + result.add(''); + } else if (remaining >= line.length) { + result.add(line); + remaining -= line.length; + } else { + result.add(line.substring(0, remaining)); + remaining = 0; + } + } + onUpdate(result, 1.0); + + case RevealStyle.linesDown: + var visibleCount = (lines.length * p).ceil(); + if (visibleCount != _lastLineCount) { + _lastLineCount = visibleCount; + onUpdate(lines.take(visibleCount).toList(), 1.0); + } + + case RevealStyle.linesUp: + var visibleCount = (lines.length * p).ceil(); + if (visibleCount != _lastLineCount) { + _lastLineCount = visibleCount; + var startIdx = lines.length - visibleCount; + var visible = List.generate(lines.length, (i) { + return i >= startIdx ? lines[i] : ''; + }); + onUpdate(visible, 1.0); + } + + case RevealStyle.centerOut: + var visibleCount = (lines.length * p).ceil(); + if (visibleCount != _lastLineCount) { + _lastLineCount = visibleCount; + var center = lines.length ~/ 2; + var halfVisible = (visibleCount / 2).ceil(); + var visible = List.generate(lines.length, (i) { + var distFromCenter = (i - center).abs(); + return distFromCenter < halfVisible ? lines[i] : ''; + }); + onUpdate(visible, 1.0); + } + + case RevealStyle.fade: + if (p != _lastOpacity) { + _lastOpacity = p; + onUpdate(lines, p); + } + } + } +} + +/// Simple value animation with repeat support. +class ValueAnimation extends Animation { + final double from; + final double to; + final void Function(double value) onUpdate; + + double _lastValue = double.nan; + + ValueAnimation({ + this.from = 0.0, + this.to = 1.0, + required this.onUpdate, + Duration duration = const Duration(milliseconds: 300), + super.easing = Easing.easeInOut, + super.repeatMode, + super.repeatCount, + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + var value = from + (to - from) * p; + if (value != _lastValue) { + _lastValue = value; + onUpdate(value); + } + } +} + +/// Shimmer/scan line effect that moves across text. +class ShimmerAnimation extends Animation { + final int width; + final void Function(int position) onUpdate; + + int _lastPos = -1; + + ShimmerAnimation({ + required this.width, + required this.onUpdate, + Duration duration = const Duration(milliseconds: 1000), + super.repeatMode = RepeatMode.loop, + super.repeatCount, + super.onComplete, + }) : super(duration: duration); + + @override + void update() { + var p = progress; + var pos = (width * p).round(); + if (pos != _lastPos) { + _lastPos = pos; + onUpdate(pos); + } + } +} + +/// Blink animation for cursors or alerts. +class BlinkAnimation extends Animation { + final void Function(bool visible) onUpdate; + final Duration blinkRate; + + bool _lastVisible = true; + + BlinkAnimation({ + required this.onUpdate, + this.blinkRate = const Duration(milliseconds: 500), + super.repeatMode = RepeatMode.loop, + super.repeatCount, + super.onComplete, + }) : super(duration: blinkRate); + + @override + void update() { + var p = progress; + var visible = p < 0.5; + if (visible != _lastVisible) { + _lastVisible = visible; + onUpdate(visible); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BIGTEXT ANIMATION +// ═══════════════════════════════════════════════════════════════════════════ + +/// Style for revealing each line in BigTextAnimation. +enum LineRevealStyle { + /// Instant reveal - line appears immediately. + instant, + + /// Typewriter - characters appear left to right. + typewriter, + + /// Glitch - random characters that resolve to final text. + glitch, + + /// Matrix - matrix rain characters that resolve. + matrix, + + /// Fade - line fades in (dim to bright). + fade, + + /// Slide - line slides in from the side. + slide, +} + +/// Configuration for a single line's reveal animation. +class LineRevealConfig { + final LineRevealStyle style; + final Duration duration; + final double Function(double) easing; + + const LineRevealConfig({ + this.style = LineRevealStyle.typewriter, + this.duration = const Duration(milliseconds: 300), + this.easing = Easing.easeOut, + }); + + static const instant = LineRevealConfig( + style: LineRevealStyle.instant, + duration: Duration.zero, + ); + + static const typewriter = LineRevealConfig( + style: LineRevealStyle.typewriter, + duration: Duration(milliseconds: 400), + ); + + static const glitch = LineRevealConfig( + style: LineRevealStyle.glitch, + duration: Duration(milliseconds: 500), + ); + + static const matrix = LineRevealConfig( + style: LineRevealStyle.matrix, + duration: Duration(milliseconds: 600), + ); + + static const fade = LineRevealConfig( + style: LineRevealStyle.fade, + duration: Duration(milliseconds: 300), + ); +} + +/// Animates BigText line by line with different reveal styles per line. +class BigTextAnimation extends Animation { + final List lines; + final void Function(List renderedLines) onUpdate; + + /// Delay between starting each line's animation. + final Duration lineDelay; + + /// Reveal style for each line. If shorter than lines, last style repeats. + final List lineStyles; + + /// Default style if lineStyles is empty. + final LineRevealConfig defaultStyle; + + final Random _random = Random(); + static const _glitchChars = r'!@#$%^&*()_+-=[]{}|;:,.<>?/\~`░▒▓█▀▄'; + static const _matrixChars = 'アイウエオカキクケコサシスセソタチツテト0123456789'; + + List _currentLines = []; + int _lastLineIndex = -1; + + BigTextAnimation({ + required this.lines, + required this.onUpdate, + this.lineDelay = const Duration(milliseconds: 100), + this.lineStyles = const [], + this.defaultStyle = const LineRevealConfig(), + super.easing, + super.repeatMode, + super.repeatCount, + super.onComplete, + }) : super( + duration: Duration( + milliseconds: lines.length * lineDelay.inMilliseconds + + (lineStyles.isNotEmpty + ? lineStyles.last.duration.inMilliseconds + : const LineRevealConfig().duration.inMilliseconds), + ), + ) { + _currentLines = List.filled(lines.length, ''); + } + + LineRevealConfig _getStyleForLine(int index) { + if (lineStyles.isEmpty) return defaultStyle; + if (index < lineStyles.length) return lineStyles[index]; + return lineStyles.last; + } + + @override + void update() { + var p = progress; + var elapsed = p * duration.inMilliseconds; + + for (var i = 0; i < lines.length; i++) { + var lineStartTime = i * lineDelay.inMilliseconds; + var style = _getStyleForLine(i); + var lineDuration = style.duration.inMilliseconds; + + if (elapsed < lineStartTime) { + // Line hasn't started yet + _currentLines[i] = ''; + } else if (lineDuration == 0 || elapsed >= lineStartTime + lineDuration) { + // Line is complete + _currentLines[i] = lines[i]; + } else { + // Line is animating + var lineProgress = (elapsed - lineStartTime) / lineDuration; + lineProgress = style.easing(lineProgress.clamp(0.0, 1.0)); + _currentLines[i] = _renderLine(lines[i], lineProgress, style.style); + } + } + + onUpdate(List.from(_currentLines)); + } + + String _renderLine(String line, double progress, LineRevealStyle style) { + if (line.isEmpty) return ''; + + switch (style) { + case LineRevealStyle.instant: + return line; + + case LineRevealStyle.typewriter: + var visibleChars = (line.length * progress).round(); + return line.substring(0, visibleChars).padRight(line.length); + + case LineRevealStyle.glitch: + var buf = StringBuffer(); + var resolveThreshold = progress; + for (var i = 0; i < line.length; i++) { + var charProgress = i / line.length; + if (charProgress < resolveThreshold - 0.1) { + // Resolved + buf.write(line[i]); + } else if (charProgress < resolveThreshold + 0.2) { + // Glitching - mix of correct and random + if (_random.nextDouble() < progress * 0.7) { + buf.write(line[i]); + } else { + buf.write(_glitchChars[_random.nextInt(_glitchChars.length)]); + } + } else { + // Not yet revealed + buf.write(' '); + } + } + return buf.toString(); + + case LineRevealStyle.matrix: + var buf = StringBuffer(); + var resolveThreshold = progress; + for (var i = 0; i < line.length; i++) { + var charProgress = i / line.length; + if (charProgress < resolveThreshold - 0.15) { + // Resolved + buf.write(line[i]); + } else if (charProgress < resolveThreshold + 0.3) { + // Matrix effect - cycling through characters + if (_random.nextDouble() < progress * 0.6) { + buf.write(line[i]); + } else { + var matrixChar = + _matrixChars[_random.nextInt(_matrixChars.length)]; + buf.write('${Colors.green}$matrixChar${Colors.reset}'); + } + } else { + // Not yet revealed - dim matrix chars + if (_random.nextDouble() < 0.3) { + var matrixChar = + _matrixChars[_random.nextInt(_matrixChars.length)]; + buf.write('${Colors.dim}$matrixChar${Colors.reset}'); + } else { + buf.write(' '); + } + } + } + return buf.toString(); + + case LineRevealStyle.fade: + // Simulate fade with dim/normal/bright + if (progress < 0.33) { + return '${Colors.dim}$line${Colors.reset}'; + } else if (progress < 0.66) { + return line; + } else { + return '${Colors.bold}$line${Colors.reset}'; + } + + case LineRevealStyle.slide: + // Slide in from right + var offset = ((1 - progress) * line.length).round(); + var visible = line.length - offset; + if (visible <= 0) return ' ' * line.length; + return '${' ' * offset}${line.substring(0, visible)}'; + } + } +} + +/// Animated wrapper for BigText that plays reveal animation on start. +/// +/// When [autoStart] is true (default) the animation begins +/// automatically on the first [resize] with a non-zero size. +/// You can also call [startAnimation] manually — if the widget +/// has no size yet the start is deferred until the next [resize]. +class AnimatedBigText extends View { + final BigText _bigText; + final BigTextFont _font; + final String _text; + final AnimationController _controller = AnimationController(selfTick: false); + final Duration lineDelay; + final List lineStyles; + final LineRevealConfig defaultStyle; + final void Function()? onComplete; + + /// Whether to start animating on first resize automatically. + final bool autoStart; + + List _animatedLines = []; + bool _animationComplete = false; + bool _started = false; + bool _pendingStart = false; + + AnimatedBigText( + String text, { + BigTextFont font = BigTextFont.shadow, + List? gradient, + String? subtitle, + bool showBorder = false, + this.lineDelay = const Duration(milliseconds: 80), + this.lineStyles = const [], + this.defaultStyle = const LineRevealConfig(), + this.onComplete, + this.autoStart = true, + }) : _text = text, + _font = font, + _bigText = BigText(text, + font: font, + gradient: gradient, + subtitle: subtitle, + showBorder: showBorder); + + /// Start the reveal animation. + /// + /// Safe to call before the widget has been sized — the actual + /// start is deferred until [resize] provides a non-zero size. + void startAnimation() { + if (width == 0 || height == 0) { + _pendingStart = true; + return; + } + _doStart(); + } + + void _doStart() { + _pendingStart = false; + _started = true; + _animationComplete = false; + _animatedLines = []; + _controller.clear(); + + // Generate lines statically — no dependency on widget size. + var lines = BigText.generateLines(_text, font: _font); + if (lines.isEmpty) return; + + _controller.add(BigTextAnimation( + lines: lines, + lineDelay: lineDelay, + lineStyles: lineStyles, + defaultStyle: defaultStyle, + onUpdate: (rendered) { + _animatedLines = rendered; + }, + onComplete: () { + _animationComplete = true; + onComplete?.call(); + }, + )); + } + + /// Skip animation and show final state. + void skipAnimation() { + _controller.clear(); + _animationComplete = true; + _animatedLines = []; + _pendingStart = false; + } + + @override + void resize(Size size, Position offset) { + super.resize(size, offset); + _bigText.resize(size, offset); + + if (width > 0 && height > 0) { + if (_pendingStart) { + _doStart(); + } else if (autoStart && !_started) { + _doStart(); + } + } + } + + @override + void render(Canvas canvas) { + _controller.tick(); + update(); + super.render(canvas); + } + + @override + void update() { + if (_animationComplete || _animatedLines.isEmpty) { + _bigText.update(); + text = _bigText.text; + } else { + text = []; + final grad = _bigText.gradient; + for (var i = 0; i < _animatedLines.length; i++) { + var line = _animatedLines[i]; + var x = _bigText.centered ? (width - line.length) ~/ 2 : 0; + if (x < 0) x = 0; + + if (grad != null && grad.isNotEmpty) { + // Per-character gradient — same as BigText. + for (var c = 0; c < line.length; c++) { + var ch = line[c]; + if (ch == ' ') continue; + var rgb = BigText.interpolateGradient(grad, c, line.length); + text.add(Text(ch) + ..color = rgb.toAnsi() + ..position = Position(x + c, i)); + } + } else { + text.add(Text(line) + ..color = _bigText.color + ..position = Position(x, i)); + } + } + } + } + + void dispose() { + _controller.clear(); + } +} diff --git a/lib/src/audio/articulation.dart b/lib/src/audio/articulation.dart new file mode 100644 index 0000000..f7eebb7 --- /dev/null +++ b/lib/src/audio/articulation.dart @@ -0,0 +1,39 @@ +part of tui; + +/// Named articulation presets that override the melody's default envelope. +/// +/// Used in the DSL with `:` suffix: `C4.q:st` (staccato), `C4.q:pz` (pizz). +class Articulation { + /// Envelope override for this articulation. + final Envelope envelope; + + const Articulation(this.envelope); + + /// Staccato: short, detached, percussive. + static const staccato = Articulation( + Envelope(attack: 0.003, decay: 0.06, sustain: 0.0, release: 0.01), + ); + + /// Pizzicato: plucked string, snappy attack, fast decay. + static const pizzicato = Articulation( + Envelope(attack: 0.001, decay: 0.12, sustain: 0.0, release: 0.02), + ); + + /// Spiccato: bouncing bow, slightly longer than staccato. + static const spiccato = Articulation( + Envelope(attack: 0.005, decay: 0.08, sustain: 0.1, release: 0.03), + ); + + /// Tenuto: held full value, slight emphasis. + static const tenuto = Articulation( + Envelope(attack: 0.01, decay: 0.02, sustain: 0.85, release: 0.04), + ); + + /// DSL code mapping. + static const Map codes = { + 'st': staccato, + 'pz': pizzicato, + 'sp': spiccato, + 'tn': tenuto, + }; +} diff --git a/lib/src/audio/channel.dart b/lib/src/audio/channel.dart new file mode 100644 index 0000000..b743443 --- /dev/null +++ b/lib/src/audio/channel.dart @@ -0,0 +1,71 @@ +part of tui; + +/// Organizes audio playback into named channels. +/// +/// Each channel plays one sound at a time — new playback replaces the old. +class Audio { + final Map _channels = {}; + final AudioPlayer _player = AudioPlayer(); + bool muted = false; + + bool get isAvailable => _player.isAvailable; + + AudioChannel channel(String name) { + return _channels.putIfAbsent(name, () => AudioChannel._(this)); + } + + void beep({double frequency = 440.0, double duration = 0.15, Waveform waveform = Waveform.sine}) { + channel('sfx').tone(frequency: frequency, duration: duration, waveform: waveform); + } + + void stopAll() { + for (final ch in _channels.values) { + ch.stop(); + } + } + + void dispose() { + stopAll(); + _channels.clear(); + } +} + +/// A named audio channel — plays one sound at a time. +class AudioChannel { + final Audio _audio; + AudioPlayer? _currentPlayer; + + AudioChannel._(this._audio); + + void tone({ + required double frequency, + double duration = 0.3, + Waveform waveform = Waveform.sine, + Envelope? envelope, + double volume = 1.0, + }) { + _play(Synthesizer.tone( + frequency: frequency, duration: duration, waveform: waveform, envelope: envelope, volume: volume)); + } + + void playMelody(Melody melody) => _play(melody.toSamples()); + + void play(String dsl, {int bpm = 120}) => playMelody(Melody.parse(dsl, bpm: bpm)); + + void stop() { + _currentPlayer?.stop(); + _currentPlayer = null; + } + + void _play(Float64List samples) { + if (_audio.muted || !_audio.isAvailable) return; + stop(); + + final player = AudioPlayer(); + _currentPlayer = player; + + player.playWav(WavWriter.encode(samples)).whenComplete(() { + if (_currentPlayer == player) _currentPlayer = null; + }); + } +} diff --git a/lib/src/audio/melody.dart b/lib/src/audio/melody.dart new file mode 100644 index 0000000..358afbe --- /dev/null +++ b/lib/src/audio/melody.dart @@ -0,0 +1,337 @@ +part of tui; + +sealed class MelodyEvent { + final double beats; + const MelodyEvent(this.beats); +} + +class NoteEvent extends MelodyEvent { + final double frequency; + final Waveform waveform; + + /// Per-note volume (0.0-1.0). When null, inherits from [Melody.volume]. + final double? volume; + + /// When true, this note connects to the next without re-attacking. + final bool tied; + + /// Articulation override (staccato, pizzicato, etc.). + final Articulation? articulation; + + const NoteEvent( + this.frequency, + double beats, + this.waveform, { + this.volume, + this.tied = false, + this.articulation, + }) : super(beats); +} + +class RestEvent extends MelodyEvent { + const RestEvent(double beats) : super(beats); +} + +/// A sequence of notes and rests that can be synthesized to audio. +class Melody { + final int bpm; + Waveform waveform; + Envelope envelope; + final double volume; + + /// Vibrato applied to all notes in this melody. + Vibrato? vibrato; + + /// Harmonic timbre for additive synthesis (overrides [waveform]). + Timbre? timbre; + + /// Tempo map for ritardando, accelerando, and fermata. + TempoMap? tempoMap; + + final List events = []; + + Melody({ + this.bpm = 120, + this.waveform = Waveform.square, + Envelope? envelope, + this.volume = 0.7, + this.vibrato, + this.timbre, + this.tempoMap, + }) : envelope = envelope ?? const Envelope.preset(); + + /// Add a note with optional per-note overrides. + void note( + double frequency, + double beats, { + Waveform? waveform, + double? volume, + bool tied = false, + Articulation? articulation, + }) { + events.add(NoteEvent( + frequency, + beats, + waveform ?? this.waveform, + volume: volume, + tied: tied, + articulation: articulation, + )); + } + + void rest(double beats) { + events.add(RestEvent(beats)); + } + + double get totalBeats { + var sum = 0.0; + for (final event in events) { + sum += event.beats; + } + return sum; + } + + double get totalSeconds { + if (tempoMap != null) return tempoMap!.beatsToSeconds(0, totalBeats); + return Dur.toSeconds(totalBeats, bpm); + } + + Float64List toSamples({int sampleRate = 44100}) { + final totalSamples = (totalSeconds * sampleRate).round(); + final output = Float64List(totalSamples); + var offset = 0; + var currentBeat = 0.0; + + for (var i = 0; i < events.length; i++) { + final event = events[i]; + final duration = tempoMap != null + ? tempoMap!.beatsToSeconds(currentBeat, event.beats) + : Dur.toSeconds(event.beats, bpm); + final length = (duration * sampleRate).round(); + + if (event is NoteEvent) { + final env = _envelopeForNote(event, i); + final samples = Synthesizer.tone( + frequency: event.frequency, + duration: duration, + waveform: event.waveform, + envelope: env, + volume: event.volume ?? volume, + sampleRate: sampleRate, + vibrato: vibrato, + timbre: timbre, + ); + for (var j = 0; j < samples.length && offset + j < totalSamples; j++) { + output[offset + j] = samples[j]; + } + } + currentBeat += event.beats; + offset += length; + } + + return output; + } + + /// Compute effective envelope for a note, handling ties and articulations. + Envelope _envelopeForNote(NoteEvent event, int index) { + // Articulation overrides everything + if (event.articulation != null) return event.articulation!.envelope; + + final prevIsTied = index > 0 && + events[index - 1] is NoteEvent && + (events[index - 1] as NoteEvent).tied; + final nextIsTied = event.tied && + index + 1 < events.length && + events[index + 1] is NoteEvent; + + if (prevIsTied && nextIsTied) { + // Middle of legato phrase: no attack, no release + return Envelope( + attack: 0.002, + decay: 0, + sustain: envelope.sustain, + release: 0.002, + ); + } else if (prevIsTied) { + // End of legato phrase: no attack, normal release + return Envelope( + attack: 0.002, + decay: 0, + sustain: envelope.sustain, + release: envelope.release, + ); + } else if (nextIsTied) { + // Start of legato phrase: normal attack, no release + return Envelope( + attack: envelope.attack, + decay: envelope.decay, + sustain: envelope.sustain, + release: 0.002, + ); + } + + return envelope; + } + + static const Map _durationCodes = { + 'w': Dur.whole, + 'h': Dur.half, + 'dh': Dur.dottedHalf, + 'q': Dur.quarter, + 'dq': Dur.dottedQuarter, + 'e': Dur.eighth, + 'de': Dur.dottedEighth, + 's': Dur.sixteenth, + 't': 1.0 / 3.0, + }; + + static const Map _dynamicLevels = { + 'ppp': 0.15, + 'pp': 0.3, + 'p': 0.45, + 'mp': 0.6, + 'mf': 0.75, + 'f': 0.88, + 'ff': 1.0, + }; + + /// Ornament codes for DSL: `C4.q~tr` (trill), `C4.q~mr` (mordent). + static const _ornamentCodes = {'tr', 'mr', 'tn'}; + + /// Parse a melody from DSL string. + /// + /// Format: `"C4.q D4.q E4.h R.q Cs4.e"` + /// - Note: `{Name}{Octave}.{Duration}` — e.g. `C4.q`, `Fs5.e` + /// - Rest: `R.{Duration}` + /// - Bar separator: `|` (ignored) + /// - Durations: w h dh q dq e de s t + /// - Dynamics: `@` + level — e.g. `C4.q@ff` (ppp pp p mp mf f ff) + /// - Articulation: `:` + code — e.g. `C4.q:st` (st pz sp tn) + /// - Tie: `_` suffix — e.g. `C4.q_` (legato to next note) + /// - Ornament: `~` + code — e.g. `C4.q~tr` (tr mr tn) + static Melody parse( + String dsl, { + int bpm = 120, + Waveform waveform = Waveform.square, + Vibrato? vibrato, + Timbre? timbre, + }) { + final melody = Melody( + bpm: bpm, + waveform: waveform, + vibrato: vibrato, + timbre: timbre, + ); + final tokens = dsl.trim().split(RegExp(r'\s+')); + + for (final token in tokens) { + if (token == '|' || token.isEmpty) continue; + _parseToken(melody, token); + } + + return melody; + } + + static void _parseToken(Melody melody, String token) { + final dotIndex = token.indexOf('.'); + if (dotIndex == -1) { + throw FormatException('Invalid token (missing dot): "$token"'); + } + + final left = token.substring(0, dotIndex); + var right = token.substring(dotIndex + 1); + + // Parse suffixes from right side: duration[@dyn][:art][_][~orn] + double? noteVolume; + Articulation? articulation; + var tied = false; + String? ornament; + + // Ornament: ~tr, ~mr, ~tn (must be last) + final tildeIdx = right.indexOf('~'); + if (tildeIdx != -1) { + ornament = right.substring(tildeIdx + 1); + right = right.substring(0, tildeIdx); + if (!_ornamentCodes.contains(ornament)) { + throw FormatException('Unknown ornament "$ornament" in "$token"'); + } + } + + // Tie: trailing _ + if (right.endsWith('_')) { + tied = true; + right = right.substring(0, right.length - 1); + } + + // Articulation: :st, :pz, :sp, :tn + final colonIdx = right.indexOf(':'); + if (colonIdx != -1) { + final artCode = right.substring(colonIdx + 1); + right = right.substring(0, colonIdx); + articulation = Articulation.codes[artCode]; + if (articulation == null) { + throw FormatException('Unknown articulation "$artCode" in "$token"'); + } + } + + // Dynamics: @ff, @pp, etc. + final atIdx = right.indexOf('@'); + if (atIdx != -1) { + final dynCode = right.substring(atIdx + 1); + right = right.substring(0, atIdx); + noteVolume = _dynamicLevels[dynCode]; + if (noteVolume == null) { + throw FormatException('Unknown dynamics "$dynCode" in "$token"'); + } + } + + final beats = _durationCodes[right]; + if (beats == null) { + throw FormatException('Unknown duration code "$right" in "$token"'); + } + + if (left == 'R') { + melody.rest(beats); + return; + } + + final noteMatch = RegExp(r'^([A-G]s?)(\d)$').firstMatch(left); + if (noteMatch == null) { + throw FormatException('Invalid note "$left" in "$token"'); + } + final freq = noteFrequency( + noteMatch.group(1)!, + int.parse(noteMatch.group(2)!), + ); + + // Ornaments expand into multiple notes + if (ornament != null) { + _addOrnament(melody, ornament, freq, beats, noteVolume); + } else { + melody.note( + freq, + beats, + volume: noteVolume, + tied: tied, + articulation: articulation, + ); + } + } + + static void _addOrnament( + Melody melody, + String code, + double freq, + double beats, + double? volume, + ) { + switch (code) { + case 'tr': + melody.trill(freq, beats, volume: volume); + case 'mr': + melody.mordent(freq, beats, volume: volume); + case 'tn': + melody.turn(freq, beats, volume: volume); + } + } +} diff --git a/lib/src/audio/notes.dart b/lib/src/audio/notes.dart new file mode 100644 index 0000000..863ef32 --- /dev/null +++ b/lib/src/audio/notes.dart @@ -0,0 +1,165 @@ +part of tui; + +/// Note frequencies in Hz, 12-tone equal temperament (A4 = 440 Hz). +/// Sharps use 's' suffix: `Cs4` = C#4. +abstract class Note { + // --- Octave 0 --- + static const double C0 = 16.3516; + static const double Cs0 = 17.3239; + static const double D0 = 18.3540; + static const double Ds0 = 19.4454; + static const double E0 = 20.6017; + static const double F0 = 21.8268; + static const double Fs0 = 23.1247; + static const double G0 = 24.4997; + static const double Gs0 = 25.9565; + static const double A0 = 27.5000; + static const double As0 = 29.1352; + static const double B0 = 30.8677; + + // --- Octave 1 --- + static const double C1 = 32.7032; + static const double Cs1 = 34.6478; + static const double D1 = 36.7081; + static const double Ds1 = 38.8909; + static const double E1 = 41.2034; + static const double F1 = 43.6535; + static const double Fs1 = 46.2493; + static const double G1 = 48.9994; + static const double Gs1 = 51.9131; + static const double A1 = 55.0000; + static const double As1 = 58.2705; + static const double B1 = 61.7354; + + // --- Octave 2 --- + static const double C2 = 65.4064; + static const double Cs2 = 69.2957; + static const double D2 = 73.4162; + static const double Ds2 = 77.7817; + static const double E2 = 82.4069; + static const double F2 = 87.3071; + static const double Fs2 = 92.4986; + static const double G2 = 97.9989; + static const double Gs2 = 103.826; + static const double A2 = 110.000; + static const double As2 = 116.541; + static const double B2 = 123.471; + + // --- Octave 3 --- + static const double C3 = 130.813; + static const double Cs3 = 138.591; + static const double D3 = 146.832; + static const double Ds3 = 155.563; + static const double E3 = 164.814; + static const double F3 = 174.614; + static const double Fs3 = 184.997; + static const double G3 = 195.998; + static const double Gs3 = 207.652; + static const double A3 = 220.000; + static const double As3 = 233.082; + static const double B3 = 246.942; + + // --- Octave 4 (Middle C) --- + static const double C4 = 261.626; + static const double Cs4 = 277.183; + static const double D4 = 293.665; + static const double Ds4 = 311.127; + static const double E4 = 329.628; + static const double F4 = 349.228; + static const double Fs4 = 369.994; + static const double G4 = 391.995; + static const double Gs4 = 415.305; + static const double A4 = 440.000; + static const double As4 = 466.164; + static const double B4 = 493.883; + + // --- Octave 5 --- + static const double C5 = 523.251; + static const double Cs5 = 554.365; + static const double D5 = 587.330; + static const double Ds5 = 622.254; + static const double E5 = 659.255; + static const double F5 = 698.456; + static const double Fs5 = 739.989; + static const double G5 = 783.991; + static const double Gs5 = 830.609; + static const double A5 = 880.000; + static const double As5 = 932.328; + static const double B5 = 987.767; + + // --- Octave 6 --- + static const double C6 = 1046.50; + static const double Cs6 = 1108.73; + static const double D6 = 1174.66; + static const double Ds6 = 1244.51; + static const double E6 = 1318.51; + static const double F6 = 1396.91; + static const double Fs6 = 1479.98; + static const double G6 = 1567.98; + static const double Gs6 = 1661.22; + static const double A6 = 1760.00; + static const double As6 = 1864.66; + static const double B6 = 1975.53; + + // --- Octave 7 --- + static const double C7 = 2093.00; + static const double Cs7 = 2217.46; + static const double D7 = 2349.32; + static const double Ds7 = 2489.02; + static const double E7 = 2637.02; + static const double F7 = 2793.83; + static const double Fs7 = 2959.96; + static const double G7 = 3135.96; + static const double Gs7 = 3322.44; + static const double A7 = 3520.00; + static const double As7 = 3729.31; + static const double B7 = 3951.07; + + // --- Octave 8 --- + static const double C8 = 4186.01; + + static const Map _semitones = { + 'C': 0, + 'Cs': 1, + 'D': 2, + 'Ds': 3, + 'E': 4, + 'F': 5, + 'Fs': 6, + 'G': 7, + 'Gs': 8, + 'A': 9, + 'As': 10, + 'B': 11, + }; + + static double frequency(String name, int octave) => noteFrequency(name, octave); +} + +/// Compute frequency from note name and octave: `noteFrequency('A', 4) == 440.0` +double noteFrequency(String name, int octave) { + final semitone = Note._semitones[name]; + if (semitone == null) throw ArgumentError('Unknown note name: $name'); + if (octave < 0 || octave > 8) throw ArgumentError('Octave must be 0-8, got: $octave'); + if (octave == 8 && semitone > 0) throw ArgumentError('Only C8 is supported in octave 8'); + final midi = 12 + octave * 12 + semitone; + return 440.0 * pow(2.0, (midi - 69) / 12.0); +} + +/// Beat duration constants (quarter note = 1 beat). +abstract class Dur { + static const double whole = 4.0; + static const double dottedHalf = 3.0; + static const double half = 2.0; + static const double dottedQuarter = 1.5; + static const double quarter = 1.0; + static const double dottedEighth = 0.75; + static const double eighth = 0.5; + static const double dottedSixteenth = 0.375; + static const double sixteenth = 0.25; + static const double thirtySecond = 0.125; + static const double triplet = 1.0 / 3.0; + + static double quarterAt(int bpm) => 60.0 / bpm; + static double toSeconds(double beats, int bpm) => beats * 60.0 / bpm; +} diff --git a/lib/src/audio/ornaments.dart b/lib/src/audio/ornaments.dart new file mode 100644 index 0000000..34e2af7 --- /dev/null +++ b/lib/src/audio/ornaments.dart @@ -0,0 +1,83 @@ +part of tui; + +/// Ornament extensions for [Melody]: trill, mordent, turn, grace note. +/// +/// Each ornament expands into multiple short [NoteEvent]s so the +/// existing synthesis pipeline handles them without modification. +extension MelodyOrnaments on Melody { + /// Trill: rapid alternation between [frequency] and the note above. + /// + /// [semitones] controls interval (default 2 = whole step). + void trill( + double frequency, + double beats, { + int semitones = 2, + Waveform? waveform, + double? volume, + }) { + final upper = frequency * pow(2.0, semitones / 12.0); + final noteCount = max(4, (beats / Dur.thirtySecond).round()); + final noteDur = beats / noteCount; + for (var i = 0; i < noteCount; i++) { + note(i.isEven ? frequency : upper, noteDur, + waveform: waveform, volume: volume); + } + } + + /// Mordent: quick alternation down-up-principal. + /// + /// First 1/4 of duration is the ornament, rest is the main note. + void mordent( + double frequency, + double beats, { + int semitones = 2, + Waveform? waveform, + double? volume, + }) { + final lower = frequency / pow(2.0, semitones / 12.0); + final ornDur = beats * 0.25; + final mainDur = beats - ornDur; + final triplet = ornDur / 3.0; + + note(frequency, triplet, waveform: waveform, volume: volume); + note(lower, triplet, waveform: waveform, volume: volume); + note(frequency, triplet, waveform: waveform, volume: volume); + note(frequency, mainDur, waveform: waveform, volume: volume); + } + + /// Turn: 4-note ornament — upper, principal, lower, principal. + /// + /// First 1/3 of duration is the ornament, rest is the main note. + void turn( + double frequency, + double beats, { + int semitones = 2, + Waveform? waveform, + double? volume, + }) { + final upper = frequency * pow(2.0, semitones / 12.0); + final lower = frequency / pow(2.0, semitones / 12.0); + final ornDur = beats * 0.333; + final mainDur = beats - ornDur; + final quarter = ornDur / 4.0; + + note(upper, quarter, waveform: waveform, volume: volume); + note(frequency, quarter, waveform: waveform, volume: volume); + note(lower, quarter, waveform: waveform, volume: volume); + note(frequency, quarter, waveform: waveform, volume: volume); + note(frequency, mainDur, waveform: waveform, volume: volume); + } + + /// Grace note: very short note leading into the principal note. + void graceNote( + double graceFrequency, + double mainFrequency, + double beats, { + Waveform? waveform, + double? volume, + }) { + final graceDur = min(beats * 0.12, Dur.thirtySecond); + note(graceFrequency, graceDur, waveform: waveform, volume: volume); + note(mainFrequency, beats - graceDur, waveform: waveform, volume: volume); + } +} diff --git a/lib/src/audio/player.dart b/lib/src/audio/player.dart new file mode 100644 index 0000000..a812ac3 --- /dev/null +++ b/lib/src/audio/player.dart @@ -0,0 +1,72 @@ +part of tui; + +/// Plays WAV audio via platform commands (afplay / aplay / PowerShell). +class AudioPlayer { + Process? _process; + String? _tempPath; + + static List? get _command { + if (Platform.isMacOS) return ['afplay']; + if (Platform.isLinux) return ['aplay', '-q']; + if (Platform.isWindows) return ['powershell', '-c', '(New-Object Media.SoundPlayer "{path}").PlaySync()']; + return null; + } + + bool get isAvailable => _command != null; + + Future playWav(Uint8List wavBytes) async { + final cmd = _command; + if (cmd == null) throw StateError('No audio player available on this platform'); + + final tempDir = await Directory.systemTemp.createTemp('tui_audio_'); + final tempFile = File('${tempDir.path}/playback.wav'); + _tempPath = tempFile.path; + await tempFile.writeAsBytes(wavBytes); + + try { + final List args; + if (Platform.isWindows) { + args = cmd.map((s) => s.replaceAll('{path}', tempFile.path)).toList(); + } else { + args = [...cmd, tempFile.path]; + } + + _process = await Process.start(args.first, args.skip(1).toList()); + await _process!.exitCode; + } finally { + _process = null; + await _cleanup(); + } + } + + void stop() { + final proc = _process; + if (proc != null) { + proc.kill(); + _process = null; + } + _cleanupSync(); + } + + Future _cleanup() async { + final path = _tempPath; + if (path != null) { + _tempPath = null; + try { + final file = File(path); + if (await file.exists()) await file.parent.delete(recursive: true); + } catch (_) {} + } + } + + void _cleanupSync() { + final path = _tempPath; + if (path != null) { + _tempPath = null; + try { + final file = File(path); + if (file.existsSync()) file.parent.deleteSync(recursive: true); + } catch (_) {} + } + } +} diff --git a/lib/src/audio/presets.dart b/lib/src/audio/presets.dart new file mode 100644 index 0000000..10afc92 --- /dev/null +++ b/lib/src/audio/presets.dart @@ -0,0 +1,56 @@ +part of tui; + +/// Preset sound effects for games and UI. +enum SFX { + beep, + click, + success, + error, + powerUp, + explosion, + coin, + jump; + + Melody get melody { + final m = Melody(bpm: 240, waveform: Waveform.sine); + switch (this) { + case SFX.beep: + m.note(Note.A4, Dur.quarter); + case SFX.click: + m.note(Note.C6, Dur.sixteenth); + case SFX.success: + m.note(Note.C5, Dur.eighth); + m.note(Note.E5, Dur.eighth); + m.note(Note.G5, Dur.eighth); + m.note(Note.C6, Dur.quarter); + case SFX.error: + m.note(Note.E4, Dur.eighth); + m.note(Note.C4, Dur.eighth); + m.note(Note.A3, Dur.quarter); + case SFX.powerUp: + m.note(Note.C4, Dur.sixteenth); + m.note(Note.D4, Dur.sixteenth); + m.note(Note.E4, Dur.sixteenth); + m.note(Note.F4, Dur.sixteenth); + m.note(Note.G4, Dur.sixteenth); + m.note(Note.A4, Dur.sixteenth); + m.note(Note.B4, Dur.sixteenth); + m.note(Note.C5, Dur.sixteenth); + case SFX.explosion: + m.waveform = Waveform.noise; + m.note(Note.C2, Dur.half); + case SFX.coin: + m.note(Note.B5, Dur.eighth); + m.note(Note.E6, Dur.quarter); + case SFX.jump: + m.note(Note.C4, Dur.sixteenth); + m.note(Note.E4, Dur.sixteenth); + m.note(Note.G4, Dur.eighth); + } + return m; + } + + Float64List toSamples({int sampleRate = 44100}) => melody.toSamples(sampleRate: sampleRate); + + void playOn(AudioChannel channel) => channel.playMelody(melody); +} diff --git a/lib/src/audio/reverb.dart b/lib/src/audio/reverb.dart new file mode 100644 index 0000000..fa3e95d --- /dev/null +++ b/lib/src/audio/reverb.dart @@ -0,0 +1,110 @@ +part of tui; + +/// Schroeder reverb for hall/room simulation. +/// +/// Uses 4 parallel comb filters + 2 serial allpass filters +/// to create a convincing reverb tail. Essential for Vivaldi +/// (performed in churches with significant reverberation). +class Reverb { + /// Wet/dry mix: 0.0 = completely dry, 1.0 = completely wet. + final double mix; + + /// Decay factor (0.0-1.0). Higher = longer reverb tail. + final double decay; + + const Reverb({this.mix = 0.3, this.decay = 0.5}); + + /// Church reverb: long tail, moderate mix (Vivaldi's sound). + static const church = Reverb(mix: 0.35, decay: 0.7); + + /// Small room: short tail, subtle. + static const room = Reverb(mix: 0.2, decay: 0.3); + + /// Large hall: very long tail. + static const hall = Reverb(mix: 0.4, decay: 0.8); + + /// Apply reverb to mono samples. + Float64List apply(Float64List input, {int sampleRate = 44100}) { + if (mix <= 0 || input.isEmpty) return Float64List.fromList(input); + + final feedback = decay.clamp(0.0, 0.98); + + // Schroeder comb filter delays (ms) — tuned prime-ish spacing + final combDelayMs = [29.7, 37.1, 41.1, 43.7]; + + // Process 4 parallel comb filters + final combSum = Float64List(input.length); + for (final ms in combDelayMs) { + final delaySamples = (ms / 1000.0 * sampleRate).round(); + _addCombFilter(input, combSum, delaySamples, feedback); + } + + // Normalize comb sum + for (var i = 0; i < combSum.length; i++) { + combSum[i] /= combDelayMs.length; + } + + // 2 serial allpass filters + var wet = combSum; + for (final ms in [5.0, 1.7]) { + wet = _allpassFilter( + wet, + (ms / 1000.0 * sampleRate).round(), + 0.7, + ); + } + + // Mix dry + wet + final output = Float64List(input.length); + final dryGain = 1.0 - mix; + for (var i = 0; i < input.length; i++) { + output[i] = (input[i] * dryGain + wet[i] * mix).clamp(-1.0, 1.0); + } + + return output; + } + + /// Apply reverb to stereo samples (each channel independently). + StereoSamples applyStereo( + StereoSamples input, { + int sampleRate = 44100, + }) { + return StereoSamples( + apply(input.left, sampleRate: sampleRate), + apply(input.right, sampleRate: sampleRate), + ); + } + + /// Comb filter: y[n] = x[n-d] + feedback * y[n-d], accumulated into out. + static void _addCombFilter( + Float64List input, + Float64List out, + int delay, + double feedback, + ) { + final buf = Float64List(input.length); + for (var i = 0; i < input.length; i++) { + final d = i - delay; + final x = d >= 0 ? input[d] : 0.0; + final y = d >= 0 ? buf[d] : 0.0; + buf[i] = x + feedback * y; + out[i] += buf[i]; + } + } + + /// Allpass filter: y[n] = -g*x[n] + x[n-d] + g*y[n-d] + static Float64List _allpassFilter( + Float64List input, + int delay, + double gain, + ) { + final output = Float64List(input.length); + for (var i = 0; i < input.length; i++) { + final d = i - delay; + final xd = d >= 0 ? input[d] : 0.0; + final yd = d >= 0 ? output[d] : 0.0; + output[i] = -gain * input[i] + xd + gain * yd; + } + return output; + } +} diff --git a/lib/src/audio/stereo.dart b/lib/src/audio/stereo.dart new file mode 100644 index 0000000..a5d2f3e --- /dev/null +++ b/lib/src/audio/stereo.dart @@ -0,0 +1,70 @@ +part of tui; + +/// Stereo audio buffer with separate left and right channels. +class StereoSamples { + final Float64List left; + final Float64List right; + + StereoSamples(this.left, this.right) : assert(left.length == right.length); + + int get length => left.length; + + /// Create stereo from a mono signal with constant-power panning. + /// + /// [pan]: -1.0 = full left, 0.0 = center, +1.0 = full right. + factory StereoSamples.fromMono(Float64List mono, {double pan = 0.0}) { + final p = pan.clamp(-1.0, 1.0); + // Constant-power panning law: equal loudness at center + final angle = (p + 1.0) / 2.0 * pi / 2.0; + final leftGain = cos(angle); + final rightGain = sin(angle); + + final left = Float64List(mono.length); + final right = Float64List(mono.length); + + for (var i = 0; i < mono.length; i++) { + left[i] = mono[i] * leftGain; + right[i] = mono[i] * rightGain; + } + + return StereoSamples(left, right); + } + + /// Additively mix multiple stereo buffers, clamped to [-1, 1]. + static StereoSamples mix(List buffers) { + if (buffers.isEmpty) { + return StereoSamples(Float64List(0), Float64List(0)); + } + + var maxLen = 0; + for (final buf in buffers) { + if (buf.length > maxLen) maxLen = buf.length; + } + + final left = Float64List(maxLen); + final right = Float64List(maxLen); + + for (final buf in buffers) { + for (var i = 0; i < buf.length; i++) { + left[i] += buf.left[i]; + right[i] += buf.right[i]; + } + } + + for (var i = 0; i < maxLen; i++) { + left[i] = left[i].clamp(-1.0, 1.0); + right[i] = right[i].clamp(-1.0, 1.0); + } + + return StereoSamples(left, right); + } + + /// Downmix stereo to mono (average of L+R). + Float64List toMono() { + final mono = Float64List(length); + for (var i = 0; i < length; i++) { + mono[i] = ((left[i] + right[i]) * 0.5).clamp(-1.0, 1.0); + } + return mono; + } +} diff --git a/lib/src/audio/synthesizer.dart b/lib/src/audio/synthesizer.dart new file mode 100644 index 0000000..72b24ca --- /dev/null +++ b/lib/src/audio/synthesizer.dart @@ -0,0 +1,182 @@ +part of tui; + +enum Waveform { + sine, + square, + triangle, + sawtooth, + noise; + + Float64List generate(double frequency, double duration, int sampleRate) { + final length = (duration * sampleRate).round(); + final samples = Float64List(length); + final rng = Random(); + + for (var i = 0; i < length; i++) { + final t = i / sampleRate; + switch (this) { + case Waveform.sine: + samples[i] = sin(2 * pi * frequency * t); + case Waveform.square: + final phase = (frequency * t) % 1.0; + samples[i] = phase < 0.5 ? 1.0 : -1.0; + case Waveform.triangle: + final phase = (frequency * t) % 1.0; + samples[i] = phase < 0.5 ? -1.0 + 4.0 * phase : 3.0 - 4.0 * phase; + case Waveform.sawtooth: + final phase = (frequency * t) % 1.0; + samples[i] = 2.0 * phase - 1.0; + case Waveform.noise: + samples[i] = rng.nextDouble() * 2.0 - 1.0; + } + } + return samples; + } +} + +/// ADSR envelope for shaping amplitude over time. +class Envelope { + final double attack; + final double decay; + final double sustain; + final double release; + + const Envelope({ + required this.attack, + required this.decay, + required this.sustain, + required this.release, + }); + + /// Short default envelope that prevents clicks. + const Envelope.preset() + : attack = 0.01, + decay = 0.05, + sustain = 0.7, + release = 0.05; + + double amplitudeAt(double t, double duration) { + if (t < 0) return 0.0; + + final releaseStart = duration - release; + + // Attack: ramp 0 -> 1 + if (t < attack) return attack == 0 ? 1.0 : t / attack; + + // Decay: ramp 1 -> sustain + if (t < attack + decay) { + final p = (t - attack) / (decay == 0 ? 1.0 : decay); + return 1.0 - (1.0 - sustain) * p; + } + + // Release: ramp sustain -> 0 + if (t >= releaseStart && releaseStart >= attack + decay) { + final p = release == 0 ? 1.0 : (t - releaseStart) / release; + return sustain * (1.0 - p.clamp(0.0, 1.0)); + } + + // Sustain + return sustain; + } +} + +abstract class Synthesizer { + /// Generate a single tone with optional [vibrato] and [timbre]. + /// + /// When [timbre] is provided, uses additive synthesis (harmonic series) + /// instead of the raw [waveform]. When [vibrato] is provided, modulates + /// pitch with a low-frequency oscillator. + static Float64List tone({ + required double frequency, + required double duration, + Waveform waveform = Waveform.sine, + Envelope? envelope, + double volume = 1.0, + int sampleRate = 44100, + Vibrato? vibrato, + Timbre? timbre, + }) { + envelope ??= const Envelope.preset(); + + // Timbre (additive synthesis) takes priority over basic waveform + final raw = timbre != null + ? timbre.generate(frequency, duration, sampleRate, vibrato: vibrato) + : _generateWithVibrato( + waveform, frequency, duration, sampleRate, vibrato); + + final length = raw.length; + final output = Float64List(length); + + for (var i = 0; i < length; i++) { + final t = i / sampleRate; + final amp = envelope.amplitudeAt(t, duration); + output[i] = (raw[i] * amp * volume).clamp(-1.0, 1.0); + } + return output; + } + + /// Generate waveform samples with optional vibrato modulation. + /// + /// When vibrato is null or noise waveform, falls back to plain generation. + /// Otherwise uses phase accumulation for smooth frequency modulation. + static Float64List _generateWithVibrato( + Waveform waveform, + double frequency, + double duration, + int sampleRate, + Vibrato? vibrato, + ) { + if (vibrato == null || vibrato.depth == 0 || waveform == Waveform.noise) { + return waveform.generate(frequency, duration, sampleRate); + } + + final length = (duration * sampleRate).round(); + final samples = Float64List(length); + var phase = 0.0; + + for (var i = 0; i < length; i++) { + final t = i / sampleRate; + final freq = vibrato.frequencyAt(t, frequency); + + switch (waveform) { + case Waveform.sine: + samples[i] = sin(2 * pi * phase); + case Waveform.square: + samples[i] = (phase % 1.0) < 0.5 ? 1.0 : -1.0; + case Waveform.triangle: + final p = phase % 1.0; + samples[i] = p < 0.5 ? -1.0 + 4.0 * p : 3.0 - 4.0 * p; + case Waveform.sawtooth: + samples[i] = 2.0 * (phase % 1.0) - 1.0; + case Waveform.noise: + break; // unreachable — handled above + } + + phase += freq / sampleRate; + phase %= 1.0; + } + + return samples; + } + + /// Additively mix multiple buffers, clamped to [-1, 1]. + static Float64List mix(List buffers) { + if (buffers.isEmpty) return Float64List(0); + + var maxLen = 0; + for (final buf in buffers) { + if (buf.length > maxLen) maxLen = buf.length; + } + + final output = Float64List(maxLen); + for (final buf in buffers) { + for (var i = 0; i < buf.length; i++) { + output[i] += buf[i]; + } + } + for (var i = 0; i < maxLen; i++) { + output[i] = output[i].clamp(-1.0, 1.0); + } + return output; + } +} diff --git a/lib/src/audio/tempo_map.dart b/lib/src/audio/tempo_map.dart new file mode 100644 index 0000000..4e1a072 --- /dev/null +++ b/lib/src/audio/tempo_map.dart @@ -0,0 +1,81 @@ +part of tui; + +/// A tempo change point in the music. +class TempoChange { + /// Beat position where this tempo takes effect. + final double beat; + + /// Target BPM at this point. + final int bpm; + + const TempoChange(this.beat, this.bpm); +} + +/// Maps tempo changes across a piece. Between change points, +/// BPM interpolates linearly (enabling ritardando/accelerando). +/// +/// ```dart +/// final map = TempoMap(120, [ +/// TempoChange(16.0, 100), // start slowing at beat 16 +/// TempoChange(20.0, 60), // reach 60 BPM by beat 20 +/// TempoChange(21.0, 120), // snap back to tempo +/// ]); +/// ``` +class TempoMap { + final int baseBpm; + final List _changes; + + TempoMap(this.baseBpm, List changes) + : _changes = List.of(changes)..sort((a, b) => a.beat.compareTo(b.beat)); + + /// BPM at a given beat position. Interpolates linearly between changes. + double bpmAt(double beat) { + if (_changes.isEmpty) return baseBpm.toDouble(); + + // Before first change: use baseBpm + if (beat <= _changes.first.beat) { + // Interpolate from baseBpm to first change + if (_changes.first.beat > 0) { + final t = beat / _changes.first.beat; + return baseBpm + t * (_changes.first.bpm - baseBpm); + } + return _changes.first.bpm.toDouble(); + } + + // After last change + if (beat >= _changes.last.beat) return _changes.last.bpm.toDouble(); + + // Between two change points + for (var i = 0; i < _changes.length - 1; i++) { + if (beat >= _changes[i].beat && beat < _changes[i + 1].beat) { + final range = _changes[i + 1].beat - _changes[i].beat; + final t = (beat - _changes[i].beat) / range; + final bpmDelta = _changes[i + 1].bpm - _changes[i].bpm; + return _changes[i].bpm + t * bpmDelta; + } + } + + return _changes.last.bpm.toDouble(); + } + + /// Convert a beat range to seconds, integrating over tempo changes. + /// + /// Uses trapezoid rule for numerical integration — accurate for + /// the linear interpolation used in [bpmAt]. + double beatsToSeconds(double startBeat, double numBeats) { + if (numBeats <= 0) return 0.0; + + const steps = 64; + final stepSize = numBeats / steps; + var seconds = 0.0; + + for (var i = 0; i < steps; i++) { + final b1 = startBeat + i * stepSize; + final b2 = startBeat + (i + 1) * stepSize; + // Trapezoid rule: average of seconds-per-beat at both endpoints + seconds += stepSize * 0.5 * (60.0 / bpmAt(b1) + 60.0 / bpmAt(b2)); + } + + return seconds; + } +} diff --git a/lib/src/audio/timbre.dart b/lib/src/audio/timbre.dart new file mode 100644 index 0000000..d5c97b8 --- /dev/null +++ b/lib/src/audio/timbre.dart @@ -0,0 +1,105 @@ +part of tui; + +/// A single harmonic partial in an instrument timbre. +class Harmonic { + /// Harmonic number: 1 = fundamental, 2 = octave, 3 = fifth+octave, etc. + final int number; + + /// Relative amplitude (0.0 - 1.0). + final double amplitude; + + const Harmonic(this.number, this.amplitude); +} + +/// Defines the harmonic content of an instrument via additive synthesis. +/// +/// Real instruments produce a series of overtones above the fundamental +/// that give each instrument its unique character. A violin sounds +/// different from a flute at the same pitch because of different +/// harmonic profiles. +class Timbre { + final List harmonics; + + const Timbre(this.harmonics); + + /// Violin: bright, rich harmonics with strong even and odd partials. + static const violin = Timbre([ + Harmonic(1, 1.0), + Harmonic(2, 0.55), + Harmonic(3, 0.42), + Harmonic(4, 0.28), + Harmonic(5, 0.18), + Harmonic(6, 0.12), + Harmonic(7, 0.07), + Harmonic(8, 0.04), + ]); + + /// Viola: warmer than violin, fewer high harmonics. + static const viola = Timbre([ + Harmonic(1, 1.0), + Harmonic(2, 0.48), + Harmonic(3, 0.32), + Harmonic(4, 0.18), + Harmonic(5, 0.1), + Harmonic(6, 0.05), + ]); + + /// Cello: deep, rich, strong low harmonics. + static const cello = Timbre([ + Harmonic(1, 1.0), + Harmonic(2, 0.65), + Harmonic(3, 0.48), + Harmonic(4, 0.32), + Harmonic(5, 0.22), + Harmonic(6, 0.14), + Harmonic(7, 0.08), + ]); + + /// Pure tone: fundamental only (equivalent to sine waveform). + static const pure = Timbre([Harmonic(1, 1.0)]); + + /// Generate samples using additive synthesis with phase accumulators. + /// + /// Uses per-sample phase accumulation (not `sin(2*pi*f*t)`) to prevent + /// discontinuities when frequency changes (e.g. vibrato). + Float64List generate( + double frequency, + double duration, + int sampleRate, { + Vibrato? vibrato, + }) { + final length = (duration * sampleRate).round(); + final samples = Float64List(length); + + // Normalization factor to keep output in [-1, 1] + var totalAmp = 0.0; + for (final h in harmonics) { + totalAmp += h.amplitude; + } + final norm = totalAmp > 0 ? 1.0 / totalAmp : 1.0; + + // Phase accumulators per harmonic (prevents clicks on freq change) + final phases = List.filled(harmonics.length, 0.0); + final nyquist = sampleRate / 2.0; + + for (var i = 0; i < length; i++) { + final t = i / sampleRate; + final freq = vibrato?.frequencyAt(t, frequency) ?? frequency; + + var sample = 0.0; + for (var h = 0; h < harmonics.length; h++) { + final harmonic = harmonics[h]; + final hFreq = freq * harmonic.number; + if (hFreq >= nyquist) continue; + + sample += harmonic.amplitude * sin(2 * pi * phases[h]); + phases[h] += hFreq / sampleRate; + phases[h] %= 1.0; + } + + samples[i] = sample * norm; + } + + return samples; + } +} diff --git a/lib/src/audio/vibrato.dart b/lib/src/audio/vibrato.dart new file mode 100644 index 0000000..a7f875b --- /dev/null +++ b/lib/src/audio/vibrato.dart @@ -0,0 +1,44 @@ +part of tui; + +/// Pitch modulation via low-frequency oscillator (LFO). +/// +/// Vibrato adds warmth and life to sustained notes — critical for +/// realistic string sounds. [rate] controls oscillation speed, +/// [depth] controls pitch deviation, [delay] lets the note start straight. +class Vibrato { + /// LFO frequency in Hz. Strings typically use 5-7 Hz. + final double rate; + + /// Pitch deviation in cents (100 cents = 1 semitone). + /// Typical string vibrato is ±15-30 cents. + final double depth; + + /// Delay in seconds before vibrato begins. + /// Strings often start a note straight, then add vibrato. + final double delay; + + const Vibrato({ + this.rate = 5.5, + this.depth = 20.0, + this.delay = 0.1, + }); + + /// Subtle, fast vibrato for violin. + static const violin = Vibrato(rate: 5.5, depth: 22.0, delay: 0.08); + + /// Wider, slower vibrato for viola. + static const viola = Vibrato(rate: 5.0, depth: 20.0, delay: 0.1); + + /// Deep, slow vibrato for cello. + static const cello = Vibrato(rate: 4.8, depth: 18.0, delay: 0.12); + + /// Compute instantaneous frequency at time [t] given base [frequency]. + /// + /// Returns unmodulated frequency before [delay] or when [depth] is zero. + double frequencyAt(double t, double frequency) { + if (depth == 0 || rate == 0 || t < delay) return frequency; + final vibratoT = t - delay; + final cents = depth * sin(2 * pi * rate * vibratoT); + return frequency * pow(2.0, cents / 1200.0); + } +} diff --git a/lib/src/audio/wav_writer.dart b/lib/src/audio/wav_writer.dart new file mode 100644 index 0000000..cd70c9f --- /dev/null +++ b/lib/src/audio/wav_writer.dart @@ -0,0 +1,91 @@ +part of tui; + +/// Encodes audio samples into PCM WAV files. +abstract class WavWriter { + /// Encodes mono float64 samples into a 16-bit mono PCM WAV file. + static Uint8List encode(Float64List samples, {int sampleRate = 44100}) { + return _encodeWav(samples: samples, channels: 1, sampleRate: sampleRate); + } + + /// Encodes stereo samples into a 16-bit stereo PCM WAV file. + static Uint8List encodeStereo( + StereoSamples stereo, { + int sampleRate = 44100, + }) { + // Interleave L/R channels: [L0, R0, L1, R1, ...] + final interleaved = Float64List(stereo.length * 2); + for (var i = 0; i < stereo.length; i++) { + interleaved[i * 2] = stereo.left[i]; + interleaved[i * 2 + 1] = stereo.right[i]; + } + return _encodeWav( + samples: interleaved, + channels: 2, + sampleRate: sampleRate, + ); + } + + static Uint8List _encodeWav({ + required Float64List samples, + required int channels, + required int sampleRate, + }) { + final numSamples = samples.length; + final dataSize = numSamples * 2; + final fileSize = 44 + dataSize; + final byteRate = sampleRate * channels * 2; + final blockAlign = channels * 2; + + final buf = ByteData(fileSize); + var o = 0; + + // RIFF header + buf.setUint8(o++, 0x52); // R + buf.setUint8(o++, 0x49); // I + buf.setUint8(o++, 0x46); // F + buf.setUint8(o++, 0x46); // F + buf.setUint32(o, fileSize - 8, Endian.little); + o += 4; + buf.setUint8(o++, 0x57); // W + buf.setUint8(o++, 0x41); // A + buf.setUint8(o++, 0x56); // V + buf.setUint8(o++, 0x45); // E + + // fmt sub-chunk + buf.setUint8(o++, 0x66); // f + buf.setUint8(o++, 0x6D); // m + buf.setUint8(o++, 0x74); // t + buf.setUint8(o++, 0x20); // (space) + buf.setUint32(o, 16, Endian.little); + o += 4; // sub-chunk size + buf.setUint16(o, 1, Endian.little); + o += 2; // PCM format + buf.setUint16(o, channels, Endian.little); + o += 2; // channels + buf.setUint32(o, sampleRate, Endian.little); + o += 4; + buf.setUint32(o, byteRate, Endian.little); + o += 4; // byte rate + buf.setUint16(o, blockAlign, Endian.little); + o += 2; // block align + buf.setUint16(o, 16, Endian.little); + o += 2; // bits per sample + + // data sub-chunk + buf.setUint8(o++, 0x64); // d + buf.setUint8(o++, 0x61); // a + buf.setUint8(o++, 0x74); // t + buf.setUint8(o++, 0x61); // a + buf.setUint32(o, dataSize, Endian.little); + o += 4; + + // PCM samples + for (var i = 0; i < numSamples; i++) { + final int16 = (samples[i].clamp(-1.0, 1.0) * 32767).round(); + buf.setInt16(o, int16, Endian.little); + o += 2; + } + + return buf.buffer.asUint8List(); + } +} diff --git a/lib/src/bigtext.dart b/lib/src/bigtext.dart new file mode 100644 index 0000000..bdffc08 --- /dev/null +++ b/lib/src/bigtext.dart @@ -0,0 +1,773 @@ +part of tui; + +enum BigTextFont { + block, + slim, + chunky, + shadow, +} + +class RGB { + final int r, g, b; + const RGB(this.r, this.g, this.b); + + RGB lerp(RGB other, double t) { + return RGB( + (r + (other.r - r) * t).round(), + (g + (other.g - g) * t).round(), + (b + (other.b - b) * t).round(), + ); + } + + String toAnsi() => '38;2;$r;$g;$b'; +} + +class Gradients { + static const rainbow = [ + RGB(255, 0, 0), + RGB(255, 127, 0), + RGB(255, 255, 0), + RGB(0, 255, 0), + RGB(0, 0, 255), + RGB(139, 0, 255), + ]; + + static const sunset = [ + RGB(255, 100, 100), + RGB(255, 150, 50), + RGB(255, 200, 100), + ]; + + static const ocean = [ + RGB(0, 100, 150), + RGB(0, 150, 200), + RGB(100, 200, 255), + ]; + + static const purple = [ + RGB(100, 100, 255), + RGB(150, 100, 200), + RGB(200, 100, 150), + ]; + + static const cyan = [ + RGB(0, 200, 200), + RGB(0, 255, 255), + RGB(100, 255, 255), + ]; + + static const fire = [ + RGB(255, 0, 0), + RGB(255, 100, 0), + RGB(255, 200, 0), + ]; + + static const forest = [ + RGB(0, 100, 0), + RGB(0, 150, 50), + RGB(50, 200, 100), + ]; + + static const gemini = [ + RGB(100, 130, 255), + RGB(150, 120, 200), + RGB(200, 150, 180), + ]; + + static const matrix = [ + RGB(0, 80, 0), + RGB(0, 180, 0), + RGB(100, 255, 100), + ]; +} + +/// Displays text as large ASCII art letters with optional subtitle and border. +class BigText extends View { + String _txt; + + String get content => _txt; + set content(String value) { + _txt = value.toUpperCase(); + update(); + } + + BigTextFont font; + + String color = '0'; + + List? gradient; + + int letterSpacing = 1; + + String? subtitle; + + String subtitleColor = '8'; + + bool showBorder = false; + + String borderColor = '36'; + + bool centered = true; + + BigText( + this._txt, { + this.font = BigTextFont.block, + this.gradient, + this.subtitle, + this.showBorder = false, + this.centered = true, + }) { + _txt = _txt.toUpperCase(); + } + + static List generateLines(String text, + {BigTextFont font = BigTextFont.shadow, int letterSpacing = 1}) { + text = text.toUpperCase(); + var fontData = _fontData(font); + var charHeight = fontData.height; + var lines = List.generate(charHeight, (_) => StringBuffer()); + + for (var i = 0; i < text.length; i++) { + var char = text[i]; + var glyph = fontData.glyphs[char] ?? fontData.glyphs[' ']!; + + for (var row = 0; row < charHeight; row++) { + if (row < glyph.length) { + lines[row].write(glyph[row]); + } + if (i < text.length - 1) { + lines[row].write(' ' * letterSpacing); + } + } + } + + return lines.map((sb) => sb.toString()).toList(); + } + + static _FontData _fontData(BigTextFont font) { + switch (font) { + case BigTextFont.block: + return _fontBlock; + case BigTextFont.slim: + return _fontSlim; + case BigTextFont.chunky: + return _fontChunky; + case BigTextFont.shadow: + return _fontShadow; + } + } + + @override + void update() { + text = []; + if (width < 1 || height < 1) return; + + var charHeight = _fontData(font).height; + var lines = generateLines(_txt, font: font, letterSpacing: letterSpacing); + + var textWidth = lines.isNotEmpty ? lines[0].length : 0; + var totalHeight = charHeight; + if (subtitle != null) totalHeight += 2; + if (showBorder) totalHeight += 2; + + var y = 0; + var contentWidth = showBorder ? width - 2 : width; + var xOffset = showBorder ? 1 : 0; + + if (showBorder) { + var borderWidth = max(textWidth + 4, (subtitle?.length ?? 0) + 6); + if (borderWidth > width) borderWidth = width; + var borderLine = + '${BoxChars.doubleTL}${BoxChars.doubleH * (borderWidth - 2)}${BoxChars.doubleTR}'; + var bx = centered ? (width - borderWidth) ~/ 2 : 0; + text.add(Text(borderLine) + ..color = borderColor + ..position = Position(bx, y++)); + } + + for (var row = 0; + row < charHeight && y < height - (showBorder ? 1 : 0); + row++) { + var line = lines[row].toString(); + if (line.length > contentWidth) { + line = line.substring(0, contentWidth); + } + + var x = xOffset; + if (centered) { + x = (width - line.length) ~/ 2; + if (x < xOffset) x = xOffset; + } + + if (showBorder) { + var borderWidth = max(textWidth + 4, (subtitle?.length ?? 0) + 6); + if (borderWidth > width) borderWidth = width; + var bx = centered ? (width - borderWidth) ~/ 2 : 0; + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx, y)); + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx + borderWidth - 1, y)); + } + + if (gradient != null && gradient!.isNotEmpty) { + _renderGradientLine(line, x, y); + } else { + text.add(Text(line) + ..color = color + ..position = Position(x, y)); + } + y++; + } + + if (subtitle != null && y < height - (showBorder ? 1 : 0)) { + y++; + + if (showBorder) { + var borderWidth = max(textWidth + 4, subtitle!.length + 6); + if (borderWidth > width) borderWidth = width; + var bx = centered ? (width - borderWidth) ~/ 2 : 0; + + var subBoxWidth = subtitle!.length + 4; + var subX = centered ? (width - subBoxWidth) ~/ 2 : bx + 2; + + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx, y)); + text.add(Text( + '${BoxChars.lightTL}${BoxChars.lightH * (subBoxWidth - 2)}${BoxChars.lightTR}') + ..color = subtitleColor + ..position = Position(subX, y)); + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx + borderWidth - 1, y)); + y++; + + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx, y)); + text.add(Text(BoxChars.lightV) + ..color = subtitleColor + ..position = Position(subX, y)); + text.add(Text(' ${subtitle!} ') + ..color = subtitleColor + ..position = Position(subX + 1, y)); + text.add(Text(BoxChars.lightV) + ..color = subtitleColor + ..position = Position(subX + subBoxWidth - 1, y)); + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx + borderWidth - 1, y)); + y++; + + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx, y)); + text.add(Text( + '${BoxChars.lightBL}${BoxChars.lightH * (subBoxWidth - 2)}${BoxChars.lightBR}') + ..color = subtitleColor + ..position = Position(subX, y)); + text.add(Text(BoxChars.doubleV) + ..color = borderColor + ..position = Position(bx + borderWidth - 1, y)); + y++; + } else { + var subX = centered ? (width - subtitle!.length) ~/ 2 : 0; + text.add(Text(subtitle!) + ..color = subtitleColor + ..position = Position(subX, y)); + y++; + } + } + + if (showBorder && y < height) { + var borderWidth = max(textWidth + 4, (subtitle?.length ?? 0) + 6); + if (borderWidth > width) borderWidth = width; + var borderLine = + '${BoxChars.doubleBL}${BoxChars.doubleH * (borderWidth - 2)}${BoxChars.doubleBR}'; + var bx = centered ? (width - borderWidth) ~/ 2 : 0; + text.add(Text(borderLine) + ..color = borderColor + ..position = Position(bx, y)); + } + } + + void _renderGradientLine(String line, int startX, int row) { + if (line.isEmpty) return; + + for (var i = 0; i < line.length; i++) { + var char = line[i]; + if (char == ' ') continue; + + var gradientColor = _getGradientColor(i, line.length); + text.add(Text(char) + ..color = gradientColor.toAnsi() + ..position = Position(startX + i, row)); + } + } + + RGB _getGradientColor(int position, int totalWidth) { + return interpolateGradient(gradient!, position, totalWidth); + } + + /// Interpolate a color from [colors] at [position] of [totalWidth]. + static RGB interpolateGradient( + List colors, + int position, + int totalWidth, + ) { + if (colors.isEmpty) return RGB(255, 255, 255); + if (colors.length == 1) return colors.first; + + var t = totalWidth > 1 ? position / (totalWidth - 1) : 0.0; + var scaledT = t * (colors.length - 1); + var index = scaledT.floor(); + var localT = scaledT - index; + + if (index >= colors.length - 1) return colors.last; + return colors[index].lerp(colors[index + 1], localT); + } +} + +class _FontData { + final int height; + final Map> glyphs; + + _FontData(this.height, this.glyphs); +} + +final _fontShadow = _FontData(6, { + 'A': [ + ' █████╗ ', + '██╔══██╗', + '███████║', + '██╔══██║', + '██║ ██║', + '╚═╝ ╚═╝', + ], + 'B': [ + '██████╗ ', + '██╔══██╗', + '██████╔╝', + '██╔══██╗', + '██████╔╝', + '╚═════╝ ', + ], + 'C': [ + ' ██████╗', + '██╔════╝', + '██║ ', + '██║ ', + '╚██████╗', + ' ╚═════╝', + ], + 'D': [ + '██████╗ ', + '██╔══██╗', + '██║ ██║', + '██║ ██║', + '██████╔╝', + '╚═════╝ ', + ], + 'E': [ + '███████╗', + '██╔════╝', + '█████╗ ', + '██╔══╝ ', + '███████╗', + '╚══════╝', + ], + 'F': [ + '███████╗', + '██╔════╝', + '█████╗ ', + '██╔══╝ ', + '██║ ', + '╚═╝ ', + ], + 'G': [ + ' ██████╗ ', + '██╔════╝ ', + '██║ ███╗', + '██║ ██║', + '╚██████╔╝', + ' ╚═════╝ ', + ], + 'H': [ + '██╗ ██╗', + '██║ ██║', + '███████║', + '██╔══██║', + '██║ ██║', + '╚═╝ ╚═╝', + ], + 'I': [ + '██╗', + '██║', + '██║', + '██║', + '██║', + '╚═╝', + ], + 'J': [ + ' ██╗', + ' ██║', + ' ██║', + '██ ██║', + '╚█████╔╝', + ' ╚════╝ ', + ], + 'K': [ + '██╗ ██╗', + '██║ ██╔╝', + '█████╔╝ ', + '██╔═██╗ ', + '██║ ██╗', + '╚═╝ ╚═╝', + ], + 'L': [ + '██╗ ', + '██║ ', + '██║ ', + '██║ ', + '███████╗', + '╚══════╝', + ], + 'M': [ + '███╗ ███╗', + '████╗ ████║', + '██╔████╔██║', + '██║╚██╔╝██║', + '██║ ╚═╝ ██║', + '╚═╝ ╚═╝', + ], + 'N': [ + '███╗ ██╗', + '████╗ ██║', + '██╔██╗ ██║', + '██║╚██╗██║', + '██║ ╚████║', + '╚═╝ ╚═══╝', + ], + 'O': [ + ' ██████╗ ', + '██╔═══██╗', + '██║ ██║', + '██║ ██║', + '╚██████╔╝', + ' ╚═════╝ ', + ], + 'P': [ + '██████╗ ', + '██╔══██╗', + '██████╔╝', + '██╔═══╝ ', + '██║ ', + '╚═╝ ', + ], + 'Q': [ + ' ██████╗ ', + '██╔═══██╗', + '██║ ██║', + '██║▄▄ ██║', + '╚██████╔╝', + ' ╚══▀▀═╝ ', + ], + 'R': [ + '██████╗ ', + '██╔══██╗', + '██████╔╝', + '██╔══██╗', + '██║ ██║', + '╚═╝ ╚═╝', + ], + 'S': [ + '███████╗', + '██╔════╝', + '███████╗', + '╚════██║', + '███████║', + '╚══════╝', + ], + 'T': [ + '████████╗', + '╚══██╔══╝', + ' ██║ ', + ' ██║ ', + ' ██║ ', + ' ╚═╝ ', + ], + 'U': [ + '██╗ ██╗', + '██║ ██║', + '██║ ██║', + '██║ ██║', + '╚██████╔╝', + ' ╚═════╝ ', + ], + 'V': [ + '██╗ ██╗', + '██║ ██║', + '██║ ██║', + '╚██╗ ██╔╝', + ' ╚████╔╝ ', + ' ╚═══╝ ', + ], + 'W': [ + '██╗ ██╗', + '██║ ██║', + '██║ █╗ ██║', + '██║███╗██║', + '╚███╔███╔╝', + ' ╚══╝╚══╝ ', + ], + 'X': [ + '██╗ ██╗', + '╚██╗██╔╝', + ' ╚███╔╝ ', + ' ██╔██╗ ', + '██╔╝ ██╗', + '╚═╝ ╚═╝', + ], + 'Y': [ + '██╗ ██╗', + '╚██╗ ██╔╝', + ' ╚████╔╝ ', + ' ╚██╔╝ ', + ' ██║ ', + ' ╚═╝ ', + ], + 'Z': [ + '███████╗', + '╚══███╔╝', + ' ███╔╝ ', + ' ███╔╝ ', + '███████╗', + '╚══════╝', + ], + '0': [ + ' ██████╗ ', + '██╔═████╗', + '██║██╔██║', + '████╔╝██║', + '╚██████╔╝', + ' ╚═════╝ ', + ], + '1': [ + ' ██╗', + '███║', + '╚██║', + ' ██║', + ' ██║', + ' ╚═╝', + ], + '2': [ + '██████╗ ', + '╚════██╗', + ' █████╔╝', + '██╔═══╝ ', + '███████╗', + '╚══════╝', + ], + '3': [ + '██████╗ ', + '╚════██╗', + ' █████╔╝', + ' ╚═══██╗', + '██████╔╝', + '╚═════╝ ', + ], + '4': [ + '██╗ ██╗', + '██║ ██║', + '███████║', + '╚════██║', + ' ██║', + ' ╚═╝', + ], + '5': [ + '███████╗', + '██╔════╝', + '███████╗', + '╚════██║', + '███████║', + '╚══════╝', + ], + '6': [ + ' ██████╗', + '██╔════╝', + '███████╗', + '██╔══██║', + '╚█████╔╝', + ' ╚════╝ ', + ], + '7': [ + '███████╗', + '╚════██║', + ' ██╔╝', + ' ██╔╝ ', + ' ██║ ', + ' ╚═╝ ', + ], + '8': [ + ' █████╗ ', + '██╔══██╗', + '╚█████╔╝', + '██╔══██╗', + '╚█████╔╝', + ' ╚════╝ ', + ], + '9': [ + ' █████╗ ', + '██╔══██╗', + '╚██████║', + ' ╚═══██║', + ' █████╔╝', + ' ╚════╝ ', + ], + ' ': [ + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ], + '!': [ + '██╗', + '██║', + '██║', + '╚═╝', + '██╗', + '╚═╝', + ], + '.': [ + ' ', + ' ', + ' ', + ' ', + '██╗', + '╚═╝', + ], + '-': [ + ' ', + ' ', + '█████╗', + '╚════╝', + ' ', + ' ', + ], + ':': [ + ' ', + '██╗', + '╚═╝', + '██╗', + '╚═╝', + ' ', + ], +}); + +final _fontBlock = _FontData(5, { + 'A': ['█████', '█ █', '█████', '█ █', '█ █'], + 'B': ['████ ', '█ █', '████ ', '█ █', '████ '], + 'C': ['█████', '█ ', '█ ', '█ ', '█████'], + 'D': ['████ ', '█ █', '█ █', '█ █', '████ '], + 'E': ['█████', '█ ', '████ ', '█ ', '█████'], + 'F': ['█████', '█ ', '████ ', '█ ', '█ '], + 'G': ['█████', '█ ', '█ ██', '█ █', '█████'], + 'H': ['█ █', '█ █', '█████', '█ █', '█ █'], + 'I': ['█████', ' █ ', ' █ ', ' █ ', '█████'], + 'J': ['█████', ' █', ' █', '█ █', '█████'], + 'K': ['█ █', '█ █ ', '███ ', '█ █ ', '█ █'], + 'L': ['█ ', '█ ', '█ ', '█ ', '█████'], + 'M': ['█ █', '██ ██', '█ █ █', '█ █', '█ █'], + 'N': ['█ █', '██ █', '█ █ █', '█ ██', '█ █'], + 'O': ['█████', '█ █', '█ █', '█ █', '█████'], + 'P': ['█████', '█ █', '█████', '█ ', '█ '], + 'Q': ['█████', '█ █', '█ █', '█ █ ', '███ █'], + 'R': ['█████', '█ █', '█████', '█ █ ', '█ █'], + 'S': ['█████', '█ ', '█████', ' █', '█████'], + 'T': ['█████', ' █ ', ' █ ', ' █ ', ' █ '], + 'U': ['█ █', '█ █', '█ █', '█ █', '█████'], + 'V': ['█ █', '█ █', '█ █', ' █ █ ', ' █ '], + 'W': ['█ █', '█ █', '█ █ █', '██ ██', '█ █'], + 'X': ['█ █', ' █ █ ', ' █ ', ' █ █ ', '█ █'], + 'Y': ['█ █', ' █ █ ', ' █ ', ' █ ', ' █ '], + 'Z': ['█████', ' █ ', ' █ ', ' █ ', '█████'], + '0': ['█████', '█ █', '█ █', '█ █', '█████'], + '1': [' █ ', ' ██ ', ' █ ', ' █ ', '█████'], + '2': ['█████', ' █', '█████', '█ ', '█████'], + '3': ['█████', ' █', '█████', ' █', '█████'], + '4': ['█ █', '█ █', '█████', ' █', ' █'], + '5': ['█████', '█ ', '█████', ' █', '█████'], + '6': ['█████', '█ ', '█████', '█ █', '█████'], + '7': ['█████', ' █', ' █ ', ' █ ', ' █ '], + '8': ['█████', '█ █', '█████', '█ █', '█████'], + '9': ['█████', '█ █', '█████', ' █', '█████'], + ' ': [' ', ' ', ' ', ' ', ' '], + '!': [' █ ', ' █ ', ' █ ', ' ', ' █ '], + '.': [' ', ' ', ' ', ' ', ' █ '], + '-': [' ', ' ', '█████', ' ', ' '], +}); + +final _fontSlim = _FontData(5, { + 'A': ['╔═╗', '╠═╣', '║ ║', '║ ║', '╩ ╩'], + 'B': ['╔═╗', '╠═╣', '╠═╣', '║ ║', '╚═╝'], + 'C': ['╔══', '║ ', '║ ', '║ ', '╚══'], + 'D': ['╔═╗', '║ ║', '║ ║', '║ ║', '╚═╝'], + 'E': ['╔══', '╠═ ', '║ ', '║ ', '╚══'], + 'F': ['╔══', '╠═ ', '║ ', '║ ', '╩ '], + 'G': ['╔══', '║ ', '║ ╗', '║ ║', '╚═╝'], + 'H': ['║ ║', '╠═╣', '║ ║', '║ ║', '╩ ╩'], + 'I': ['═╦═', ' ║ ', ' ║ ', ' ║ ', '═╩═'], + 'J': ['══╗', ' ║', ' ║', '╔═╣', '╚═╝'], + 'K': ['║ ╱', '╠╣ ', '║ ╲', '║ ║', '╩ ╩'], + 'L': ['║ ', '║ ', '║ ', '║ ', '╚══'], + 'M': ['╔╗╔╗', '║╚╝║', '║ ║', '║ ║', '╩ ╩'], + 'N': ['╔╗ ║', '║╚╗║', '║ ╚╣', '║ ║', '╩ ╩'], + 'O': ['╔═╗', '║ ║', '║ ║', '║ ║', '╚═╝'], + 'P': ['╔═╗', '╠═╝', '║ ', '║ ', '╩ '], + 'Q': ['╔═╗', '║ ║', '║ ║', '║╚╗', '╚═╩'], + 'R': ['╔═╗', '╠═╣', '║╚╗', '║ ║', '╩ ╩'], + 'S': ['╔══', '╚═╗', ' ║', '╔═╝', '╚══'], + 'T': ['═╦═', ' ║ ', ' ║ ', ' ║ ', ' ╩ '], + 'U': ['║ ║', '║ ║', '║ ║', '║ ║', '╚═╝'], + 'V': ['║ ║', '║ ║', '║ ║', '╚╦╝', ' ╩ '], + 'W': ['║ ║', '║ ║', '║╔╗║', '╠╝╚╣', '╩ ╩'], + 'X': ['╲ ╱', ' ╳ ', '╱ ╲', '║ ║', '╩ ╩'], + 'Y': ['╲ ╱', ' ║ ', ' ║ ', ' ║ ', ' ╩ '], + 'Z': ['══╗', ' ╔╝', '╔╝ ', '║ ', '╚══'], + ' ': [' ', ' ', ' ', ' ', ' '], +}); + +final _fontChunky = _FontData(4, { + 'A': ['▄▀▀▄', '█▄▄█', '█ █', '▀ ▀'], + 'B': ['█▀▀▄', '█▄▄▀', '█ █', '▀▀▀ '], + 'C': ['▄▀▀▀', '█ ', '█ ', '▀▀▀▀'], + 'D': ['█▀▀▄', '█ █', '█ █', '▀▀▀ '], + 'E': ['█▀▀▀', '█▄▄ ', '█ ', '▀▀▀▀'], + 'F': ['█▀▀▀', '█▄▄ ', '█ ', '▀ '], + 'G': ['▄▀▀▀', '█ ▀█', '█ █', '▀▀▀▀'], + 'H': ['█ █', '█▄▄█', '█ █', '▀ ▀'], + 'I': ['▀█▀', ' █ ', ' █ ', '▀▀▀'], + 'J': [' ▀█', ' █', '█ █', '▀▀▀ '], + 'K': ['█ ▄▀', '██ ', '█ ▀▄', '▀ ▀'], + 'L': ['█ ', '█ ', '█ ', '▀▀▀▀'], + 'M': ['█▄▄█', '█▀▀█', '█ █', '▀ ▀'], + 'N': ['█▄ █', '█ ▀█', '█ █', '▀ ▀'], + 'O': ['▄▀▀▄', '█ █', '█ █', '▀▀▀▀'], + 'P': ['█▀▀▄', '█▄▄▀', '█ ', '▀ '], + 'Q': ['▄▀▀▄', '█ █', '█ ▀█', '▀▀▀▀'], + 'R': ['█▀▀▄', '█▄▄▀', '█ ▀▄', '▀ ▀'], + 'S': ['▄▀▀▀', '▀▀▀▄', '▄ █', '▀▀▀ '], + 'T': ['▀█▀', ' █ ', ' █ ', ' ▀ '], + 'U': ['█ █', '█ █', '█ █', '▀▀▀▀'], + 'V': ['█ █', '█ █', '▀▄▄▀', ' ▀▀ '], + 'W': ['█ █', '█ █', '█▄▄█', '▀▀▀▀'], + 'X': ['▀▄▄▀', ' ▀▀ ', '▄▀▀▄', '▀ ▀'], + 'Y': ['█ █', '▀▄▄▀', ' █▀ ', ' ▀ '], + 'Z': ['▀▀▀█', ' ▄▀ ', '▄▀ ', '▀▀▀▀'], + ' ': [' ', ' ', ' ', ' '], + '!': [' █ ', ' █ ', ' ', ' ▀ '], +}); diff --git a/lib/src/canvas.dart b/lib/src/canvas.dart index 80fbe19..9564d63 100644 --- a/lib/src/canvas.dart +++ b/lib/src/canvas.dart @@ -1,44 +1,53 @@ part of tui; -/** - * A Canvas provides constrained read/write access to the Screen. - * - * When a container view is ready to render its child views it - * will create a new Canvas object that is smaller than the - * container's Canvas but hopefuly large enough for child. It - * will then call the child's render() method passing in the - * Canvas. - * - * A View's render() method should not try to write outside the - * canvas width/height boundary. - * - */ -class Canvas extends Object with Sizable, Positionable { - - Screen _screen; - - Canvas(Size size, Position offset, this._screen) { +/// A Canvas provides constrained read/write access to the [Screen]. +/// +/// All writes are clipped to the canvas bounds AND the inherited +/// clip region from the parent canvas. +class Canvas with Sizable, Positionable { + final Screen _scr; + final int _clipRight; + final int _clipBottom; + + Canvas(Size size, Position offset, this._scr, [int? clipRight, int? clipBottom]) + : _clipRight = clipRight ?? (offset.x + size.width), + _clipBottom = clipBottom ?? (offset.y + size.height) { this.size = size; this.position = offset; } - // Create a new canvas offset from this one. Canvas canvas(Size size, Position offset) { - offset.x += x; - offset.y += y; - return new Canvas(size!=null?size:new Size.from(this.size), offset, this._screen); + var combinedOffset = Position(offset.x + x, offset.y + y); + return Canvas( + size, + combinedOffset, + _scr, + min(combinedOffset.x + size.width, _clipRight), + min(combinedOffset.y + size.height, _clipBottom), + ); } - // Returns true if there is already something written at this point. - bool occluded(int x, int y) => - _screen.occluded(position.x+x, position.y+y); + /// Returns absolute screen coordinates, or null if clipped. + (int, int)? _abs(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return null; + var absX = position.x + x; + var absY = position.y + y; + if (absX >= _clipRight || absY >= _clipBottom) return null; + return (absX, absY); + } - // Writes to a point on the canvas. - void write(int x, int y, String char) => - _screen.write(position.x+x, position.y+y, char); + bool occluded(int x, int y) { + var pt = _abs(x, y); + return pt == null || _scr.occluded(pt.$1, pt.$2); + } - // Returns value at this point. - String stringAt(int x, int y) => - _screen.stringAt(position.x+x, position.y+y); + void write(int x, int y, String char) { + var pt = _abs(x, y); + if (pt != null) _scr.write(pt.$1, pt.$2, char); + } + String stringAt(int x, int y) { + var pt = _abs(x, y); + return pt != null ? _scr.stringAt(pt.$1, pt.$2) : ''; + } } diff --git a/lib/src/escape.dart b/lib/src/escape.dart index be32683..b503a3c 100644 --- a/lib/src/escape.dart +++ b/lib/src/escape.dart @@ -1,7 +1,6 @@ part of tui; abstract class ANSI { - static const String ESC = "\x1b["; static const String CURSOR_HOME = "${ESC}H"; @@ -16,34 +15,35 @@ abstract class ANSI { static const String ERASE_SCREEN = "${ESC}2J"; static const String ERASE_TO_END_OF_LINE = "${ESC}K"; - String set_cursor_position(int x, int y) { - return ESC+"${y+1};${x+1}H"; + static const String BRACKETED_PASTE_ON = "${ESC}?2004h"; + static const String BRACKETED_PASTE_OFF = "${ESC}?2004l"; + + static String setCursorPosition(int x, int y) { + return ESC + "${y + 1};${x + 1}H"; } - String move_cursor_up(int x) { + static String moveCursorUp(int x) { if (x < 1) return ""; - return ESC+"${x}A"; + return ESC + "${x}A"; } - String move_cursor_down(int x) { + static String moveCursorDown(int x) { if (x < 1) return ""; - return ESC+"${x}B"; + return ESC + "${x}B"; } - String move_cursor_right(int x) { + static String moveCursorRight(int x) { if (x < 1) return ""; - return ESC+"${x}C"; + return ESC + "${x}C"; } - String move_cursor_left(int x) { + static String moveCursorLeft(int x) { if (x < 1) return ""; - return ESC+"${x}D"; + return ESC + "${x}D"; } - } abstract class KeyCode { - static const String UP = "${ANSI.ESC}A"; static const String DOWN = "${ANSI.ESC}B"; static const String RIGHT = "${ANSI.ESC}C"; @@ -52,6 +52,12 @@ abstract class KeyCode { static const String HOME = "${ANSI.ESC}H"; static const String END = "${ANSI.ESC}F"; + static const String TAB = "\t"; + static const String SHIFT_TAB = "${ANSI.ESC}Z"; + static const String ENTER = "\n"; + static const String ESCAPE = "\x1b"; + static const String BACKSPACE = "\x7f"; + static const String F1 = "${ANSI.ESC}M"; static const String F2 = "${ANSI.ESC}N"; static const String F3 = "${ANSI.ESC}O"; @@ -71,5 +77,108 @@ abstract class KeyCode { static const String PAGE_DOWN = "${ANSI.ESC}6~"; static const String SPACE = " "; +} + +/// ANSI color codes for terminal output. +abstract class Colors { + static const String reset = '\x1B[0m'; + static const String bold = '\x1B[1m'; + static const String dim = '\x1B[2m'; + static const String italic = '\x1B[3m'; + static const String underline = '\x1B[4m'; + static const String blink = '\x1B[5m'; + static const String inverse = '\x1B[7m'; + + static const String black = '\x1B[30m'; + static const String red = '\x1B[31m'; + static const String green = '\x1B[32m'; + static const String yellow = '\x1B[33m'; + static const String blue = '\x1B[34m'; + static const String magenta = '\x1B[35m'; + static const String cyan = '\x1B[36m'; + static const String white = '\x1B[37m'; + + static const String brightBlack = '\x1B[90m'; + static const String brightRed = '\x1B[91m'; + static const String brightGreen = '\x1B[92m'; + static const String brightYellow = '\x1B[93m'; + static const String brightBlue = '\x1B[94m'; + static const String brightMagenta = '\x1B[95m'; + static const String brightCyan = '\x1B[96m'; + static const String brightWhite = '\x1B[97m'; + + static const String bgBlack = '\x1B[40m'; + static const String bgRed = '\x1B[41m'; + static const String bgGreen = '\x1B[42m'; + static const String bgYellow = '\x1B[43m'; + static const String bgBlue = '\x1B[44m'; + static const String bgMagenta = '\x1B[45m'; + static const String bgCyan = '\x1B[46m'; + static const String bgWhite = '\x1B[47m'; + + static String fg256(int n) => '\x1B[38;5;${n}m'; + static String bg256(int n) => '\x1B[48;5;${n}m'; + static String fgRgb(int r, int g, int b) => '\x1B[38;2;$r;$g;${b}m'; + static String bgRgb(int r, int g, int b) => '\x1B[48;2;$r;$g;${b}m'; +} +/// Box drawing characters for borders and frames. +abstract class BoxChars { + static const String lightH = '─'; + static const String lightV = '│'; + static const String lightTL = '┌'; + static const String lightTR = '┐'; + static const String lightBL = '└'; + static const String lightBR = '┘'; + static const String lightTeeR = '├'; + static const String lightTeeL = '┤'; + static const String lightTeeD = '┬'; + static const String lightTeeU = '┴'; + static const String lightCross = '┼'; + + static const String heavyH = '━'; + static const String heavyV = '┃'; + static const String heavyTL = '┏'; + static const String heavyTR = '┓'; + static const String heavyBL = '┗'; + static const String heavyBR = '┛'; + static const String heavyTeeR = '┣'; + static const String heavyTeeL = '┫'; + static const String heavyTeeD = '┳'; + static const String heavyTeeU = '┻'; + static const String heavyCross = '╋'; + + static const String doubleH = '═'; + static const String doubleV = '║'; + static const String doubleTL = '╔'; + static const String doubleTR = '╗'; + static const String doubleBL = '╚'; + static const String doubleBR = '╝'; + static const String doubleTeeR = '╠'; + static const String doubleTeeL = '╣'; + static const String doubleTeeD = '╦'; + static const String doubleTeeU = '╩'; + static const String doubleCross = '╬'; + + static const String roundedTL = '╭'; + static const String roundedTR = '╮'; + static const String roundedBL = '╰'; + static const String roundedBR = '╯'; + + static const String asciiH = '-'; + static const String asciiV = '|'; + static const String asciiCorner = '+'; + + static const String fullBlock = '█'; + static const String lightShade = '░'; + static const String mediumShade = '▒'; + static const String darkShade = '▓'; + static const String upperHalf = '▀'; + static const String lowerHalf = '▄'; + static const String leftHalf = '▌'; + static const String rightHalf = '▐'; + + static const String progressFull = '█'; + static const String progressEmpty = '░'; + static const String progressHalf = '▌'; } \ No newline at end of file diff --git a/lib/src/flexview.dart b/lib/src/flexview.dart deleted file mode 100644 index 41f4e06..0000000 --- a/lib/src/flexview.dart +++ /dev/null @@ -1,94 +0,0 @@ -part of tui.flexbox; - -// see: http://css-tricks.com/snippets/css/a-guide-to-flexbox/ - -enum FlexDirection { - row, - row_reverse, - column, - column_reverse -} - -enum FlexWrap { - nowrap, - wrap, - wrap_reverse -} - -class FlexFlow { - FlexDirection direction = FlexDirection.row; - FlexWrap wrap = FlexWrap.nowrap; -} - -enum JustifyContent { - flex_start, - flex_end, - center, - space_between, - space_around -} - -enum AlignItems { - flex_start, - flex_end, - center, - baseline, - stretch -} - -enum AlignContent { - flex_start, - flex_end, - center, - space_between, - space_around, - stretch -} - -class FlexContainer { - FlexFlow flex_flow = new FlexFlow(); - FlexDirection get flex_direction => flex_flow.direction; - FlexWrap get flex_wrap => flex_flow.wrap; - JustifyContent justify_content = JustifyContent.flex_start; - AlignItems align_items = AlignItems.stretch; - AlignContent align_content = AlignContent.stretch; -} - -class Flex { - int _grow = 0; - int get grow => _grow; - void set grow(int grow) { - if (grow < 0) throw "Negative numbers are invalid."; - this.grow = grow; - } - - int _shrink = 1; - int get shrink => _shrink; - void set shrink(int shrink) { - if (shrink < 0) throw "Negative numbers are invalid."; - this.shrink = shrink; - } - - int basis = null; // auto -} - -enum AlignSelf { - flex_start, - flex_end, - center, - baseline, - stretch -} - -class FlexItem { - Flex flex_flex = new Flex(); - int get flex_grow => flex_flex.grow; - int get flex_shrink => flex_flex.shrink; - int get flex_basis => flex_flex.basis; - - AlignSelf align_self = null; // auto -} - -class FlexView extends View with FlexContainer, FlexItem { - -} \ No newline at end of file diff --git a/lib/src/focus.dart b/lib/src/focus.dart new file mode 100644 index 0000000..f25750f --- /dev/null +++ b/lib/src/focus.dart @@ -0,0 +1,80 @@ +part of tui; + +/// Mixin that provides focus management for a container of Views. +mixin FocusManager { + View? _focused; + View? get focusedView => _focused; + + View get focusRoot; + + void focusFirst() { + var views = focusRoot.focusableViews; + if (views.isNotEmpty) { + focus(views.first); + } + } + + void focus(View view) { + if (!view.focusable) return; + if (_focused == view) return; + + var oldFrames = _ancestorFrames(_focused); + var oldFocused = _focused; + + _focused?.focused = false; + _focused = view; + view.focused = true; + + var newFrames = _ancestorFrames(view); + for (var frame in oldFrames) { + if (!newFrames.contains(frame)) frame.focused = false; + } + for (var frame in newFrames) { + if (!oldFrames.contains(frame)) frame.focused = true; + } + + oldFocused?.onBlur(); + view.onFocus(); + } + + void focusNext() { + var views = focusRoot.focusableViews; + if (views.isEmpty) return; + + if (_focused == null) { + focus(views.first); + return; + } + + var idx = views.indexOf(_focused!); + var nextIdx = (idx + 1) % views.length; + focus(views[nextIdx]); + } + + void focusPrev() { + var views = focusRoot.focusableViews; + if (views.isEmpty) return; + + if (_focused == null) { + focus(views.last); + return; + } + + var idx = views.indexOf(_focused!); + var prevIdx = (idx - 1 + views.length) % views.length; + focus(views[prevIdx]); + } + + bool routeKeyToFocused(String key) => _focused?.onKey(key) ?? false; + bool routePasteToFocused(String text) => _focused?.onPaste(text) ?? false; + + Set _ancestorFrames(View? view) { + var frames = {}; + var current = view?.parent; + while (current != null) { + if (current is Frame) frames.add(current); + current = current.parent; + } + return frames; + } +} diff --git a/lib/src/input.dart b/lib/src/input.dart new file mode 100644 index 0000000..fea9875 --- /dev/null +++ b/lib/src/input.dart @@ -0,0 +1,209 @@ +part of tui; + +/// Single-line text input field. +class Input extends View { + String _val = ''; + String get value => _val; + set value(String v) { + if (maxLength != null && v.length > maxLength!) { + v = v.substring(0, maxLength); + } + _val = v; + if (cursorPosition > _val.length) { + cursorPosition = _val.length; + } + update(); + } + + int cursorPosition = 0; + + String? placeholder; + + bool password = false; + + int? maxLength; + + String prompt = '> '; + + String promptColor = '36'; + String textColor = '0'; + String placeholderColor = '90'; + String cursorColor = '7'; + + void Function(String value)? onSubmit; + void Function(String value)? onChange; + + Input({this.placeholder, this.password = false, this.maxLength}) { + focusable = true; + } + + @override + void onFocus() { + update(); + } + + @override + void onBlur() { + focused = false; + update(); + } + + @override + bool onKey(String key) { + if (key == KeyCode.ENTER) { + onSubmit?.call(_val); + return true; + } + + if (key == KeyCode.LEFT) { + if (cursorPosition > 0) { + cursorPosition--; + update(); + } + return true; + } + + if (key == KeyCode.RIGHT) { + if (cursorPosition < _val.length) { + cursorPosition++; + update(); + } + return true; + } + + if (key == KeyCode.HOME) { + cursorPosition = 0; + update(); + return true; + } + + if (key == KeyCode.END) { + cursorPosition = _val.length; + update(); + return true; + } + + if (key == KeyCode.BACKSPACE) { + if (cursorPosition > 0) { + _val = _val.substring(0, cursorPosition - 1) + + _val.substring(cursorPosition); + cursorPosition--; + onChange?.call(_val); + update(); + } + return true; + } + + if (key == KeyCode.DEL) { + if (cursorPosition < _val.length) { + _val = _val.substring(0, cursorPosition) + + _val.substring(cursorPosition + 1); + onChange?.call(_val); + update(); + } + return true; + } + + if (key.runes.length == 1 && key.runes.first >= 32) { + if (maxLength == null || _val.length < maxLength!) { + _val = _val.substring(0, cursorPosition) + + key + + _val.substring(cursorPosition); + cursorPosition += key.length; + onChange?.call(_val); + update(); + } + return true; + } + + return false; + } + + @override + bool onPaste(String text) { + var clean = text.replaceAll('\r', '').replaceAll('\n', ' '); + if (clean.isEmpty) return true; + + if (maxLength != null) { + var available = maxLength! - _val.length; + if (available <= 0) return true; + clean = String.fromCharCodes(clean.runes.take(available)); + } + + _val = _val.substring(0, cursorPosition) + + clean + + _val.substring(cursorPosition); + cursorPosition += clean.length; + onChange?.call(_val); + update(); + return true; + } + + @override + void update() { + text = []; + if (width < 1 || height < 1) return; + + var displayText = _val; + if (password) { + displayText = '*' * _val.length; + } + + var showPlaceholder = _val.isEmpty && placeholder != null; + var contentToShow = showPlaceholder ? placeholder! : displayText; + + var availableWidth = width - prompt.length; + if (availableWidth < 1) availableWidth = 1; + + var scrollOffset = 0; + if (cursorPosition > availableWidth - 1) { + scrollOffset = cursorPosition - availableWidth + 1; + } + + var visibleContent = contentToShow.length > scrollOffset + ? contentToShow.substring(scrollOffset) + : ''; + if (visibleContent.length > availableWidth) { + visibleContent = visibleContent.substring(0, availableWidth); + } + + text.add(Text(prompt)..color = promptColor); + + if (showPlaceholder) { + text.add(Text(visibleContent) + ..color = placeholderColor + ..position = Position(prompt.length, 0)); + } else if (focused) { + var cursorPosInVisible = cursorPosition - scrollOffset; + + if (cursorPosInVisible >= 0 && cursorPosInVisible <= visibleContent.length) { + if (cursorPosInVisible > 0) { + text.add(Text(visibleContent.substring(0, cursorPosInVisible)) + ..color = textColor + ..position = Position(prompt.length, 0)); + } + + var cursorChar = cursorPosInVisible < visibleContent.length + ? visibleContent[cursorPosInVisible] + : ' '; + text.add(Text(cursorChar) + ..color = cursorColor + ..position = Position(prompt.length + cursorPosInVisible, 0)); + + if (cursorPosInVisible < visibleContent.length - 1) { + text.add(Text(visibleContent.substring(cursorPosInVisible + 1)) + ..color = textColor + ..position = Position(prompt.length + cursorPosInVisible + 1, 0)); + } + } else { + text.add(Text(visibleContent) + ..color = textColor + ..position = Position(prompt.length, 0)); + } + } else { + text.add(Text(visibleContent) + ..color = textColor + ..position = Position(prompt.length, 0)); + } + } +} \ No newline at end of file diff --git a/lib/src/input_parser.dart b/lib/src/input_parser.dart new file mode 100644 index 0000000..7c031f7 --- /dev/null +++ b/lib/src/input_parser.dart @@ -0,0 +1,126 @@ +part of tui; + +class InputParser { + final void Function(String key) onKey; + final void Function(String text) onPaste; + + String _buf = ''; + Timer? _escTimer; + bool _inPaste = false; + final StringBuffer _pasteBuf = StringBuffer(); + + static final _csiPattern = RegExp(r'\x1b\[[0-9;]*[A-Za-z~]'); + static const _escTimeout = Duration(milliseconds: 50); + static const _pasteStart = '\x1b[200~'; + static const _pasteEnd = '\x1b[201~'; + + InputParser({required this.onKey, required this.onPaste}); + + void feed(String data) { + _escTimer?.cancel(); + _buf += data; + _parse(); + } + + void _parse() { + while (_buf.isNotEmpty) { + if (_inPaste) { + _parsePaste(); + return; + } + + if (_buf[0] == '\x1b') { + if (!_parseEscape()) { + _escTimer = Timer(_escTimeout, _flushBuf); + return; + } + continue; + } + + var char = String.fromCharCode(_buf.runes.first); + onKey(char); + _buf = _buf.substring(char.length); + } + } + + bool _parseEscape() { + if (_buf.length < 2) return false; + + if (_buf[1] == '[') { + return _parseCsi(); + } + + onKey('\x1b'); + _buf = _buf.substring(1); + return true; + } + + bool _parseCsi() { + var match = _csiPattern.matchAsPrefix(_buf); + if (match == null) { + if (_buf.length > 20) { + onKey('\x1b'); + _buf = _buf.substring(1); + return true; + } + return false; + } + + var seq = match.group(0)!; + _buf = _buf.substring(seq.length); + + if (seq == _pasteStart) { + _inPaste = true; + _parsePaste(); + return true; + } + + onKey(seq); + return true; + } + + void _parsePaste() { + while (_buf.isNotEmpty) { + var escIdx = _buf.indexOf('\x1b'); + + if (escIdx == -1) { + _pasteBuf.write(_buf); + _buf = ''; + return; + } + + if (escIdx > 0) { + _pasteBuf.write(_buf.substring(0, escIdx)); + _buf = _buf.substring(escIdx); + } + + if (_buf.length < _pasteEnd.length) { + // Might be partial end marker — wait for more data + return; + } + + if (_buf.startsWith(_pasteEnd)) { + _buf = _buf.substring(_pasteEnd.length); + _inPaste = false; + onPaste(_pasteBuf.toString()); + _pasteBuf.clear(); + return; + } + + // Not the end marker — add ESC to paste content and continue + _pasteBuf.write(_buf[0]); + _buf = _buf.substring(1); + } + } + + void _flushBuf() { + if (_buf.isEmpty) return; + onKey(_buf[0]); + _buf = _buf.substring(1); + _parse(); + } + + void dispose() { + _escTimer?.cancel(); + } +} diff --git a/lib/src/navigation_bar.dart b/lib/src/navigation_bar.dart new file mode 100644 index 0000000..7757739 --- /dev/null +++ b/lib/src/navigation_bar.dart @@ -0,0 +1,98 @@ +part of tui; + +/// Navigation bar with Prev/Next buttons for PageView. +class NavigationBar extends View { + final Page page; + String? prevLabel; + String nextLabel; + + late Button? _btnPrev; + late Button _btnNext; + + NavigationBar( + this.page, { + this.prevLabel = "← Wstecz", + this.nextLabel = "Dalej →", + }) { + _initBtns(); + } + + void _initBtns() { + if (prevLabel != null) { + _btnPrev = Button(prevLabel!, onPressed: _prev); + children.add(_btnPrev!); + } else { + _btnPrev = null; + } + + _btnNext = Button(nextLabel, onPressed: _next); + children.add(_btnNext); + } + + void _prev() { + page.pageView.goPrev(); + } + + void _next() { + page.pageView.goNext(); + } + + @override + void resizeChildren() { + int x = width; + + x -= _btnNext.displayWidth; + _btnNext.resize(Size(_btnNext.displayWidth, 1), Position(x, 0)); + + if (_btnPrev != null) { + x -= _btnPrev!.displayWidth + 1; + _btnPrev!.resize(Size(_btnPrev!.displayWidth, 1), Position(x, 0)); + } + } +} + +/// A focusable button widget. +class Button extends View { + String label; + void Function()? onPressed; + + String normalColor; + String focusedColor; + + Button( + this.label, { + this.onPressed, + this.normalColor = "7", + this.focusedColor = "32", + }) { + focusable = true; + } + + @override + void update() { + var display = "[ $label ]"; + var color = focused ? focusedColor : normalColor; + text = [Text(display)..color = color]; + } + + @override + void onFocus() { + update(); + } + + @override + void onBlur() { + update(); + } + + @override + bool onKey(String key) { + if (key == KeyCode.ENTER || key == " ") { + onPressed?.call(); + return true; + } + return false; + } + + int get displayWidth => label.length + 4; +} \ No newline at end of file diff --git a/lib/src/page_indicator.dart b/lib/src/page_indicator.dart new file mode 100644 index 0000000..ad0e1c8 --- /dev/null +++ b/lib/src/page_indicator.dart @@ -0,0 +1,115 @@ +part of tui; + +/// Abstract base for page progress indicators. +abstract class PageIndicator extends View { + int _curr = 0; + int _total = 0; + Set _open = {}; + + int get current => _curr; + int get total => _total; + Set get unlocked => _open; + + void updateState(int current, int total, Set unlocked) { + _curr = current; + _total = total; + _open = unlocked; + update(); + } +} + +/// Step indicator with lines: [1]───[2]───[3] +class StepIndicator extends PageIndicator { + final String? activeColor; + final String? inactiveColor; + + StepIndicator({this.activeColor = "32", this.inactiveColor = "90"}); + + @override + void update() { + if (total == 0) return; + + var b = StringBuffer(); + + for (var i = 0; i < total; i++) { + var label = "[${i + 1}]"; + + if (i == current) { + if (activeColor != null) { + b.write("\x1B[${activeColor}m$label\x1B[0m"); + } else { + b.write(label); + } + } else if (i < current || unlocked.contains(i)) { + b.write(label); + } else { + if (inactiveColor != null) { + b.write("\x1B[${inactiveColor}m$label\x1B[0m"); + } else { + b.write(label); + } + } + + if (i < total - 1) { + b.write("───"); + } + } + + text = [Text(b.toString())]; + } +} + +/// Dot indicator: ○ ● ○ ○ +class DotIndicator extends PageIndicator { + final String filledDot; + final String emptyDot; + final String? activeColor; + + DotIndicator({ + this.filledDot = "●", + this.emptyDot = "○", + this.activeColor = "32", + }); + + @override + void update() { + if (total == 0) return; + + var b = StringBuffer(); + + for (var i = 0; i < total; i++) { + if (i > 0) b.write(" "); + + if (i == current) { + if (activeColor != null) { + b.write("\x1B[${activeColor}m$filledDot\x1B[0m"); + } else { + b.write(filledDot); + } + } else { + b.write(emptyDot); + } + } + + text = [Text(b.toString())]; + } +} + +/// Text indicator: "Krok 2 z 4" +class TextIndicator extends PageIndicator { + final String Function(int current, int total)? formatter; + + TextIndicator({this.formatter}); + + String _fmt(int current, int total) { + return "Krok ${current + 1} z $total"; + } + + @override + void update() { + if (total == 0) return; + + var format = formatter ?? _fmt; + text = [Text(format(current, total))]; + } +} \ No newline at end of file diff --git a/lib/src/page_view.dart b/lib/src/page_view.dart new file mode 100644 index 0000000..69ec80e --- /dev/null +++ b/lib/src/page_view.dart @@ -0,0 +1,143 @@ +part of tui; + +/// Abstract base class for pages in a PageView. +abstract class Page extends View { + PageView? _pv; + + PageView get pageView { + if (_pv == null) { + throw StateError('Page not attached to PageView'); + } + return _pv!; + } + + void onEnter() {} + + void onLeave() {} + + bool canProceed() => true; + + dynamic getData() => null; +} + +/// Multi-step container for sequential page navigation. +class PageView extends View { + final List pages; + final PageIndicator? indicator; + + int _idx = 0; + final Map _data = {}; + final Set _unlocked = {}; + + int get currentIndex => _idx; + int get pageCount => pages.length; + Page get currentPage => pages[_idx]; + Set get unlockedPages => Set.unmodifiable(_unlocked); + + PageView({required this.pages, this.indicator}) { + if (pages.isEmpty) { + throw ArgumentError('PageView requires at least one page'); + } + + focusable = true; + + for (var page in pages) { + page._pv = this; + } + + children = [_PageCont(this)]; + } + + @override + bool onKey(String key) { + return currentPage.onKey(key); + } + + @override + List get focusableViews { + var result = []; + if (focusable) result.add(this); + result.addAll(currentPage.focusableViews); + return result; + } + + bool goNext() { + if (_idx >= pages.length - 1) return false; + + var page = currentPage; + if (!page.canProceed()) return false; + + _data[_idx] = page.getData(); + page.onLeave(); + + _idx++; + _updInd(); + currentPage.onEnter(); + + return true; + } + + bool goPrev() { + if (_idx <= 0) return false; + + currentPage.onLeave(); + _idx--; + _updInd(); + currentPage.onEnter(); + + return true; + } + + bool goTo(int index) { + if (index < 0 || index >= pages.length) return false; + if (index == _idx) return false; + if (!_unlocked.contains(index) && index > _idx) return false; + + currentPage.onLeave(); + _idx = index; + _updInd(); + currentPage.onEnter(); + + return true; + } + + void unlock(int index) { + if (index >= 0 && index < pages.length) { + _unlocked.add(index); + } + } + + T? getData(int pageIndex) { + return _data[pageIndex] as T?; + } + + Map getAllData() => Map.unmodifiable(_data); + + void _updInd() { + indicator?.updateState(_idx, pages.length, _unlocked); + } + + @override + void resize(Size size, Position position) { + super.resize(size, position); + _updInd(); + } + + void start() { + currentPage.onEnter(); + _updInd(); + } +} + +class _PageCont extends View { + final PageView _pv; + + _PageCont(this._pv); + + @override + void render(Canvas canvas) { + var page = _pv.currentPage; + page.resize(size, Position(0, 0)); + page.render(canvas); + } +} \ No newline at end of file diff --git a/lib/src/render_loop.dart b/lib/src/render_loop.dart index 319e31b..fe261fe 100644 --- a/lib/src/render_loop.dart +++ b/lib/src/render_loop.dart @@ -1,31 +1,118 @@ part of tui; +/// Tracks frame timing and calculates FPS statistics. +class FpsMeter { + final int _samples; + final Queue _times = Queue(); + DateTime _last = DateTime.now(); + + double _curr = 0; + double _avg = 0; + double _min = 0; + double _max = 0; + double _ms = 0; + + FpsMeter({int sampleSize = 60}) : _samples = sampleSize; + + void tick() { + final now = DateTime.now(); + final delta = now.difference(_last); + _last = now; + + _ms = delta.inMicroseconds / 1000.0; + if (_ms > 0) { + _curr = 1000.0 / _ms; + } + + _times.add(_ms); + if (_times.length > _samples) { + _times.removeFirst(); + } + + if (_times.isNotEmpty) { + var sum = 0.0; + var minTime = double.infinity; + var maxTime = 0.0; + for (final t in _times) { + sum += t; + if (t < minTime) minTime = t; + if (t > maxTime) maxTime = t; + } + final avgTime = sum / _times.length; + _avg = avgTime > 0 ? 1000.0 / avgTime : 0; + _max = minTime > 0 ? 1000.0 / minTime : 0; + _min = maxTime > 0 ? 1000.0 / maxTime : 0; + } + } + + double get currentFps => _curr; + double get averageFps => _avg; + double get minFps => _min; + double get maxFps => _max; + double get frameTimeMs => _ms; + + String get summary => + 'FPS: ${_curr.toStringAsFixed(0)} (avg: ${_avg.toStringAsFixed(0)}, min: ${_min.toStringAsFixed(0)})'; + + String get compact => + '${_avg.toStringAsFixed(0)} fps ${BoxChars.lightV} ${_ms.toStringAsFixed(1)}ms'; + + List get detailed => [ + 'FPS: ${_curr.toStringAsFixed(1)}', + 'Avg: ${_avg.toStringAsFixed(1)}', + 'Min: ${_min.toStringAsFixed(1)}', + 'Max: ${_max.toStringAsFixed(1)}', + 'Frame: ${_ms.toStringAsFixed(2)}ms', + ]; + + void reset() { + _times.clear(); + _curr = 0; + _avg = 0; + _min = 0; + _max = 0; + _ms = 0; + _last = DateTime.now(); + } +} + class RenderLoop { - Duration _interval; - bool _stop = false; + late Duration _dur; + bool _stopped = false; + final FpsMeter fps = FpsMeter(); + + int get targetFps => (1000 / _dur.inMilliseconds).round(); - RenderLoop({int milliseconds: 50}) { - _interval = new Duration(milliseconds: milliseconds); + RenderLoop({int milliseconds = 16}) { + _dur = Duration(milliseconds: milliseconds); } - void start([Function update_callback]) { - new Timer(_interval, () { - if (_stop) return; - if (update_callback != null) { - update_callback(); + RenderLoop.withFps(int fps) { + _dur = Duration(milliseconds: (1000 / fps).round()); + } + + void start([Function? onUpdate]) { + _loop(onUpdate); + } + + void _loop([Function? onUpdate]) { + Timer(_dur, () { + if (_stopped) return; + fps.tick(); + if (onUpdate != null) { + onUpdate(); } else { update(); } - new Timer(_interval, ()=>start(update_callback)); + _loop(onUpdate); }); } void update() { - throw "Either pass an update function to start() or extend the RenderLoop and implement update() method."; + throw UnimplementedError("Either pass an update function to start() or extend RenderLoop and implement update()."); } void stop() { - _stop = true; + _stopped = true; } - -} +} \ No newline at end of file diff --git a/lib/src/screen.dart b/lib/src/screen.dart index 3eb3540..0af997e 100644 --- a/lib/src/screen.dart +++ b/lib/src/screen.dart @@ -1,53 +1,118 @@ part of tui; -/** - * Screen is the main buffer shared by all Canvas write operations. - * - * Once all View.render() methods have been called the Screen writes - * its buffer to stdout. It will try to optimize things by only writing - * lines that have changed from the previous buffer. - * - */ -class Screen extends Object with Sizable { - - List> _buffer = []; - List> _previous_buffer = []; +/// Screen is the main buffer shared by all Canvas write operations. +/// +/// Uses double-buffering with line-level diffing: only lines that +/// actually changed between frames are written to the output. +class Screen with Sizable { + /// Current frame buffer (front). + List> _buf = []; + + /// Previous frame buffer (back) — used for diffing. + List> _prev = []; + + /// Whether any cell was written since last [swapBuffers]. + bool _hasWrites = false; Screen(Size size) { this.size = size; - clear(); + _allocate(); } void resize(Size size) { this.size = size; - clear(); + _allocate(); + } + + /// Allocates fresh front and back buffers. + void _allocate() { + _buf = List.generate(height, (_) => List.filled(width, null)); + _prev = List.generate(height, (_) => List.filled(width, null)); + _hasWrites = false; } void clear() { - _previous_buffer = _buffer; - _buffer = new List.generate(height, (_)=>new List.filled(width, null)); + for (var y = 0; y < height; y++) { + _buf[y].fillRange(0, width, null); + } + _hasWrites = false; } - Canvas canvas([Size size, Position offset]) { - return new Canvas( - size!=null?size:new Size.from(this.size), - offset!=null?offset:new Position(0,0), this); + /// True if any cell was written since last [clear]. + bool get hasWrites => _hasWrites; + + Canvas canvas([Size? size, Position? offset]) { + return Canvas( + size ?? Size.from(this.size), + offset ?? Position(0, 0), + this, + ); + } + + // ── Rendering helpers ────────────────────────────────────────── + + /// Builds a single line string from the buffer row. + String _renderLine(int y) { + final row = _buf[y]; + final sb = StringBuffer(); + for (var x = 0; x < width; x++) { + sb.write(row[x] ?? ' '); + } + return sb.toString(); } + /// Full frame as a single string (fallback, used on first frame). + @override String toString() { - return _buffer.map((line)=>line.map((char)=>char!=null?char:" ").join()).join('\n'); + final sb = StringBuffer(); + for (var y = 0; y < height; y++) { + if (y > 0) sb.write('\n'); + sb.write(_renderLine(y)); + } + return sb.toString(); } + /// Returns ANSI escape sequences to update only changed lines. + String diff() { + final sb = StringBuffer(); + for (var y = 0; y < height; y++) { + if (!_lineChanged(y)) continue; + sb.write('\x1B[${y + 1};1H'); + sb.write(_renderLine(y)); + } + return sb.toString(); + } + + bool _lineChanged(int y) { + final front = _buf[y]; + final back = _prev[y]; + for (var x = 0; x < width; x++) { + if (front[x] != back[x]) return true; + } + return false; + } + + void swapBuffers() { + for (var y = 0; y < height; y++) { + _prev[y].setRange(0, width, _buf[y]); + } + } + + // ── Cell access (unchanged API) ──────────────────────────────── + bool occluded(int x, int y) { - return _buffer[y][x] != null; + if (x < 0 || y < 0 || x >= width || y >= height) return true; + return _buf[y][x] != null; } void write(int x, int y, String char) { - _buffer[y][x] = char; + if (x < 0 || y < 0 || x >= width || y >= height) return; + _buf[y][x] = char; + _hasWrites = true; } String stringAt(int x, int y) { - return _buffer[y][x]; + if (x < 0 || y < 0 || x >= width || y >= height) return ''; + return _buf[y][x] ?? ''; } - -} \ No newline at end of file +} diff --git a/lib/src/select.dart b/lib/src/select.dart new file mode 100644 index 0000000..1364881 --- /dev/null +++ b/lib/src/select.dart @@ -0,0 +1,297 @@ +part of tui; + +/// List selection widget. +class Select extends View { + List options; + + int selectedIndex = 0; + + int scrollOffset = 0; + + String Function(T)? labelBuilder; + + bool multiSelect; + + Set selectedIndices = {}; + + int limit; + + String cursor = '> '; + + String selectedPrefix = '✓ '; + String unselectedPrefix = ' '; + + String cursorColor = '36'; + String selectedColor = '32'; + String itemColor = '0'; + + String? header; + String headerColor = '33'; + + void Function(T value)? onSelect; + void Function(List values)? onSelectMultiple; + void Function(int index)? onChange; + + Select({ + required this.options, + this.labelBuilder, + this.multiSelect = false, + this.limit = 0, + this.onSelect, + this.onSelectMultiple, + this.onChange, + this.header, + }) { + focusable = true; + } + + String _lbl(T option) { + return labelBuilder?.call(option) ?? option.toString(); + } + + int get _hdrOff => header != null ? 1 : 0; + + int get _visCnt => height - _hdrOff; + + @override + bool onKey(String key) { + if (key == KeyCode.UP) { + if (selectedIndex > 0) { + selectedIndex--; + _scroll(); + onChange?.call(selectedIndex); + update(); + } + return true; + } + + if (key == KeyCode.DOWN) { + if (selectedIndex < options.length - 1) { + selectedIndex++; + _scroll(); + onChange?.call(selectedIndex); + update(); + } + return true; + } + + if (key == KeyCode.HOME) { + selectedIndex = 0; + scrollOffset = 0; + onChange?.call(selectedIndex); + update(); + return true; + } + + if (key == KeyCode.END) { + selectedIndex = options.length - 1; + _scroll(); + onChange?.call(selectedIndex); + update(); + return true; + } + + if (key == KeyCode.PAGE_UP) { + selectedIndex = (selectedIndex - _visCnt).clamp(0, options.length - 1); + _scroll(); + onChange?.call(selectedIndex); + update(); + return true; + } + + if (key == KeyCode.PAGE_DOWN) { + selectedIndex = (selectedIndex + _visCnt).clamp(0, options.length - 1); + _scroll(); + onChange?.call(selectedIndex); + update(); + return true; + } + + if (key == KeyCode.SPACE && multiSelect) { + _toggle(selectedIndex); + update(); + return true; + } + + if (key == KeyCode.ENTER) { + if (multiSelect) { + if (selectedIndices.isEmpty) { + selectedIndices.add(selectedIndex); + } + var selected = selectedIndices.toList()..sort(); + onSelectMultiple?.call(selected.map((i) => options[i]).toList()); + } else { + onSelect?.call(options[selectedIndex]); + } + return true; + } + + return false; + } + + void _toggle(int index) { + if (selectedIndices.contains(index)) { + selectedIndices.remove(index); + } else { + if (limit == 0 || selectedIndices.length < limit) { + selectedIndices.add(index); + } + } + } + + void _scroll() { + if (selectedIndex < scrollOffset) { + scrollOffset = selectedIndex; + } else if (selectedIndex >= scrollOffset + _visCnt) { + scrollOffset = selectedIndex - _visCnt + 1; + } + } + + @override + void update() { + text = []; + if (width < 3 || height < 1) return; + + var y = 0; + + if (header != null) { + text.add(Text(header!) + ..color = headerColor + ..position = Position(0, y++)); + } + + var endIndex = (scrollOffset + _visCnt).clamp(0, options.length); + for (var i = scrollOffset; i < endIndex; i++) { + var option = options[i]; + var label = _lbl(option); + var isHighlighted = i == selectedIndex; + var isSelected = selectedIndices.contains(i); + + String prefix; + String color; + + if (isHighlighted) { + prefix = cursor; + color = cursorColor; + } else if (multiSelect && isSelected) { + prefix = selectedPrefix; + color = selectedColor; + } else { + prefix = unselectedPrefix; + color = itemColor; + } + + var displayText = '$prefix$label'; + if (displayText.length > width) { + displayText = displayText.substring(0, width); + } + + text.add(Text(displayText) + ..color = color + ..position = Position(0, y++)); + } + + if (options.length > _visCnt) { + var scrollPercent = scrollOffset / (options.length - _visCnt); + var indicatorY = _hdrOff + (scrollPercent * (_visCnt - 1)).round(); + text.add(Text('│') + ..color = '8' + ..position = Position(width - 1, indicatorY)); + } + } +} + +/// Yes/No confirmation dialog. +class Confirm extends View { + String message; + + bool selected; + + String yesLabel; + String noLabel; + + String messageColor = '0'; + String selectedColor = '7'; + String unselectedColor = '0'; + + void Function(bool result)? onConfirm; + + Confirm({ + required this.message, + this.selected = true, + this.yesLabel = 'Yes', + this.noLabel = 'No', + this.onConfirm, + }) { + focusable = true; + } + + @override + bool onKey(String key) { + if (key == KeyCode.LEFT || key == 'y' || key == 'Y') { + selected = true; + update(); + return true; + } + + if (key == KeyCode.RIGHT || key == 'n' || key == 'N') { + selected = false; + update(); + return true; + } + + if (key == KeyCode.TAB) { + selected = !selected; + update(); + return true; + } + + if (key == KeyCode.ENTER) { + onConfirm?.call(selected); + return true; + } + + return false; + } + + @override + void update() { + text = []; + if (width < 10 || height < 1) return; + + text.add(Text(message)..color = messageColor); + + var yesText = selected ? '[$yesLabel]' : ' $yesLabel '; + var noText = selected ? ' $noLabel ' : '[$noLabel]'; + + if (height == 1) { + var fullText = '$message $yesText $noText'; + text = [Text(fullText)..color = messageColor]; + + var yesStart = message.length + 2; + var noStart = yesStart + yesText.length + 2; + + if (selected) { + text.add(Text(yesText) + ..color = selectedColor + ..position = Position(yesStart, 0)); + } else { + text.add(Text(noText) + ..color = selectedColor + ..position = Position(noStart, 0)); + } + } else { + text.add(Text(message) + ..color = messageColor + ..position = Position(0, 0)); + + text.add(Text(yesText) + ..color = selected ? selectedColor : unselectedColor + ..position = Position(0, 1)); + + text.add(Text(noText) + ..color = selected ? unselectedColor : selectedColor + ..position = Position(yesText.length + 2, 1)); + } + } +} \ No newline at end of file diff --git a/lib/src/spinner.dart b/lib/src/spinner.dart new file mode 100644 index 0000000..ea3ff44 --- /dev/null +++ b/lib/src/spinner.dart @@ -0,0 +1,198 @@ +part of tui; + +/// Spinner animation styles. +enum SpinnerStyle { + /// ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ + dots, + + /// ⣾⣽⣻⢿⡿⣟⣯⣷ + dots2, + + /// ◐◓◑◒ + circle, + + /// ◴◷◶◵ + circleHalf, + + /// ▁▂▃▄▅▆▇█▇▆▅▄▃▂ + line, + + /// ←↖↑↗→↘↓↙ + arrow, + + /// ▖▘▝▗ + square, + + /// ◢◣◤◥ + triangle, + + /// ⠁⠂⠄⡀⢀⠠⠐⠈ + bounce, + + /// |/- + pipe, + + /// ◜◝◞◟ + moon, + + /// .oO@* + pulse, +} + +/// Animated loading indicator. +/// +/// Frame advance is driven by the render loop via [render] when +/// the spinner is part of the view tree. A fallback Timer ensures +/// the animation also works when the spinner is used standalone +/// (e.g. in `_SpinnerShowcase` where only [update] is called). +class Spinner extends View { + /// Label text displayed next to spinner. + String? label; + + /// Whether the spinner is running. + bool running = false; + + /// Animation style. + SpinnerStyle style; + + /// Minimum milliseconds between frame changes. + int interval; + + /// Color for the spinner (ANSI code). + String spinnerColor = '36'; + + /// Color for the label (ANSI code). + String labelColor = '0'; + + /// Position of label relative to spinner. + bool labelOnRight; + + Timer? _timer; + int _frame = 0; + DateTime _lastTick = DateTime.now(); + + /// Set to true once [render] is called — disables the fallback + /// timer since the render loop drives the animation. + bool _renderDriven = false; + + static const Map> _frames = { + SpinnerStyle.dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + SpinnerStyle.dots2: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], + SpinnerStyle.circle: ['◐', '◓', '◑', '◒'], + SpinnerStyle.circleHalf: ['◴', '◷', '◶', '◵'], + SpinnerStyle.line: [ + '▁', + '▂', + '▃', + '▄', + '▅', + '▆', + '▇', + '█', + '▇', + '▆', + '▅', + '▄', + '▃', + '▂' + ], + SpinnerStyle.arrow: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], + SpinnerStyle.square: ['▖', '▘', '▝', '▗'], + SpinnerStyle.triangle: ['◢', '◣', '◤', '◥'], + SpinnerStyle.bounce: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], + SpinnerStyle.pipe: ['|', '/', '-', '\\'], + SpinnerStyle.moon: ['◜', '◝', '◞', '◟'], + SpinnerStyle.pulse: ['.', 'o', 'O', '@', '*'], + }; + + Spinner({ + this.label, + this.style = SpinnerStyle.dots, + this.interval = 80, + this.labelOnRight = true, + }); + + List get _currentFrames => _frames[style]!; + + String get _currentFrame => _currentFrames[_frame % _currentFrames.length]; + + /// Start the spinner animation. + void start() { + if (running) return; + running = true; + _frame = 0; + _lastTick = DateTime.now(); + _startTimer(); + update(); + } + + /// Stop the spinner animation. + void stop() { + running = false; + _stopTimer(); + update(); + } + + void _startTimer() { + _stopTimer(); + _timer = Timer.periodic(Duration(milliseconds: interval), (_) { + if (!running) return; + // Only tick from timer if render() is NOT driving us. + if (_renderDriven) return; + _advanceFrame(); + }); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } + + void _advanceFrame() { + _frame++; + _lastTick = DateTime.now(); + update(); + } + + @override + void render(Canvas canvas) { + _renderDriven = true; + // Advance frame if enough time has elapsed. + if (running) { + final now = DateTime.now(); + if (now.difference(_lastTick).inMilliseconds >= interval) { + _advanceFrame(); + } + } + super.render(canvas); + } + + @override + void update() { + text = []; + if (width < 1 || height < 1) return; + + if (!running) { + if (label != null) { + text.add(Text(label!)..color = labelColor); + } + return; + } + + var spinnerChar = _currentFrame; + + if (label == null || label!.isEmpty) { + text.add(Text(spinnerChar)..color = spinnerColor); + } else if (labelOnRight) { + text.add(Text(spinnerChar)..color = spinnerColor); + text.add(Text(' $label') + ..color = labelColor + ..position = Position(1, 0)); + } else { + text.add(Text(label!)..color = labelColor); + text.add(Text(' $spinnerChar') + ..color = spinnerColor + ..position = Position(label!.length, 0)); + } + } +} diff --git a/lib/src/table.dart b/lib/src/table.dart new file mode 100644 index 0000000..b62f77b --- /dev/null +++ b/lib/src/table.dart @@ -0,0 +1,307 @@ +part of tui; + +enum TableBorder { + none, + ascii, + light, + heavy, + double, + rounded, +} + +class TableColumn { + final String title; + int? width; + final TextAlign align; + + TableColumn(this.title, {this.width, this.align = TextAlign.left}); +} + +enum TextAlign { left, center, right } + +/// Tabular data display widget. +class Table extends View { + List columns; + + List rows; + + List Function(T) rowBuilder; + + int selectedRow = 0; + + int scrollOffset = 0; + + bool selectable; + + bool showHeader; + + TableBorder border; + + String headerColor = '1'; + String selectedColor = '7'; + String rowColor = '0'; + String borderColor = '36'; + + void Function(T row)? onSelect; + void Function(int index)? onChange; + + Table({ + required this.columns, + required this.rows, + required this.rowBuilder, + this.selectable = true, + this.showHeader = true, + this.border = TableBorder.light, + this.onSelect, + this.onChange, + }) { + focusable = selectable; + } + + int get _hdrH { + if (!showHeader) return 0; + return border != TableBorder.none ? 2 : 1; + } + + int get _brdH => border != TableBorder.none ? 2 : 0; + int get _visRows => height - _hdrH - _brdH; + + @override + bool onKey(String key) { + if (!selectable) return false; + + if (key == KeyCode.UP) { + if (selectedRow > 0) { + selectedRow--; + _scroll(); + onChange?.call(selectedRow); + update(); + } + return true; + } + + if (key == KeyCode.DOWN) { + if (selectedRow < rows.length - 1) { + selectedRow++; + _scroll(); + onChange?.call(selectedRow); + update(); + } + return true; + } + + if (key == KeyCode.HOME) { + selectedRow = 0; + scrollOffset = 0; + onChange?.call(selectedRow); + update(); + return true; + } + + if (key == KeyCode.END) { + selectedRow = rows.length - 1; + _scroll(); + onChange?.call(selectedRow); + update(); + return true; + } + + if (key == KeyCode.PAGE_UP) { + selectedRow = (selectedRow - _visRows).clamp(0, rows.length - 1); + _scroll(); + onChange?.call(selectedRow); + update(); + return true; + } + + if (key == KeyCode.PAGE_DOWN) { + selectedRow = (selectedRow + _visRows).clamp(0, rows.length - 1); + _scroll(); + onChange?.call(selectedRow); + update(); + return true; + } + + if (key == KeyCode.ENTER) { + if (rows.isNotEmpty) { + onSelect?.call(rows[selectedRow]); + } + return true; + } + + return false; + } + + void _scroll() { + if (selectedRow < scrollOffset) { + scrollOffset = selectedRow; + } else if (selectedRow >= scrollOffset + _visRows) { + scrollOffset = selectedRow - _visRows + 1; + } + } + + void _calcWidths() { + var needsCalc = columns.any((c) => c.width == null); + if (!needsCalc) return; + + var maxWidths = List.generate(columns.length, (i) => columns[i].title.length); + for (var row in rows) { + var cells = rowBuilder(row); + for (var i = 0; i < columns.length && i < cells.length; i++) { + if (columns[i].width == null && cells[i].length > maxWidths[i]) { + maxWidths[i] = cells[i].length; + } + } + } + for (var i = 0; i < columns.length; i++) { + columns[i].width ??= maxWidths[i] + 2; + } + } + + String _align(String text, int width, TextAlign align) { + if (text.length >= width) return text.substring(0, width); + + switch (align) { + case TextAlign.left: + return text.padRight(width); + case TextAlign.right: + return text.padLeft(width); + case TextAlign.center: + var pad = (width - text.length) ~/ 2; + return text.padLeft(text.length + pad).padRight(width); + } + } + + _BorderChars get _chars { + switch (border) { + case TableBorder.none: + return _BorderChars( + tl: ' ', h: ' ', tr: ' ', v: ' ', + ml: ' ', mr: ' ', bl: ' ', br: ' ', + tm: ' ', mm: ' ', bm: ' ', + ); + case TableBorder.ascii: + return _BorderChars( + tl: BoxChars.asciiCorner, h: BoxChars.asciiH, tr: BoxChars.asciiCorner, v: BoxChars.asciiV, + ml: BoxChars.asciiCorner, mr: BoxChars.asciiCorner, bl: BoxChars.asciiCorner, br: BoxChars.asciiCorner, + tm: BoxChars.asciiCorner, mm: BoxChars.asciiCorner, bm: BoxChars.asciiCorner, + ); + case TableBorder.light: + return _BorderChars( + tl: BoxChars.lightTL, h: BoxChars.lightH, tr: BoxChars.lightTR, v: BoxChars.lightV, + ml: BoxChars.lightTeeR, mr: BoxChars.lightTeeL, bl: BoxChars.lightBL, br: BoxChars.lightBR, + tm: BoxChars.lightTeeD, mm: BoxChars.lightCross, bm: BoxChars.lightTeeU, + ); + case TableBorder.heavy: + return _BorderChars( + tl: BoxChars.heavyTL, h: BoxChars.heavyH, tr: BoxChars.heavyTR, v: BoxChars.heavyV, + ml: BoxChars.heavyTeeR, mr: BoxChars.heavyTeeL, bl: BoxChars.heavyBL, br: BoxChars.heavyBR, + tm: BoxChars.heavyTeeD, mm: BoxChars.heavyCross, bm: BoxChars.heavyTeeU, + ); + case TableBorder.double: + return _BorderChars( + tl: BoxChars.doubleTL, h: BoxChars.doubleH, tr: BoxChars.doubleTR, v: BoxChars.doubleV, + ml: BoxChars.doubleTeeR, mr: BoxChars.doubleTeeL, bl: BoxChars.doubleBL, br: BoxChars.doubleBR, + tm: BoxChars.doubleTeeD, mm: BoxChars.doubleCross, bm: BoxChars.doubleTeeU, + ); + case TableBorder.rounded: + return _BorderChars( + tl: BoxChars.roundedTL, h: BoxChars.lightH, tr: BoxChars.roundedTR, v: BoxChars.lightV, + ml: BoxChars.lightTeeR, mr: BoxChars.lightTeeL, bl: BoxChars.roundedBL, br: BoxChars.roundedBR, + tm: BoxChars.lightTeeD, mm: BoxChars.lightCross, bm: BoxChars.lightTeeU, + ); + } + } + + @override + void update() { + text = []; + if (width < 5 || height < 2) return; + + _calcWidths(); + + var chars = _chars; + var y = 0; + + if (border != TableBorder.none) { + var topLine = chars.tl + + columns.map((c) => chars.h * c.width!).join(chars.tm) + + chars.tr; + if (topLine.length > width) topLine = topLine.substring(0, width); + text.add(Text(topLine) + ..color = borderColor + ..position = Position(0, y++)); + } + + if (showHeader) { + var headerLine = chars.v + + columns.map((c) => _align(' ${c.title} ', c.width!, c.align)).join(chars.v) + + chars.v; + if (headerLine.length > width) headerLine = headerLine.substring(0, width); + text.add(Text(headerLine) + ..color = headerColor + ..position = Position(0, y++)); + + if (border != TableBorder.none) { + var sepLine = chars.ml + + columns.map((c) => chars.h * c.width!).join(chars.mm) + + chars.mr; + if (sepLine.length > width) sepLine = sepLine.substring(0, width); + text.add(Text(sepLine) + ..color = borderColor + ..position = Position(0, y++)); + } + } + + var endIdx = (scrollOffset + _visRows).clamp(0, rows.length); + for (var i = scrollOffset; i < endIdx; i++) { + var row = rows[i]; + var cells = rowBuilder(row); + var isSelected = selectable && i == selectedRow; + + var rowLine = chars.v + + columns.asMap().entries.map((e) { + var idx = e.key; + var col = e.value; + var cellText = idx < cells.length ? cells[idx] : ''; + return _align(' $cellText ', col.width!, col.align); + }).join(chars.v) + + chars.v; + + if (rowLine.length > width) rowLine = rowLine.substring(0, width); + + text.add(Text(rowLine) + ..color = isSelected ? selectedColor : rowColor + ..position = Position(0, y++)); + } + + if (border != TableBorder.none) { + var bottomLine = chars.bl + + columns.map((c) => chars.h * c.width!).join(chars.bm) + + chars.br; + if (bottomLine.length > width) bottomLine = bottomLine.substring(0, width); + text.add(Text(bottomLine) + ..color = borderColor + ..position = Position(0, y)); + } + } +} + +class _BorderChars { + final String tl, h, tr, v, ml, mr, bl, br; + final String tm, mm, bm; + + _BorderChars({ + required this.tl, + required this.h, + required this.tr, + required this.v, + required this.ml, + required this.mr, + required this.bl, + required this.br, + required this.tm, + required this.mm, + required this.bm, + }); +} \ No newline at end of file diff --git a/lib/src/text.dart b/lib/src/text.dart index 9770a84..67e9ada 100644 --- a/lib/src/text.dart +++ b/lib/src/text.dart @@ -1,97 +1,79 @@ part of tui; -class _TextNodeIteratorPointer { - Text node; int index; - _TextNodeIteratorPointer(this.node, this.index); +class _NodePtr { + Text node; + int idx; + _NodePtr(this.node, this.idx); } class TextNodeIterator implements Iterator { + String current = ''; + List lastStack = []; + Iterator? _strIter; + final _stack = Queue<_NodePtr>(); - // represents single character - String current; + Queue<_NodePtr> get stack => _stack; - // all of the parent nodes of the last String returned - List last_stack = []; - - // current text node - Iterator string; - - // all of the parent text nodes, this is used to - // apply all of the previous styles up to this point - final stack = new Queue<_TextNodeIteratorPointer>(); - - TextNodeIterator(Text _first) { - stack.add(new _TextNodeIteratorPointer(_first, 0)); + TextNodeIterator(Text first) { + _stack.add(_NodePtr(first, 0)); } + @override bool moveNext() { - - if (string != null) { - if (string.moveNext()) { - current = string.current; + if (_strIter != null) { + if (_strIter!.moveNext()) { + current = _strIter!.current; return true; } else { - string = null; - stack.removeLast(); + _strIter = null; + _stack.removeLast(); } } - while (stack.isNotEmpty) { - var last = stack.last; + while (_stack.isNotEmpty) { + var last = _stack.last; Text node = last.node; if (node.isLeaf) { - last_stack = stack.map((p)=>p.node).toList(); - string = node.text.split('').iterator; + lastStack = _stack.map((p) => p.node).toList(); + _strIter = node.text!.split('').iterator; return moveNext(); - } else if (node._nodes.length > last.index) { - Text child_node = node._nodes[last.index++]; - stack.add(new _TextNodeIteratorPointer(child_node, 0)); + } else if (node._nodes.length > last.idx) { + Text childNode = node._nodes[last.idx++]; + _stack.add(_NodePtr(childNode, 0)); } else { - node = stack.removeLast().node; + node = _stack.removeLast().node; } } return false; } - } - -// Text is like the equivalent of a Pen object in graphics tool kits -// it is an API to describe what should be rendered eventually to the screen - -// it should be possible to seek() and iterate from a certain point when parts -// of the text are occluded or line breaks, this class will have to figure out what formatting -// style needs to be applied given where we are in the text - -// above seek ability is also how we'll support multiple lines - -// text can be iterated without formatting, to get the visual length of this -// text can also be iterated with formatting to actually render it on screen +/// A text element that supports styling and hierarchy. class Text extends IterableBase with Positionable { - - bool get isLeaf => _text != null; + bool get isLeaf => _txt != null; bool get hasChildren => _nodes.isNotEmpty; - String _text; - String get text => _text; - void set text(String text) { - if (hasChildren) throw "Cannot set text on container type."; - _text = text; + String? _txt; + String? get text => _txt; + set text(String? text) { + if (hasChildren) throw StateError("Cannot set text on container type."); + _txt = text; } - List _nodes = []; + final List _nodes = []; List get nodes { - if (isLeaf) throw "Cannot access nodes on leaf type."; + if (isLeaf) throw StateError("Cannot access nodes on leaf type."); return _nodes; } bool bold = false; bool italics = false; - String color; + String? color; - TextNodeIterator get iterator => new TextNodeIterator(this); + @override + TextNodeIterator get iterator => TextNodeIterator(this); - Text([this._text]); + Text([this._txt]); void apply(Text text) { bold = text.bold; @@ -99,27 +81,12 @@ class Text extends IterableBase with Positionable { color = text.color; } - void _verify(Text text) { - // check that none of the children - // have a style set to true, that's - // already set to true in style passed in - // this prevents issue where close() here - // will close the style of a parent style - - // pseudo code: - // 1. check that nothing in style is also set to true here - // 2. set all style elements to true in style that are true here - // 3. call _verify(style) on content elements down the line - - // maybe instead of throwing error we can just handle this - // by ignoring styles set somewhere in the parent? - } - String open() { - if (color != null) + if (color != null) { return "\x1b[${color}m"; - else + } else { return "\x1b[31m"; + } } String close() { @@ -127,9 +94,8 @@ class Text extends IterableBase with Positionable { } Text add([String text = ""]) { - var node = new Text(text); + var node = Text(text); nodes.add(node); return node; } - -} +} \ No newline at end of file diff --git a/lib/src/textarea.dart b/lib/src/textarea.dart new file mode 100644 index 0000000..18b40ff --- /dev/null +++ b/lib/src/textarea.dart @@ -0,0 +1,348 @@ +part of tui; + +/// Multi-line text input widget. +class TextArea extends View { + List _lns = ['']; + + String get value => _lns.join('\n'); + + set value(String v) { + _lns = v.split('\n'); + if (_lns.isEmpty) _lns = ['']; + cursorX = 0; + cursorY = 0; + scrollOffset = 0; + update(); + } + + int cursorX = 0; + + int cursorY = 0; + + int scrollOffset = 0; + + int scrollX = 0; + + String? placeholder; + + int? maxLines; + + String linePrefix = ''; + + bool showLineNumbers = false; + + String textColor = '0'; + String placeholderColor = '90'; + String cursorColor = '7'; + String lineNumberColor = '8'; + + void Function(String value)? onSubmit; + void Function(String value)? onChange; + + TextArea({this.placeholder, this.maxLines}) { + focusable = true; + } + + String get _currLn => _lns[cursorY]; + + int get _lnNumW => showLineNumbers ? '${_lns.length}'.length + 2 : 0; + + int get _pfxW => _lnNumW + linePrefix.length; + + int get _visLns => height; + + int get _visW => width - _pfxW - 1; + + @override + bool onKey(String key) { + if (key == KeyCode.UP) { + if (cursorY > 0) { + cursorY--; + cursorX = cursorX.clamp(0, _currLn.length); + _scrollY(); + update(); + } + return true; + } + + if (key == KeyCode.DOWN) { + if (cursorY < _lns.length - 1) { + cursorY++; + cursorX = cursorX.clamp(0, _currLn.length); + _scrollY(); + update(); + } + return true; + } + + if (key == KeyCode.LEFT) { + if (cursorX > 0) { + cursorX--; + } else if (cursorY > 0) { + cursorY--; + cursorX = _currLn.length; + } + _scrollX(); + update(); + return true; + } + + if (key == KeyCode.RIGHT) { + if (cursorX < _currLn.length) { + cursorX++; + } else if (cursorY < _lns.length - 1) { + cursorY++; + cursorX = 0; + } + _scrollX(); + update(); + return true; + } + + if (key == KeyCode.HOME) { + cursorX = 0; + scrollX = 0; + update(); + return true; + } + + if (key == KeyCode.END) { + cursorX = _currLn.length; + _scrollX(); + update(); + return true; + } + + if (key == KeyCode.PAGE_UP) { + cursorY = (cursorY - _visLns).clamp(0, _lns.length - 1); + cursorX = cursorX.clamp(0, _currLn.length); + _scrollY(); + update(); + return true; + } + + if (key == KeyCode.PAGE_DOWN) { + cursorY = (cursorY + _visLns).clamp(0, _lns.length - 1); + cursorX = cursorX.clamp(0, _currLn.length); + _scrollY(); + update(); + return true; + } + + if (key == KeyCode.ENTER) { + if (maxLines != null && _lns.length >= maxLines!) { + return true; + } + var beforeCursor = _currLn.substring(0, cursorX); + var afterCursor = _currLn.substring(cursorX); + _lns[cursorY] = beforeCursor; + _lns.insert(cursorY + 1, afterCursor); + cursorY++; + cursorX = 0; + scrollX = 0; + _scrollY(); + onChange?.call(value); + update(); + return true; + } + + if (key == KeyCode.BACKSPACE) { + if (cursorX > 0) { + var line = _currLn; + _lns[cursorY] = line.substring(0, cursorX - 1) + line.substring(cursorX); + cursorX--; + _scrollX(); + onChange?.call(value); + update(); + } else if (cursorY > 0) { + var currentLineText = _currLn; + cursorY--; + cursorX = _currLn.length; + _lns[cursorY] = _currLn + currentLineText; + _lns.removeAt(cursorY + 1); + _scrollY(); + _scrollX(); + onChange?.call(value); + update(); + } + return true; + } + + if (key == KeyCode.DEL) { + if (cursorX < _currLn.length) { + var line = _currLn; + _lns[cursorY] = line.substring(0, cursorX) + line.substring(cursorX + 1); + onChange?.call(value); + update(); + } else if (cursorY < _lns.length - 1) { + _lns[cursorY] = _currLn + _lns[cursorY + 1]; + _lns.removeAt(cursorY + 1); + onChange?.call(value); + update(); + } + return true; + } + + if (key == '\x04') { + onSubmit?.call(value); + return true; + } + + if (key.runes.length == 1 && key.runes.first >= 32) { + var line = _currLn; + _lns[cursorY] = line.substring(0, cursorX) + key + line.substring(cursorX); + cursorX += key.length; + _scrollX(); + onChange?.call(value); + update(); + return true; + } + + if (key == KeyCode.TAB) { + var spaces = ' '; + var line = _currLn; + _lns[cursorY] = line.substring(0, cursorX) + spaces + line.substring(cursorX); + cursorX += spaces.length; + _scrollX(); + onChange?.call(value); + update(); + return true; + } + + return false; + } + + @override + bool onPaste(String text) { + var clean = text.replaceAll('\r', ''); + if (clean.isEmpty) return true; + + var lines = clean.split('\n'); + var before = _currLn.substring(0, cursorX); + var after = _currLn.substring(cursorX); + + if (lines.length == 1) { + _lns[cursorY] = before + lines[0] + after; + cursorX += lines[0].length; + } else { + _lns[cursorY] = before + lines.first; + for (var i = 1; i < lines.length - 1; i++) { + _lns.insert(cursorY + i, lines[i]); + } + _lns.insert(cursorY + lines.length - 1, lines.last + after); + cursorY += lines.length - 1; + cursorX = lines.last.length; + } + + if (maxLines != null && _lns.length > maxLines!) { + _lns.removeRange(maxLines!, _lns.length); + cursorY = cursorY.clamp(0, _lns.length - 1); + cursorX = cursorX.clamp(0, _currLn.length); + } + + _scrollY(); + _scrollX(); + onChange?.call(value); + update(); + return true; + } + + void _scrollY() { + if (cursorY < scrollOffset) { + scrollOffset = cursorY; + } else if (cursorY >= scrollOffset + _visLns) { + scrollOffset = cursorY - _visLns + 1; + } + } + + void _scrollX() { + if (cursorX < scrollX) { + scrollX = cursorX; + } else if (cursorX >= scrollX + _visW) { + scrollX = cursorX - _visW + 1; + } + } + + @override + void update() { + text = []; + if (width < 3 || height < 1) return; + + var showPlaceholder = _lns.length == 1 && _lns[0].isEmpty && placeholder != null; + + if (showPlaceholder && !focused) { + text.add(Text(placeholder!)..color = placeholderColor); + return; + } + + var endLine = (scrollOffset + _visLns).clamp(0, _lns.length); + + for (var i = scrollOffset; i < endLine; i++) { + var y = i - scrollOffset; + var xOffset = 0; + + if (showLineNumbers) { + var lineNum = '${i + 1}'.padLeft(_lnNumW - 1); + text.add(Text('$lineNum ') + ..color = lineNumberColor + ..position = Position(0, y)); + xOffset = _lnNumW; + } + + if (linePrefix.isNotEmpty) { + text.add(Text(linePrefix) + ..color = textColor + ..position = Position(xOffset, y)); + xOffset += linePrefix.length; + } + + var line = _lns[i]; + var visibleLine = line.length > scrollX ? line.substring(scrollX) : ''; + if (visibleLine.length > _visW) { + visibleLine = visibleLine.substring(0, _visW); + } + + if (focused && i == cursorY) { + var cursorPosInVisible = cursorX - scrollX; + + if (cursorPosInVisible >= 0 && cursorPosInVisible <= visibleLine.length) { + if (cursorPosInVisible > 0) { + text.add(Text(visibleLine.substring(0, cursorPosInVisible)) + ..color = textColor + ..position = Position(xOffset, y)); + } + + var cursorChar = cursorPosInVisible < visibleLine.length + ? visibleLine[cursorPosInVisible] + : ' '; + text.add(Text(cursorChar) + ..color = cursorColor + ..position = Position(xOffset + cursorPosInVisible, y)); + + if (cursorPosInVisible < visibleLine.length - 1) { + text.add(Text(visibleLine.substring(cursorPosInVisible + 1)) + ..color = textColor + ..position = Position(xOffset + cursorPosInVisible + 1, y)); + } + } else { + text.add(Text(visibleLine) + ..color = textColor + ..position = Position(xOffset, y)); + } + } else { + text.add(Text(visibleLine) + ..color = textColor + ..position = Position(xOffset, y)); + } + } + + if (_lns.length > _visLns) { + var maxScroll = _lns.length - _visLns; + var scrollPercent = maxScroll > 0 ? scrollOffset / maxScroll : 0.0; + var indicatorY = (scrollPercent * (_visLns - 1)).round(); + text.add(Text('▐') + ..color = '8' + ..position = Position(width - 1, indicatorY)); + } + } +} \ No newline at end of file diff --git a/lib/src/tree.dart b/lib/src/tree.dart index 73aa44e..c8530a6 100644 --- a/lib/src/tree.dart +++ b/lib/src/tree.dart @@ -1,141 +1,172 @@ part of tui; class TreeNode { + TreeNode? _p; + final List _kids = []; - TreeNode _parent; - List _children = []; - - int get depth => _parent!=null ? _parent.depth + 1 : 0; + int get depth => _p != null ? _p!.depth + 1 : 0; bool opened = false; - bool leaf = null; + bool leaf = false; TreeNode add(TreeNode node) { - node._parent = this; - _children.add(node); + node._p = this; + _kids.add(node); return node; } - } class TreeNodeIterator implements Iterator { - TreeNode current; - TreeModel _model; - final _queue = new Queue(); + late TreeNode current; + final TreeModel _mdl; - TreeNodeIterator(this._model) { - _queue.add([_model.root, 0]); + final _q = Queue<({TreeNode node, int index})>(); + + TreeNodeIterator(this._mdl) { + _q.add((node: _mdl.root, index: 0)); } + @override bool moveNext() { - while (_queue.isNotEmpty) { - var parent = _queue.last; - if (parent[0]._children.length > parent[1]) { - TreeNode node = parent[0]._children[parent[1]++]; - current = node; - if (node.opened && node._children.isNotEmpty) - _queue.add([node, 0]); + while (_q.isNotEmpty) { + var parent = _q.last; + var node = parent.node; + var index = parent.index; + + if (node._kids.length > index) { + _q.removeLast(); + _q.add((node: node, index: index + 1)); + + var child = node._kids[index]; + current = child; + + if (child.opened && child._kids.isNotEmpty) { + _q.add((node: child, index: 0)); + } return true; } else { - _queue.removeLast(); + _q.removeLast(); } } return false; } - } class TreeModel extends IterableBase { + final _ctrl = StreamController.broadcast(); + Stream get changes => _ctrl.stream; - final _controller = new StreamController.broadcast(); - Stream get changes => _controller.stream; - - final root = new TreeNode(); + final root = TreeNode(); - Iterator get iterator => new TreeNodeIterator(this); + @override + Iterator get iterator => TreeNodeIterator(this); - int indexOf(TreeNode target) { + int? indexOf(TreeNode target) { int i = 0; for (var node in this) { - if (node == target) - return i; + if (node == target) return i; i++; } return null; } } -class View2 { - int row; - int col; - int height; - int width; -} - -abstract class TreeView extends View2 { - - int position = 0; +/// A View that displays a tree structure with expandable nodes. +abstract class TreeView extends View { + int scrollOffset = 0; int cursor = 0; - TreeModel _model; + TreeModel model; - TreeView(this._model); + String cursorColor = "2"; + String normalColor = "7"; - void move_up() { + TreeView(this.model) { + focusable = true; + } - if (cursor > 0) { - cursor--; - if (cursor < position) { - position--; - } - render(); + @override + bool onKey(String key) { + switch (key) { + case KeyCode.UP: + moveUp(); + return true; + case KeyCode.DOWN: + moveDown(); + return true; + case KeyCode.LEFT: + collapseNode(); + return true; + case KeyCode.RIGHT: + expandNode(); + return true; } - + return false; } - void move_left() { - var node = _model.skip(cursor).first; - if (node._parent._parent != null) { - node._parent.opened = false; - cursor = _model.indexOf(node._parent); - position = min(cursor, position); - render(); + void moveUp() { + if (cursor > 0) { + cursor--; + if (cursor < scrollOffset) { + scrollOffset--; + } } } - void move_down() { - var len = _model.length-1; + void moveDown() { + var len = model.length - 1; if (len > cursor) { cursor++; - if (cursor >= (position+height)) { - position++; + if (cursor >= (scrollOffset + height)) { + scrollOffset++; } - render(); } } - void move_right() { - var node = _model.skip(cursor).first; - if (!node.leaf) { - node.opened = true; - move_down(); + void collapseNode() { + var nodes = model.toList(); + if (cursor < nodes.length) { + var node = nodes[cursor]; + if (node._p?._p != null) { + node._p!.opened = false; + cursor = model.indexOf(node._p!) ?? 0; + scrollOffset = min(cursor, scrollOffset); + } } } - void render() { - var iter = _model.skip(position).iterator; - for (var i = 0; i < height; i++) { - Terminal.moveCursor(row: row+i, column: col); - Terminal.eraseLine(); - if (iter.moveNext()) { - String line = render_row(iter.current); - if (i == (cursor-position)) Terminal.setBackgroundColor(2); - Terminal.write(line.substring(0, min(line.length, width))); - if (i == (cursor-position)) Terminal.resetBackgroundColor(); + void expandNode() { + var nodes = model.toList(); + if (cursor < nodes.length) { + var node = nodes[cursor]; + if (!node.leaf) { + node.opened = true; + moveDown(); } } } - String render_row(TreeNode node); + String renderNode(TreeNode node); + @override + void update() { + text = []; + var nodes = model.skip(scrollOffset).take(height).toList(); + + for (var i = 0; i < height; i++) { + if (i < nodes.length) { + var node = nodes[i]; + var line = renderNode(node); + if (line.length > width) { + line = line.substring(0, width); + } + + var isSelected = (i + scrollOffset) == cursor; + var t = Text(line.padRight(width)) + ..position = Position(0, i) + ..color = isSelected ? cursorColor : normalColor; + text.add(t); + } + } + } } \ No newline at end of file diff --git a/lib/src/view.dart b/lib/src/view.dart index 94c7e76..7fd2cae 100644 --- a/lib/src/view.dart +++ b/lib/src/view.dart @@ -1,174 +1,102 @@ part of tui; -abstract class View extends Object with Sizable, Positionable { - +/// Base class for all UI components. +abstract class View with Sizable, Positionable { bool get hasChildren => children.isNotEmpty; List text = []; List children = []; + View? parent; + + bool focusable = false; + bool focused = false; + + void onFocus() {} + void onBlur() {} - // The update() method is called by resize() or from your - // own code when something about the data this view represents - // changes. Common thing is to use this in callbacks for async - // events that modify data - // - // async_op().then((data) { view.data = data; view.update() } - // - // If update() is of any complexity and you have multiples async calls then - // reduce the number of calls to update() by waiting for all operations to finish, - // on the other hand if the async operations will take a long time and you want - // the screen to update with at least partial results, then go ahead and call update() - // with each async completion. - // - // var async1 = async_op(); - // var async2 = async_op2(); - // new Future.wait([async1,async2]).then(update); - // - void update() { - // called when resize happens or new data is available for this view - // update text objects + bool onKey(String key) => false; + bool onPaste(String text) => false; + + List get focusableViews { + final result = []; + if (focusable) result.add(this); + for (var child in children) { + result.addAll(child.focusableViews); + } + return result; } - // passed in Size with available size - // the View should save the size - // a parent View will use this to get an idea of what the size requirements - // are of its child elements, after all children have been resized - // it will then pass in Canvas object of that size to render() - // it is okay to modify the size during this function call, - // the parent will check for changes in size (making it smaller) - // to adjust other children or do something else - - // called when: - // 1. app is initially loaded - // 2. this view is added to another view - // 3. the parent view changes size void resize(Size size, Position position) { this.size = size; this.position = position; - resize_children(); + for (var child in children) { + child.parent = this; + } + resizeChildren(); update(); } + void update() {} + + void resizeChildren() { + for (var view in children) { + view.resize(Size.from(size), view.position); + } + } + void render(Canvas canvas) { - render_texts(canvas); - render_children(canvas); + renderTexts(canvas); + renderChildren(canvas); } - void render_texts(Canvas canvas) { + void renderTexts(Canvas canvas) { for (var line in text) { - render_text(line, canvas); + _drawTxt(line, canvas); } } - // 1. Writes the text object to Canvas - void render_text(Text text, Canvas canvas) { - - var iter = text.iterator; + void _drawTxt(Text text, Canvas canvas) { + var it = text.iterator; var x = text.x; var y = text.y; var opened = false; - var last_x; - var last_y; - - for (;x < canvas.width; x++) { - - if (iter.moveNext()) { + int? prevX; + int? prevY; + for (; x < canvas.width; x++) { + if (it.moveNext()) { if (!canvas.occluded(x, y)) { - - var value = iter.current; + var val = it.current; if (!opened) { - // need to figure out where we left off from before - // open() might take into account the entire stack, just like close does - value = iter.stack.last.node.open() + value; + val = it.stack.last.node.open() + val; opened = true; } - canvas.write(x, y, value); - - last_x = x; - last_y = y; - + canvas.write(x, y, val); + prevX = x; + prevY = y; } else if (opened) { - opened = false; - String last = canvas.stringAt(last_x, last_y); - canvas.write(last_x, last_y, last + iter.last_stack.map((t)=>t.close()).join()); - + final last = canvas.stringAt(prevX!, prevY!); + canvas.write(prevX, prevY, last + it.lastStack.map((t) => t.close()).join()); } } } - if (opened && last_x != null && last_y != null) { - String last = canvas.stringAt(last_x, last_y); - canvas.write(last_x, last_y, last + iter.last_stack.map((t)=>t.close()).join()); + if (opened && prevX != null && prevY != null) { + final last = canvas.stringAt(prevX, prevY); + canvas.write(prevX, prevY, last + it.lastStack.map((t) => t.close()).join()); } } - // default implementation just calls each child - // with it's existing size/position to trigger the initial update - void resize_children() { + void renderChildren(Canvas canvas) { for (var view in children) { - view.resize(view.size, view.position); + renderChild(view, canvas); } } - void render_children(Canvas canvas) { - for (var view in children) { - render_child(view, canvas); - } - } - - // calls view.render() with new canvas - void render_child(View view, Canvas canvas) { + void renderChild(View view, Canvas canvas) { view.render(canvas.canvas(view.size, view.position)); } - -} - - - -class Box extends View { - - String border; - String color; - - Box(this.border, this.color); - - void update() { - text = []; - text.addAll([ - new Text(border*width)..color = color, - new Text(border*width)..color = color..position = new Position(0, 1)]); - for (int i = 1; i < height-1; i++) { - text.add(new Text(border*2)..color = color..position = new Position(0, i)); - text.add(new Text(border*2)..color = color..position = new Position(width-2, i)); - } - text.addAll([ - new Text(border*width)..color = color..position = new Position(0, height-1), - new Text(border*width)..color = color..position = new Position(0, height-2), - ]); - } - - void resize_children() { - for (var view in children) { - var child_size = new Size.from(size); - child_size.height -= 4; - child_size.width -= 4; - var child_position = new Position(2, 2); - view.resize(child_size, child_position); - } - } - -} - -class CenteredText extends View { - String content; - CenteredText(this.content); - void update() { - var x = ((width/2)-(content.length/2)).toInt(); - var y = (height/2).toInt()-1; - text = [new Text(content)..position=new Position(x,y)]; - } } \ No newline at end of file diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart new file mode 100644 index 0000000..17e7e29 --- /dev/null +++ b/lib/src/widgets.dart @@ -0,0 +1,227 @@ +part of tui; + +/// A box with a border that contains children. +class Box extends View { + String border; + String color; + + Box(this.border, this.color); + + @override + void update() { + text = []; + if (width < 2 || height < 2) return; + + text.addAll([ + Text(border * width)..color = color, + Text(border * width) + ..color = color + ..position = Position(0, 1) + ]); + for (int i = 1; i < height - 1; i++) { + text.add(Text(border * 2) + ..color = color + ..position = Position(0, i)); + text.add(Text(border * 2) + ..color = color + ..position = Position(width - 2, i)); + } + text.addAll([ + Text(border * width) + ..color = color + ..position = Position(0, height - 1), + Text(border * width) + ..color = color + ..position = Position(0, height - 2), + ]); + } + + @override + void resizeChildren() { + for (var view in children) { + var childSize = Size.from(size); + childSize.height -= 4; + childSize.width -= 4; + var childPosition = Position(2, 2); + view.resize(childSize, childPosition); + } + } +} + +/// Displays text centered within the view. +class CenteredText extends View { + String content; + + CenteredText(this.content); + + @override + void update() { + var x = ((width / 2) - (content.length / 2)).toInt(); + var y = (height / 2).toInt() - 1; + text = [Text(content)..position = Position(x, y)]; + } +} + +/// Splits available space between children horizontally or vertically. +class SplitView extends View { + bool horizontal; + List? ratios; + + SplitView({this.horizontal = true, this.ratios}); + + @override + void resizeChildren() { + if (children.isEmpty) return; + + int totalRatio = 0; + for (int i = 0; i < children.length; i++) { + totalRatio += (ratios != null && i < ratios!.length) ? ratios![i] : 1; + } + + final avail = horizontal ? width : height; + + int offset = 0; + for (int i = 0; i < children.length; i++) { + final ratio = ratios != null && i < ratios!.length ? ratios![i] : 1; + final dim = (avail * ratio / totalRatio).floor(); + + if (horizontal) { + children[i].resize(Size(dim, height), Position(offset, 0)); + } else { + children[i].resize(Size(width, dim), Position(0, offset)); + } + + offset += dim; + } + } +} + +/// A frame with Unicode border and optional title. +class Frame extends View { + String? title; + String color; + String? focusColor; + int padding; + + Frame({this.title, this.color = '36', this.focusColor, this.padding = 1}); + + @override + void update() { + if (width < 4 || height < 3) { + text = []; + return; + } + + var innerWidth = width - 2; + var borderColor = focused && focusColor != null ? focusColor! : color; + + var h = focused && focusColor != null ? BoxChars.doubleH : BoxChars.lightH; + var v = focused && focusColor != null ? BoxChars.doubleV : BoxChars.lightV; + var tl = focused && focusColor != null ? BoxChars.doubleTL : BoxChars.lightTL; + var tr = focused && focusColor != null ? BoxChars.doubleTR : BoxChars.lightTR; + var bl = focused && focusColor != null ? BoxChars.doubleBL : BoxChars.lightBL; + var br = focused && focusColor != null ? BoxChars.doubleBR : BoxChars.lightBR; + + String topBorder; + if (title != null && title!.isNotEmpty) { + var titleText = ' $title '; + var remaining = innerWidth - titleText.length - 1; + if (remaining < 0) remaining = 0; + topBorder = '$tl$h$titleText${h * remaining}$tr'; + } else { + topBorder = '$tl${h * innerWidth}$tr'; + } + + text = [Text(topBorder)..color = borderColor]; + + for (var i = 1; i < height - 1; i++) { + text.add(Text(v) + ..color = borderColor + ..position = Position(0, i)); + text.add(Text(v) + ..color = borderColor + ..position = Position(width - 1, i)); + } + + text.add(Text('$bl${h * innerWidth}$br') + ..color = borderColor + ..position = Position(0, height - 1)); + } + + @override + void resizeChildren() { + var innerWidth = width - 2 - (padding * 2); + var innerHeight = height - 2 - (padding * 2); + if (innerWidth < 1) innerWidth = 1; + if (innerHeight < 1) innerHeight = 1; + + for (var child in children) { + child.resize( + Size(innerWidth, innerHeight), + Position(1 + padding, 1 + padding), + ); + } + } +} + +/// A progress bar widget. +class ProgressBar extends View { + double _val = 0.0; + double get value => _val; + set value(double v) => _val = v.clamp(0.0, 1.0); + + bool showPercent = true; + + String color = "2"; + String emptyColor = "8"; + String filledChar = BoxChars.progressFull; + String emptyChar = BoxChars.progressEmpty; + String? label; + + static const List _blocks = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", BoxChars.progressFull]; + + @override + void update() { + text = []; + if (width < 3 || height < 1) return; + + int labelWidth = 0; + if (label != null) { + labelWidth = label!.length + 1; + text.add(Text(label!)..color = color); + } + + int percentWidth = showPercent ? 5 : 0; + int barWidth = width - labelWidth - percentWidth - 2; + if (barWidth < 1) barWidth = 1; + + double filledExact = barWidth * _val; + int filledFull = filledExact.floor(); + double remainder = filledExact - filledFull; + int partialIndex = (remainder * 8).round(); + + var bar = StringBuffer(); + bar.write("["); + bar.write(filledChar * filledFull); + + if (filledFull < barWidth) { + if (partialIndex > 0 && partialIndex < 8) { + bar.write(_blocks[partialIndex]); + bar.write(emptyChar * (barWidth - filledFull - 1)); + } else { + bar.write(emptyChar * (barWidth - filledFull)); + } + } + bar.write("]"); + + if (showPercent) { + int pct = (_val * 100).round(); + bar.write(pct.toString().padLeft(4)); + bar.write("%"); + } + + text.add(Text(bar.toString()) + ..color = color + ..position = Position(labelWidth, 0)); + } +} \ No newline at end of file diff --git a/lib/src/window.dart b/lib/src/window.dart index 8525dee..83639f1 100644 --- a/lib/src/window.dart +++ b/lib/src/window.dart @@ -1,40 +1,148 @@ part of tui; -// Window is the top-level view that contains menu, status bar, title, etc +/// Top-level view that manages the application lifecycle. +class Window extends View with FocusManager { + static final _ansiPattern = RegExp(r'\x1B\[[0-9;]*m'); -class Window extends View { + late Screen _scr; + late RenderLoop _loop; + late StreamSubscription _in; + late InputParser _parser; + Size? _lastSz; - Screen screen; + bool showFps = false; - RenderLoop loop; - StreamSubscription input; + /// Whether this is the very first frame (forces full write). + bool _firstFrame = true; + + FpsMeter get fps => _loop.fps; + + @override + View get focusRoot => this; void start() { + _initTerm(); + _listenInput(); + _startLoop(); + focusFirst(); + } + + void _initTerm() { stdin.echoMode = false; stdin.lineMode = false; - loop = new RenderLoop(); - input = stdin.transform(ASCII.decoder).listen(onKey); - - print(ANSI.ERASE_SCREEN); - loop.start(() { - screen = new Screen(new Size(80,12)); - var canvas = screen.canvas(); - this.resize(canvas.size, canvas.position); - this.render(canvas); - print(ANSI.CURSOR_HOME); - print(screen.toString()); + stdout.write(ANSI.HIDE_CURSOR); + stdout.write(ANSI.ERASE_SCREEN); + stdout.write(ANSI.BRACKETED_PASTE_ON); + } + + void _listenInput() { + _parser = InputParser( + onKey: _onKey, + onPaste: _onPaste, + ); + _in = stdin.transform(utf8.decoder).listen(_parser.feed); + } + + void _startLoop() { + _loop = RenderLoop(); + _loop.start(() { + final termSize = Size( + stdout.terminalColumns, + stdout.terminalLines, + ); + + // Handle terminal resize — reallocate buffers. + final resized = _lastSz == null || + _lastSz!.width != termSize.width || + _lastSz!.height != termSize.height; + + if (resized) { + stdout.write(ANSI.ERASE_SCREEN); + _lastSz = termSize; + _scr = Screen(termSize); + _firstFrame = true; + } + + _scr.clear(); + final canvas = _scr.canvas(); + resize(canvas.size, canvas.position); + render(canvas); + + if (_firstFrame) { + // First frame: write everything. + stdout.write(ANSI.CURSOR_HOME); + stdout.write(_scr.toString()); + _scr.swapBuffers(); + _firstFrame = false; + } else { + // Subsequent frames: write only changed lines. + final patch = _scr.diff(); + if (patch.isNotEmpty) { + stdout.write(patch); + } + _scr.swapBuffers(); + } + + if (showFps) { + final fpsText = _loop.fps.compact; + final plainText = fpsText.replaceAll(_ansiPattern, ''); + final fpsX = termSize.width - plainText.length - 1; + if (fpsX > 0) { + stdout.write( + '\x1B[1;${fpsX}H${Colors.dim}$fpsText${Colors.reset}', + ); + } + } }); } - void onKey(String key) { + void _onKey(String key) { + if (key == KeyCode.TAB) { + focusNext(); + return; + } + if (key == KeyCode.SHIFT_TAB) { + focusPrev(); + return; + } + + if (routeKeyToFocused(key)) { + return; + } + + onKey(key); + } + void _onPaste(String text) { + if (routePasteToFocused(text)) { + return; + } + onPaste(text); } + @override + bool onKey(String key) => false; + + @override + bool onPaste(String text) => false; + void stop() { - stdin.echoMode = true; - stdin.lineMode = true; - loop.stop(); - input.cancel(); + _loop.stop(); + _parser.dispose(); + scheduleMicrotask(() { + _in.cancel(); + _restoreTerm(); + }); } -} \ No newline at end of file + void _restoreTerm() { + stdout.write(ANSI.BRACKETED_PASTE_OFF); + stdout.write(ANSI.SHOW_CURSOR); + stdout.write(ANSI.ERASE_SCREEN); + stdout.write(ANSI.CURSOR_HOME); + try { + stdin.echoMode = true; + stdin.lineMode = true; + } on StdinException {} + } +} diff --git a/lib/tui.dart b/lib/tui.dart index c1351ac..33b0566 100644 --- a/lib/tui.dart +++ b/lib/tui.dart @@ -5,45 +5,75 @@ import 'dart:math'; import 'dart:async'; import 'dart:convert'; import 'dart:collection'; +import 'dart:typed_data'; part 'src/screen.dart'; part 'src/canvas.dart'; part 'src/text.dart'; part 'src/view.dart'; +part 'src/widgets.dart'; part 'src/escape.dart'; +part 'src/focus.dart'; part 'src/window.dart'; part 'src/render_loop.dart'; +part 'src/input_parser.dart'; part 'src/tree.dart'; +part 'src/page_view.dart'; +part 'src/page_indicator.dart'; +part 'src/navigation_bar.dart'; +part 'src/input.dart'; +part 'src/select.dart'; +part 'src/textarea.dart'; +part 'src/spinner.dart'; +part 'src/table.dart'; +part 'src/bigtext.dart'; +part 'src/animation.dart'; +part 'src/audio/notes.dart'; +part 'src/audio/vibrato.dart'; +part 'src/audio/timbre.dart'; +part 'src/audio/stereo.dart'; +part 'src/audio/reverb.dart'; +part 'src/audio/tempo_map.dart'; +part 'src/audio/articulation.dart'; +part 'src/audio/wav_writer.dart'; +part 'src/audio/synthesizer.dart'; +part 'src/audio/player.dart'; +part 'src/audio/melody.dart'; +part 'src/audio/ornaments.dart'; +part 'src/audio/channel.dart'; +part 'src/audio/presets.dart'; class Size { - int width, height; + int width; + int height; + Size(this.width, this.height); - Size.from(Size size) { - width = size.width; - height = size.height; - } + Size.from(Size size) + : width = size.width, + height = size.height; } -abstract class Sizable { - Size size; +mixin Sizable { + Size size = Size(0, 0); int get width => size.width; int get height => size.height; } class Position { - int x, y; + int x; + int y; + Position(this.x, this.y); - Position.from(Position position) { - x = position.x; - y = position.y; - } + Position.from(Position position) + : x = position.x, + y = position.y; } -class Positionable { - Position position = new Position(0,0); +mixin Positionable { + Position position = Position(0, 0); int get x => position.x; int get y => position.y; -} \ No newline at end of file +} diff --git a/pubspec.yaml b/pubspec.yaml index 0faacdd..33b425b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,12 @@ name: 'tui' -version: 0.0.1 +version: 0.0.7 + +environment: + sdk: ^3.6.0 homepage: https://github.com/damoti/tui description: > Text-based User Interface components for building console application. -author: Lex Berezhny +dependencies: + path: ^1.9.0 dev_dependencies: - path: any - unittest: any + test: ^1.25.0 diff --git a/test/animation_test.dart b/test/animation_test.dart new file mode 100644 index 0000000..b7abf2e --- /dev/null +++ b/test/animation_test.dart @@ -0,0 +1,425 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Easing', () { + test('linear returns input unchanged', () { + expect(Easing.linear(0.0), 0.0); + expect(Easing.linear(0.5), 0.5); + expect(Easing.linear(1.0), 1.0); + }); + + test('easeIn starts slow', () { + expect(Easing.easeIn(0.5), lessThan(0.5)); + }); + + test('easeOut ends slow', () { + expect(Easing.easeOut(0.5), greaterThan(0.5)); + }); + + test('easeInOut is symmetric around 0.5', () { + var at25 = Easing.easeInOut(0.25); + var at75 = Easing.easeInOut(0.75); + expect(at25 + at75, closeTo(1.0, 0.001)); + }); + + test('easeOutBounce returns valid values', () { + expect(Easing.easeOutBounce(0.0), 0.0); + expect(Easing.easeOutBounce(1.0), 1.0); + expect(Easing.easeOutBounce(0.5), greaterThan(0.0)); + }); + }); + + group('Tween', () { + test('number interpolates correctly', () { + var tween = Tween.number(0, 100); + expect(tween.evaluate(0.0), 0.0); + expect(tween.evaluate(0.5), 50.0); + expect(tween.evaluate(1.0), 100.0); + }); + + test('integer rounds correctly', () { + var tween = Tween.integer(0, 10); + expect(tween.evaluate(0.0), 0); + expect(tween.evaluate(0.5), 5); + expect(tween.evaluate(0.25), 3); // 2.5 rounds to 3 + }); + + test('color interpolates RGB', () { + var tween = Tween.color(RGB(0, 0, 0), RGB(255, 255, 255)); + var mid = tween.evaluate(0.5); + expect(mid.r, closeTo(128, 1)); + expect(mid.g, closeTo(128, 1)); + expect(mid.b, closeTo(128, 1)); + }); + }); + + group('RGB', () { + test('lerp interpolates between colors', () { + var black = RGB(0, 0, 0); + var white = RGB(255, 255, 255); + var gray = black.lerp(white, 0.5); + expect(gray.r, 128); + expect(gray.g, 128); + expect(gray.b, 128); + }); + + test('toAnsi generates correct escape code', () { + var color = RGB(100, 150, 200); + expect(color.toAnsi(), '38;2;100;150;200'); + }); + }); + + group('RepeatMode', () { + test('enum has all expected values', () { + expect(RepeatMode.values, contains(RepeatMode.once)); + expect(RepeatMode.values, contains(RepeatMode.reverse)); + expect(RepeatMode.values, contains(RepeatMode.loop)); + expect(RepeatMode.values, contains(RepeatMode.pingPong)); + }); + }); + + group('Animation', () { + test('zero duration completes immediately', () { + var completed = false; + var anim = ValueAnimation( + duration: Duration.zero, + onUpdate: (_) {}, + onComplete: () => completed = true, + ); + anim.start(); + anim.update(); + expect(anim.completed, isTrue); + expect(completed, isTrue); + }); + + test('starts in non-running state', () { + var anim = ValueAnimation(onUpdate: (_) {}); + expect(anim.running, isFalse); + expect(anim.completed, isFalse); + }); + + test('start sets running to true', () { + var anim = ValueAnimation(onUpdate: (_) {}); + anim.start(); + expect(anim.running, isTrue); + }); + + test('stop sets running to false', () { + var anim = ValueAnimation(onUpdate: (_) {}); + anim.start(); + anim.stop(); + expect(anim.running, isFalse); + }); + + test('reset clears state', () { + var anim = ValueAnimation(onUpdate: (_) {}); + anim.start(); + anim.reset(); + expect(anim.running, isFalse); + expect(anim.completed, isFalse); + expect(anim.currentIteration, 0); + expect(anim.reversed, isFalse); + }); + }); + + group('ValueAnimation', () { + test('interpolates from begin to end', () { + var values = []; + var anim = ValueAnimation( + from: 0, + to: 100, + duration: Duration(milliseconds: 100), + onUpdate: (v) => values.add(v), + ); + anim.start(); + + // Simulate some updates + for (var i = 0; i < 10; i++) { + anim.update(); + } + + // Values should be in increasing order (roughly) + expect(values, isNotEmpty); + }); + }); + + group('AnimationController', () { + test('add starts animation', () { + var controller = AnimationController(); + var started = false; + var anim = _TestAnimation(onStart: () => started = true); + + controller.add(anim); + expect(started, isTrue); + }); + + test('clear stops all animations', () { + var controller = AnimationController(); + var anim = ValueAnimation(onUpdate: (_) {}); + + controller.add(anim); + expect(anim.running, isTrue); + + controller.clear(); + expect(anim.running, isFalse); + }); + + test('fps can be set within valid range', () { + var controller = AnimationController(); + controller.fps = 60; + expect(controller.fps, 60); + + controller.fps = 0; // Should clamp to 1 + expect(controller.fps, 1); + + controller.fps = 200; // Should clamp to 120 + expect(controller.fps, 120); + }); + }); + + group('FpsMeter', () { + test('starts with zero values', () { + var meter = FpsMeter(); + expect(meter.currentFps, 0); + expect(meter.averageFps, 0); + expect(meter.frameTimeMs, 0); + }); + + test('tick updates frame time', () { + var meter = FpsMeter(); + meter.tick(); + // First tick has no previous frame, so timing may be off + // Just verify it doesn't crash + expect(meter.frameTimeMs, greaterThanOrEqualTo(0)); + }); + + test('reset clears all values', () { + var meter = FpsMeter(); + meter.tick(); + meter.reset(); + expect(meter.currentFps, 0); + expect(meter.averageFps, 0); + }); + + test('compact returns formatted string', () { + var meter = FpsMeter(); + var compact = meter.compact; + expect(compact, contains('fps')); + expect(compact, contains('ms')); + }); + + test('summary returns formatted string', () { + var meter = FpsMeter(); + var summary = meter.summary; + expect(summary, contains('FPS:')); + expect(summary, contains('avg:')); + }); + }); + + group('RevealStyle', () { + test('enum has all expected values', () { + expect(RevealStyle.values, contains(RevealStyle.characters)); + expect(RevealStyle.values, contains(RevealStyle.linesDown)); + expect(RevealStyle.values, contains(RevealStyle.linesUp)); + expect(RevealStyle.values, contains(RevealStyle.centerOut)); + expect(RevealStyle.values, contains(RevealStyle.fade)); + }); + }); + + group('LineRevealStyle', () { + test('enum has all expected values', () { + expect(LineRevealStyle.values, contains(LineRevealStyle.instant)); + expect(LineRevealStyle.values, contains(LineRevealStyle.typewriter)); + expect(LineRevealStyle.values, contains(LineRevealStyle.glitch)); + expect(LineRevealStyle.values, contains(LineRevealStyle.matrix)); + expect(LineRevealStyle.values, contains(LineRevealStyle.fade)); + expect(LineRevealStyle.values, contains(LineRevealStyle.slide)); + }); + }); + + group('LineRevealConfig', () { + test('default constructor has sensible defaults', () { + var config = LineRevealConfig(); + expect(config.style, LineRevealStyle.typewriter); + expect(config.duration.inMilliseconds, 300); + }); + + test('preset configs exist', () { + expect(LineRevealConfig.instant.style, LineRevealStyle.instant); + expect(LineRevealConfig.typewriter.style, LineRevealStyle.typewriter); + expect(LineRevealConfig.glitch.style, LineRevealStyle.glitch); + expect(LineRevealConfig.matrix.style, LineRevealStyle.matrix); + expect(LineRevealConfig.fade.style, LineRevealStyle.fade); + }); + }); + + group('BigTextAnimation', () { + test('updates with rendered lines', () { + var updates = >[]; + var anim = BigTextAnimation( + lines: ['LINE1', 'LINE2', 'LINE3'], + lineDelay: Duration(milliseconds: 50), + defaultStyle: LineRevealConfig.instant, + onUpdate: (lines) => updates.add(List.from(lines)), + ); + + anim.start(); + // Simulate updates + for (var i = 0; i < 5; i++) { + anim.update(); + } + + expect(updates, isNotEmpty); + }); + + test('respects line styles list', () { + var anim = BigTextAnimation( + lines: ['A', 'B', 'C'], + lineDelay: Duration.zero, + lineStyles: [ + LineRevealConfig.glitch, + LineRevealConfig.matrix, + ], + onUpdate: (_) {}, + ); + + // Third line should use last style (matrix) + expect(anim, isNotNull); + }); + + test('calls onUpdate during animation', () { + var updateCount = 0; + var anim = BigTextAnimation( + lines: ['TEST', 'LINES'], + lineDelay: Duration(milliseconds: 100), + defaultStyle: LineRevealConfig( + style: LineRevealStyle.typewriter, + duration: Duration(milliseconds: 100), + ), + onUpdate: (_) => updateCount++, + ); + + anim.start(); + // Simulate a few updates + for (var i = 0; i < 10; i++) { + anim.update(); + } + + expect(updateCount, greaterThan(0)); + }); + }); + + group('AnimatedBigText', () { + test('startAnimation produces lines after resize', () { + var widget = AnimatedBigText( + 'HI', + font: BigTextFont.shadow, + autoStart: false, + ); + + // Simulate render loop giving us size. + widget.resize(Size(40, 10), Position(0, 0)); + widget.startAnimation(); + + // Tick a few frames to advance the animation. + var screen = Screen(Size(40, 10)); + var canvas = screen.canvas(); + for (var i = 0; i < 5; i++) { + widget.render(canvas); + } + + expect(widget.text, isNotEmpty); + }); + + test('autoStart begins animation on first resize', () { + var widget = AnimatedBigText('GO', font: BigTextFont.shadow); + + // Before resize — nothing should happen. + expect(widget.text, isEmpty); + + // Resize triggers auto-start. + widget.resize(Size(40, 10), Position(0, 0)); + + var screen = Screen(Size(40, 10)); + var canvas = screen.canvas(); + widget.render(canvas); + + // Controller should have an active animation. + expect(widget.text, isNotEmpty); + }); + + test('startAnimation defers when size is zero', () { + var widget = AnimatedBigText( + 'OK', + font: BigTextFont.shadow, + autoStart: false, + ); + + // Call start before resize — should defer. + widget.startAnimation(); + + // Now give it a real size — deferred start should fire. + widget.resize(Size(40, 10), Position(0, 0)); + + var screen = Screen(Size(40, 10)); + var canvas = screen.canvas(); + widget.render(canvas); + + expect(widget.text, isNotEmpty); + }); + + test('skipAnimation shows final BigText', () { + var widget = AnimatedBigText('AB', font: BigTextFont.shadow); + + widget.resize(Size(40, 10), Position(0, 0)); + widget.skipAnimation(); + widget.update(); + + // Should show static BigText output, not animated lines. + expect(widget.text, isNotEmpty); + // Verify at least one text node has content from BigText. + var hasContent = + widget.text.any((t) => t.text != null && t.text!.trim().isNotEmpty); + expect(hasContent, isTrue); + }); + + test('does not restart animation on subsequent resizes', () { + var widget = AnimatedBigText('X', font: BigTextFont.shadow); + + widget.resize(Size(40, 10), Position(0, 0)); + + // Tick a few frames. + var screen = Screen(Size(40, 10)); + var canvas = screen.canvas(); + for (var i = 0; i < 3; i++) { + widget.render(canvas); + } + + // Resize again — should NOT restart. + widget.resize(Size(50, 12), Position(0, 0)); + widget.render(canvas); + + // Still has text, animation wasn't reset. + expect(widget.text, isNotEmpty); + }); + }); +} + +/// Test animation that tracks lifecycle. +class _TestAnimation extends Animation { + final void Function()? onStart; + + _TestAnimation({this.onStart}) : super(duration: Duration(milliseconds: 100)); + + @override + void start() { + super.start(); + onStart?.call(); + } + + @override + void update() { + progress; // Advance time + } +} diff --git a/test/audio/articulation_test.dart b/test/audio/articulation_test.dart new file mode 100644 index 0000000..dbf1f84 --- /dev/null +++ b/test/audio/articulation_test.dart @@ -0,0 +1,134 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Articulation presets', () { + test('staccato has very short envelope', () { + final env = Articulation.staccato.envelope; + expect(env.attack, equals(0.003)); + expect(env.decay, equals(0.06)); + expect(env.sustain, equals(0.0)); + expect(env.release, equals(0.01)); + }); + + test('pizzicato has snap attack and fast decay', () { + final env = Articulation.pizzicato.envelope; + expect(env.attack, equals(0.001)); + expect(env.decay, equals(0.12)); + expect(env.sustain, equals(0.0)); + expect(env.release, equals(0.02)); + }); + + test('spiccato is between staccato and normal', () { + final env = Articulation.spiccato.envelope; + expect(env.attack, equals(0.005)); + expect(env.decay, equals(0.08)); + expect(env.sustain, equals(0.1)); + expect(env.release, equals(0.03)); + }); + + test('tenuto holds full value', () { + final env = Articulation.tenuto.envelope; + expect(env.attack, equals(0.01)); + expect(env.sustain, equals(0.85)); + expect(env.release, equals(0.04)); + }); + }); + + group('Articulation DSL codes', () { + test('codes map contains all presets', () { + expect(Articulation.codes, hasLength(4)); + expect(Articulation.codes['st'], equals(Articulation.staccato)); + expect(Articulation.codes['pz'], equals(Articulation.pizzicato)); + expect(Articulation.codes['sp'], equals(Articulation.spiccato)); + expect(Articulation.codes['tn'], equals(Articulation.tenuto)); + }); + }); + + group('Articulation in NoteEvent', () { + test('NoteEvent stores articulation', () { + final event = NoteEvent( + 440.0, + 1.0, + Waveform.sine, + articulation: Articulation.staccato, + ); + expect(event.articulation, equals(Articulation.staccato)); + }); + + test('NoteEvent articulation defaults to null', () { + final event = NoteEvent(440.0, 1.0, Waveform.sine); + expect(event.articulation, isNull); + }); + }); + + group('Articulation in Melody', () { + test('note() accepts articulation parameter', () { + final melody = Melody(); + melody.note(440.0, 1.0, articulation: Articulation.pizzicato); + final event = melody.events.first as NoteEvent; + expect(event.articulation, equals(Articulation.pizzicato)); + }); + + test('articulation overrides default envelope in synthesis', () { + // Staccato note should be mostly silent at the end + final staccatoMelody = Melody(bpm: 60); + staccatoMelody.note( + 440.0, + 2.0, + articulation: Articulation.staccato, + ); + final samples = staccatoMelody.toSamples(sampleRate: 8000); + + // Last quarter of samples should be near-silent + final lastQuarter = samples.length * 3 ~/ 4; + var maxTail = 0.0; + for (var i = lastQuarter; i < samples.length; i++) { + if (samples[i].abs() > maxTail) maxTail = samples[i].abs(); + } + expect(maxTail, lessThan(0.05)); + }); + }); + + group('Articulation DSL parsing', () { + test('C4.q:st parses staccato', () { + final melody = Melody.parse('C4.q:st'); + expect(melody.events, hasLength(1)); + final event = melody.events.first as NoteEvent; + expect(event.articulation, equals(Articulation.staccato)); + expect(event.beats, equals(Dur.quarter)); + }); + + test('C4.e:pz parses pizzicato', () { + final melody = Melody.parse('C4.e:pz'); + final event = melody.events.first as NoteEvent; + expect(event.articulation, equals(Articulation.pizzicato)); + }); + + test('C4.h:sp parses spiccato', () { + final melody = Melody.parse('C4.h:sp'); + final event = melody.events.first as NoteEvent; + expect(event.articulation, equals(Articulation.spiccato)); + }); + + test('C4.q:tn parses tenuto', () { + final melody = Melody.parse('C4.q:tn'); + final event = melody.events.first as NoteEvent; + expect(event.articulation, equals(Articulation.tenuto)); + }); + + test('dynamics + articulation: C4.q@ff:st', () { + final melody = Melody.parse('C4.q@ff:st'); + final event = melody.events.first as NoteEvent; + expect(event.volume, equals(1.0)); + expect(event.articulation, equals(Articulation.staccato)); + }); + + test('throws on unknown articulation code', () { + expect( + () => Melody.parse('C4.q:xx'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/audio/channel_test.dart b/test/audio/channel_test.dart new file mode 100644 index 0000000..a94ef34 --- /dev/null +++ b/test/audio/channel_test.dart @@ -0,0 +1,150 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Audio', () { + late Audio audio; + + setUp(() { + audio = Audio(); + }); + + tearDown(() { + audio.dispose(); + }); + + test('provides named channels', () { + final sfx = audio.channel('sfx'); + final music = audio.channel('music'); + expect(sfx, isA()); + expect(music, isA()); + expect(sfx, isNot(same(music))); + }); + + test('same name returns same channel instance', () { + final first = audio.channel('sfx'); + final second = audio.channel('sfx'); + expect(first, same(second)); + }); + + test('muted flag defaults to false', () { + expect(audio.muted, isFalse); + }); + + test('muted flag can be toggled', () { + audio.muted = true; + expect(audio.muted, isTrue); + audio.muted = false; + expect(audio.muted, isFalse); + }); + + test('isAvailable returns a bool', () { + expect(audio.isAvailable, isA()); + }); + + test('dispose stops all channels and clears them', () { + // Create some channels. + audio.channel('sfx'); + audio.channel('music'); + audio.channel('ui'); + + // dispose should not throw. + audio.dispose(); + + // After dispose, requesting a channel creates a fresh one. + final fresh = audio.channel('sfx'); + expect(fresh, isA()); + }); + + test('stopAll does not throw on empty channels', () { + expect(() => audio.stopAll(), returnsNormally); + }); + + test('beep does not throw', () { + // With muted on so we don't actually play audio. + audio.muted = true; + expect( + () => audio.beep(frequency: 880.0, duration: 0.1), + returnsNormally, + ); + }); + }); + + group('AudioChannel', () { + late Audio audio; + + setUp(() { + audio = Audio(); + }); + + tearDown(() { + audio.dispose(); + }); + + test('tone() does not throw', () { + // Mute so we don't produce actual audio during tests. + audio.muted = true; + final ch = audio.channel('test'); + expect( + () => ch.tone(frequency: 440.0, duration: 0.1), + returnsNormally, + ); + }); + + test('tone() with all parameters does not throw', () { + audio.muted = true; + final ch = audio.channel('test'); + expect( + () => ch.tone( + frequency: 880.0, + duration: 0.2, + waveform: Waveform.square, + envelope: const Envelope.preset(), + volume: 0.5, + ), + returnsNormally, + ); + }); + + test('playMelody() accepts Melody object', () { + audio.muted = true; + final ch = audio.channel('test'); + final melody = Melody(bpm: 120); + melody.note(Note.C4, Dur.quarter); + melody.note(Note.E4, Dur.quarter); + melody.note(Note.G4, Dur.half); + + expect(() => ch.playMelody(melody), returnsNormally); + }); + + test('stop() cancels playback without error', () { + audio.muted = true; + final ch = audio.channel('test'); + ch.tone(frequency: 440.0); + expect(() => ch.stop(), returnsNormally); + }); + + test('stop() on a fresh channel does not throw', () { + final ch = audio.channel('test'); + expect(() => ch.stop(), returnsNormally); + }); + + test('play() accepts DSL string', () { + audio.muted = true; + final ch = audio.channel('test'); + expect( + () => ch.play('C4.q D4.q E4.h', bpm: 120), + returnsNormally, + ); + }); + + test('play() with default bpm does not throw', () { + audio.muted = true; + final ch = audio.channel('test'); + expect( + () => ch.play('C4.q R.e G4.q'), + returnsNormally, + ); + }); + }); +} diff --git a/test/audio/melody_test.dart b/test/audio/melody_test.dart new file mode 100644 index 0000000..ca7dad3 --- /dev/null +++ b/test/audio/melody_test.dart @@ -0,0 +1,402 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('MelodyEvent', () { + test('NoteEvent stores frequency and duration', () { + final event = NoteEvent(440.0, 1.0, Waveform.sine); + expect(event.frequency, equals(440.0)); + expect(event.beats, equals(1.0)); + expect(event.waveform, equals(Waveform.sine)); + }); + + test('RestEvent stores duration', () { + final event = RestEvent(2.0); + expect(event.beats, equals(2.0)); + }); + }); + + group('Melody builder', () { + test('note adds NoteEvent', () { + final melody = Melody(); + melody.note(440.0, 1.0); + expect(melody.events, hasLength(1)); + expect(melody.events.first, isA()); + final event = melody.events.first as NoteEvent; + expect(event.frequency, equals(440.0)); + expect(event.beats, equals(1.0)); + expect(event.waveform, equals(Waveform.square)); // melody default + }); + + test('note with custom waveform overrides default', () { + final melody = Melody(); + melody.note(440.0, 1.0, waveform: Waveform.sine); + final event = melody.events.first as NoteEvent; + expect(event.waveform, equals(Waveform.sine)); + }); + + test('rest adds RestEvent', () { + final melody = Melody(); + melody.rest(0.5); + expect(melody.events, hasLength(1)); + expect(melody.events.first, isA()); + expect(melody.events.first.beats, equals(0.5)); + }); + + test('totalBeats sums all events', () { + final melody = Melody(); + melody.note(440.0, 1.0); // quarter + melody.rest(0.5); // eighth + melody.note(523.0, 2.0); // half + expect(melody.totalBeats, equals(3.5)); + }); + + test('totalSeconds computes correctly', () { + final melody = Melody(bpm: 120); + melody.note(440.0, 4.0); // whole note = 4 beats + // At 120 BPM, 4 beats = 2.0 seconds + expect(melody.totalSeconds, equals(2.0)); + }); + + test('default constructor values', () { + final melody = Melody(); + expect(melody.bpm, equals(120)); + expect(melody.waveform, equals(Waveform.square)); + expect(melody.volume, equals(0.7)); + expect(melody.events, isEmpty); + }); + + test('waveform is mutable', () { + final melody = Melody(); + expect(melody.waveform, equals(Waveform.square)); + melody.waveform = Waveform.sine; + expect(melody.waveform, equals(Waveform.sine)); + }); + }); + + group('Melody.toSamples', () { + test('produces audio data', () { + final melody = Melody(bpm: 120); + melody.note(440.0, 1.0); // quarter note at 120 BPM = 0.5s + final samples = melody.toSamples(sampleRate: 44100); + expect(samples, isA()); + expect(samples.length, greaterThan(0)); + // Should have non-zero samples (not silence). + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('rest produces silence', () { + final melody = Melody(bpm: 120); + melody.rest(1.0); // quarter rest + final samples = melody.toSamples(sampleRate: 44100); + expect(samples.length, greaterThan(0)); + // All samples should be zero (silence). + for (final s in samples) { + expect(s, equals(0.0)); + } + }); + + test('sample count matches expected duration', () { + final melody = Melody(bpm: 120); + melody.note(440.0, 2.0); // half note = 1.0s at 120 BPM + const sampleRate = 44100; + final samples = melody.toSamples(sampleRate: sampleRate); + // 1.0 seconds * 44100 = 44100 samples + expect(samples.length, equals(44100)); + }); + }); + + group('Melody.parse', () { + test('parses note with octave and duration', () { + final melody = Melody.parse('C4.q'); + expect(melody.events, hasLength(1)); + final event = melody.events.first as NoteEvent; + expect(event.frequency, closeTo(Note.C4, 0.01)); + expect(event.beats, equals(Dur.quarter)); + }); + + test('parses multiple notes', () { + final melody = Melody.parse('C4.q D4.q E4.h'); + expect(melody.events, hasLength(3)); + final first = melody.events[0] as NoteEvent; + final second = melody.events[1] as NoteEvent; + final third = melody.events[2] as NoteEvent; + expect(first.frequency, closeTo(Note.C4, 0.01)); + expect(first.beats, equals(Dur.quarter)); + expect(second.frequency, closeTo(Note.D4, 0.01)); + expect(second.beats, equals(Dur.quarter)); + expect(third.frequency, closeTo(Note.E4, 0.01)); + expect(third.beats, equals(Dur.half)); + }); + + test('parses rests', () { + final melody = Melody.parse('R.q R.h'); + expect(melody.events, hasLength(2)); + expect(melody.events[0], isA()); + expect(melody.events[0].beats, equals(Dur.quarter)); + expect(melody.events[1], isA()); + expect(melody.events[1].beats, equals(Dur.half)); + }); + + test('parses sharps (Cs, Fs, etc.)', () { + final melody = Melody.parse('Cs4.e Fs5.q'); + expect(melody.events, hasLength(2)); + final cs = melody.events[0] as NoteEvent; + final fs = melody.events[1] as NoteEvent; + expect(cs.frequency, closeTo(Note.Cs4, 0.01)); + expect(cs.beats, equals(Dur.eighth)); + expect(fs.frequency, closeTo(Note.Fs5, 0.01)); + expect(fs.beats, equals(Dur.quarter)); + }); + + test('ignores bar separators |', () { + final melody = Melody.parse('C4.q D4.q | E4.q F4.q'); + expect(melody.events, hasLength(4)); + expect(melody.events[0], isA()); + expect(melody.events[1], isA()); + expect(melody.events[2], isA()); + expect(melody.events[3], isA()); + }); + + test('parses all duration codes', () { + final melody = Melody.parse( + 'C4.w C4.h C4.dh C4.q C4.dq C4.e C4.de C4.s C4.t', + ); + expect(melody.events, hasLength(9)); + expect(melody.events[0].beats, equals(Dur.whole)); + expect(melody.events[1].beats, equals(Dur.half)); + expect(melody.events[2].beats, equals(Dur.dottedHalf)); + expect(melody.events[3].beats, equals(Dur.quarter)); + expect(melody.events[4].beats, equals(Dur.dottedQuarter)); + expect(melody.events[5].beats, equals(Dur.eighth)); + expect(melody.events[6].beats, equals(Dur.dottedEighth)); + expect(melody.events[7].beats, equals(Dur.sixteenth)); + expect(melody.events[8].beats, closeTo(1.0 / 3.0, 0.001)); + }); + + test('respects bpm and waveform parameters', () { + final melody = Melody.parse( + 'C4.q', + bpm: 60, + waveform: Waveform.sine, + ); + expect(melody.bpm, equals(60)); + expect(melody.waveform, equals(Waveform.sine)); + }); + + test('throws FormatException on invalid token', () { + expect( + () => Melody.parse('X4.q'), + throwsA(isA()), + ); + }); + + test('throws FormatException on missing dot', () { + expect( + () => Melody.parse('C4q'), + throwsA(isA()), + ); + }); + + test('throws FormatException on unknown duration code', () { + expect( + () => Melody.parse('C4.z'), + throwsA(isA()), + ); + }); + + test('parses dynamics suffix @ff', () { + final melody = Melody.parse('C4.q@ff D4.q@pp'); + expect(melody.events, hasLength(2)); + final c = melody.events[0] as NoteEvent; + final d = melody.events[1] as NoteEvent; + expect(c.volume, equals(1.0)); + expect(d.volume, equals(0.3)); + }); + + test('parses all dynamic levels', () { + final melody = Melody.parse( + 'C4.q@ppp C4.q@pp C4.q@p C4.q@mp C4.q@mf C4.q@f C4.q@ff', + ); + final volumes = + melody.events.cast().map((e) => e.volume).toList(); + expect(volumes, equals([0.15, 0.3, 0.45, 0.6, 0.75, 0.88, 1.0])); + }); + + test('notes without dynamics have null volume', () { + final melody = Melody.parse('C4.q D4.q@mf E4.q'); + final c = melody.events[0] as NoteEvent; + final d = melody.events[1] as NoteEvent; + final e = melody.events[2] as NoteEvent; + expect(c.volume, isNull); + expect(d.volume, equals(0.75)); + expect(e.volume, isNull); + }); + + test('throws FormatException on unknown dynamics', () { + expect( + () => Melody.parse('C4.q@xyz'), + throwsA(isA()), + ); + }); + + test('dynamics work with rests (rest ignores @)', () { + // Rests don't have dynamics — only notes do + final melody = Melody.parse('C4.q@ff R.q C4.q@pp'); + expect(melody.events, hasLength(3)); + expect(melody.events[1], isA()); + }); + + test('parse passes vibrato and timbre to melody', () { + final melody = Melody.parse( + 'C4.q', + vibrato: Vibrato.violin, + timbre: Timbre.violin, + ); + expect(melody.vibrato, equals(Vibrato.violin)); + expect(melody.timbre, equals(Timbre.violin)); + }); + }); + + group('Melody per-note dynamics', () { + test('note with explicit volume stores it', () { + final melody = Melody(); + melody.note(440.0, 1.0, volume: 0.5); + final event = melody.events.first as NoteEvent; + expect(event.volume, equals(0.5)); + }); + + test('note without volume has null volume', () { + final melody = Melody(); + melody.note(440.0, 1.0); + final event = melody.events.first as NoteEvent; + expect(event.volume, isNull); + }); + + test('per-note volume affects sample amplitude', () { + final loud = Melody(bpm: 120, volume: 1.0); + loud.note(440.0, 1.0, volume: 1.0); + final loudSamples = loud.toSamples(); + + final quiet = Melody(bpm: 120, volume: 1.0); + quiet.note(440.0, 1.0, volume: 0.3); + final quietSamples = quiet.toSamples(); + + // Peak of quiet should be less than peak of loud + var loudPeak = 0.0; + var quietPeak = 0.0; + for (final s in loudSamples) { + if (s.abs() > loudPeak) loudPeak = s.abs(); + } + for (final s in quietSamples) { + if (s.abs() > quietPeak) quietPeak = s.abs(); + } + expect(quietPeak, lessThan(loudPeak)); + }); + }); + + group('Melody legato ties', () { + test('NoteEvent stores tied flag', () { + final event = NoteEvent(440.0, 1.0, Waveform.sine, tied: true); + expect(event.tied, isTrue); + }); + + test('NoteEvent tied defaults to false', () { + final event = NoteEvent(440.0, 1.0, Waveform.sine); + expect(event.tied, isFalse); + }); + + test('note() accepts tied parameter', () { + final melody = Melody(); + melody.note(440.0, 1.0, tied: true); + final event = melody.events.first as NoteEvent; + expect(event.tied, isTrue); + }); + + test('tied notes produce continuous audio', () { + // Two tied notes should blend smoothly + final tied = Melody(bpm: 120); + tied.note(440.0, 1.0, tied: true); + tied.note(440.0, 1.0); + final samples = tied.toSamples(sampleRate: 8000); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('DSL C4.q_ parses tie', () { + final melody = Melody.parse('C4.q_ C4.q'); + expect(melody.events, hasLength(2)); + final first = melody.events[0] as NoteEvent; + final second = melody.events[1] as NoteEvent; + expect(first.tied, isTrue); + expect(second.tied, isFalse); + }); + + test('DSL multiple ties: C4.q_ C4.q_ C4.q', () { + final melody = Melody.parse('C4.q_ C4.q_ C4.q'); + expect(melody.events, hasLength(3)); + expect((melody.events[0] as NoteEvent).tied, isTrue); + expect((melody.events[1] as NoteEvent).tied, isTrue); + expect((melody.events[2] as NoteEvent).tied, isFalse); + }); + + test('DSL tie with dynamics: C4.q@mf_', () { + final melody = Melody.parse('C4.q@mf_ C4.q'); + final first = melody.events[0] as NoteEvent; + expect(first.tied, isTrue); + expect(first.volume, equals(0.75)); + }); + + test('DSL tie with articulation: C4.q:tn_', () { + final melody = Melody.parse('C4.q:tn_ C4.q'); + final first = melody.events[0] as NoteEvent; + expect(first.tied, isTrue); + expect(first.articulation, equals(Articulation.tenuto)); + }); + }); + + group('Melody DSL combined suffixes', () { + test('dynamics + articulation + tie: C4.q@ff:st_', () { + final melody = Melody.parse('C4.q@ff:st_ C4.q'); + final event = melody.events[0] as NoteEvent; + expect(event.volume, equals(1.0)); + expect(event.articulation, equals(Articulation.staccato)); + expect(event.tied, isTrue); + }); + + test('mixed notes preserve correct parsing', () { + final melody = Melody.parse('C4.q@ff E4.e:pz G4.h_ G4.h'); + expect(melody.events, hasLength(4)); + final c = melody.events[0] as NoteEvent; + final e = melody.events[1] as NoteEvent; + final g1 = melody.events[2] as NoteEvent; + final g2 = melody.events[3] as NoteEvent; + expect(c.volume, equals(1.0)); + expect(c.articulation, isNull); + expect(c.tied, isFalse); + expect(e.articulation, equals(Articulation.pizzicato)); + expect(g1.tied, isTrue); + expect(g2.tied, isFalse); + }); + }); + + group('Melody with vibrato/timbre', () { + test('toSamples with vibrato produces valid output', () { + final melody = Melody(bpm: 120, vibrato: Vibrato.violin); + melody.note(440.0, 2.0); + final samples = melody.toSamples(); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('toSamples with timbre produces valid output', () { + final melody = Melody(bpm: 120, timbre: Timbre.cello); + melody.note(220.0, 2.0); + final samples = melody.toSamples(); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + }); +} diff --git a/test/audio/notes_test.dart b/test/audio/notes_test.dart new file mode 100644 index 0000000..9f0aab2 --- /dev/null +++ b/test/audio/notes_test.dart @@ -0,0 +1,133 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Note frequencies', () { + test('A4 = 440 Hz', () { + expect(Note.A4, equals(440.0)); + }); + + test('A5 = 880 Hz', () { + expect(Note.A5, equals(880.0)); + }); + + test('C4 ≈ 261.63 Hz', () { + expect(Note.C4, closeTo(261.63, 0.01)); + }); + + test('A0 = 27.5 Hz', () { + expect(Note.A0, equals(27.5)); + }); + + test('C8 ≈ 4186.01 Hz', () { + expect(Note.C8, closeTo(4186.01, 0.01)); + }); + + test('octave doubling: A3 = 220, A4 = 440, A5 = 880', () { + expect(Note.A3, equals(220.0)); + expect(Note.A4, equals(440.0)); + expect(Note.A5, equals(880.0)); + expect(Note.A5, equals(Note.A4 * 2)); + expect(Note.A4, equals(Note.A3 * 2)); + }); + }); + + group('noteFrequency()', () { + test('matches static constant for A4', () { + expect(noteFrequency('A', 4), closeTo(Note.A4, 0.01)); + }); + + test('matches static constant for C4', () { + expect(noteFrequency('C', 4), closeTo(Note.C4, 0.01)); + }); + + test('matches static constant for Cs4 (C#4)', () { + expect(noteFrequency('Cs', 4), closeTo(Note.Cs4, 0.01)); + }); + + test('matches static constant for A5', () { + expect(noteFrequency('A', 5), closeTo(Note.A5, 0.01)); + }); + + test('matches static constant for G3', () { + expect(noteFrequency('G', 3), closeTo(Note.G3, 0.01)); + }); + + test('matches static constant for C0', () { + expect(noteFrequency('C', 0), closeTo(Note.C0, 0.01)); + }); + + test('matches static constant for C8', () { + expect(noteFrequency('C', 8), closeTo(Note.C8, 0.01)); + }); + + test('Note.frequency delegates to noteFrequency', () { + expect(Note.frequency('A', 4), closeTo(noteFrequency('A', 4), 0.001)); + }); + + test('throws on unknown note name', () { + expect(() => noteFrequency('X', 4), throwsArgumentError); + }); + + test('throws on octave out of range', () { + expect(() => noteFrequency('A', -1), throwsArgumentError); + expect(() => noteFrequency('A', 9), throwsArgumentError); + }); + + test('throws for notes above C8', () { + expect(() => noteFrequency('D', 8), throwsArgumentError); + }); + }); + + group('Dur constants', () { + test('whole = 4x quarter', () { + expect(Dur.whole, equals(4.0 * Dur.quarter)); + }); + + test('half = 2x quarter', () { + expect(Dur.half, equals(2.0 * Dur.quarter)); + }); + + test('eighth = 0.5x quarter', () { + expect(Dur.eighth, equals(0.5 * Dur.quarter)); + }); + + test('sixteenth = 0.25x quarter', () { + expect(Dur.sixteenth, equals(0.25 * Dur.quarter)); + }); + + test('thirtySecond = 0.125', () { + expect(Dur.thirtySecond, equals(0.125)); + }); + + test('dotted values are 1.5x their base', () { + expect(Dur.dottedHalf, equals(Dur.half * 1.5)); + expect(Dur.dottedQuarter, equals(Dur.quarter * 1.5)); + expect(Dur.dottedEighth, equals(Dur.eighth * 1.5)); + expect(Dur.dottedSixteenth, equals(Dur.sixteenth * 1.5)); + }); + }); + + group('Dur helpers', () { + test('quarterAt(120) = 0.5 seconds', () { + expect(Dur.quarterAt(120), equals(0.5)); + }); + + test('quarterAt(60) = 1.0 seconds', () { + expect(Dur.quarterAt(60), equals(1.0)); + }); + + test('toSeconds converts beats to seconds', () { + // At 120 BPM, quarter = 0.5s, so whole (4 beats) = 2.0s + expect(Dur.toSeconds(Dur.whole, 120), equals(2.0)); + expect(Dur.toSeconds(Dur.half, 120), equals(1.0)); + expect(Dur.toSeconds(Dur.quarter, 120), equals(0.5)); + expect(Dur.toSeconds(Dur.eighth, 120), equals(0.25)); + }); + + test('toSeconds at 60 BPM: 1 beat = 1 second', () { + expect(Dur.toSeconds(1.0, 60), equals(1.0)); + expect(Dur.toSeconds(4.0, 60), equals(4.0)); + }); + }); +} diff --git a/test/audio/ornaments_test.dart b/test/audio/ornaments_test.dart new file mode 100644 index 0000000..f1628c3 --- /dev/null +++ b/test/audio/ornaments_test.dart @@ -0,0 +1,193 @@ +import 'dart:math'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('MelodyOrnaments.trill', () { + test('expands into alternating notes', () { + final melody = Melody(bpm: 120); + melody.trill(Note.C4, Dur.quarter); + expect(melody.events.length, greaterThanOrEqualTo(4)); + // Even-indexed notes should be C4, odd should be D4 + for (var i = 0; i < melody.events.length; i++) { + final event = melody.events[i] as NoteEvent; + if (i.isEven) { + expect(event.frequency, closeTo(Note.C4, 0.01)); + } else { + // Default 2 semitones up = D4 + final expected = Note.C4 * pow(2.0, 2 / 12.0); + expect(event.frequency, closeTo(expected, 0.01)); + } + } + }); + + test('total beats equal original duration', () { + final melody = Melody(bpm: 120); + melody.trill(Note.A4, Dur.half); + expect(melody.totalBeats, closeTo(Dur.half, 0.001)); + }); + + test('custom semitones interval', () { + final melody = Melody(bpm: 120); + melody.trill(Note.C4, Dur.quarter, semitones: 1); + // Upper note should be 1 semitone up = Cs4 + final upper = melody.events[1] as NoteEvent; + final expected = Note.C4 * pow(2.0, 1 / 12.0); + expect(upper.frequency, closeTo(expected, 0.01)); + }); + + test('volume is passed to individual notes', () { + final melody = Melody(bpm: 120); + melody.trill(Note.C4, Dur.quarter, volume: 0.5); + for (final event in melody.events) { + expect((event as NoteEvent).volume, equals(0.5)); + } + }); + + test('trill produces valid audio', () { + final melody = Melody(bpm: 120); + melody.trill(Note.E5, Dur.half); + final samples = melody.toSamples(sampleRate: 8000); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + }); + + group('MelodyOrnaments.mordent', () { + test('expands into 4 notes (3 ornament + main)', () { + final melody = Melody(bpm: 120); + melody.mordent(Note.E4, Dur.quarter); + expect(melody.events, hasLength(4)); + }); + + test('pattern is principal-lower-principal-principal', () { + final melody = Melody(bpm: 120); + melody.mordent(Note.E4, Dur.quarter); + final notes = melody.events.cast(); + final lower = Note.E4 / pow(2.0, 2 / 12.0); + expect(notes[0].frequency, closeTo(Note.E4, 0.01)); + expect(notes[1].frequency, closeTo(lower, 0.5)); + expect(notes[2].frequency, closeTo(Note.E4, 0.01)); + expect(notes[3].frequency, closeTo(Note.E4, 0.01)); + }); + + test('total beats equal original duration', () { + final melody = Melody(bpm: 120); + melody.mordent(Note.A4, Dur.half); + expect(melody.totalBeats, closeTo(Dur.half, 0.001)); + }); + + test('ornament takes first 25% of duration', () { + final melody = Melody(bpm: 120); + melody.mordent(Note.C4, Dur.quarter); + final notes = melody.events.cast(); + final ornDur = notes[0].beats + notes[1].beats + notes[2].beats; + final mainDur = notes[3].beats; + expect(ornDur, closeTo(Dur.quarter * 0.25, 0.001)); + expect(mainDur, closeTo(Dur.quarter * 0.75, 0.001)); + }); + }); + + group('MelodyOrnaments.turn', () { + test('expands into 5 notes (4 ornament + main)', () { + final melody = Melody(bpm: 120); + melody.turn(Note.G4, Dur.quarter); + expect(melody.events, hasLength(5)); + }); + + test('pattern is upper-principal-lower-principal-principal', () { + final melody = Melody(bpm: 120); + melody.turn(Note.G4, Dur.quarter); + final notes = melody.events.cast(); + final upper = Note.G4 * pow(2.0, 2 / 12.0); + final lower = Note.G4 / pow(2.0, 2 / 12.0); + expect(notes[0].frequency, closeTo(upper, 0.5)); + expect(notes[1].frequency, closeTo(Note.G4, 0.01)); + expect(notes[2].frequency, closeTo(lower, 0.5)); + expect(notes[3].frequency, closeTo(Note.G4, 0.01)); + expect(notes[4].frequency, closeTo(Note.G4, 0.01)); + }); + + test('total beats equal original duration', () { + final melody = Melody(bpm: 120); + melody.turn(Note.G4, Dur.half); + expect(melody.totalBeats, closeTo(Dur.half, 0.001)); + }); + }); + + group('MelodyOrnaments.graceNote', () { + test('expands into 2 notes (grace + main)', () { + final melody = Melody(bpm: 120); + melody.graceNote(Note.D4, Note.E4, Dur.quarter); + expect(melody.events, hasLength(2)); + }); + + test('first note is grace frequency, second is main', () { + final melody = Melody(bpm: 120); + melody.graceNote(Note.D4, Note.E4, Dur.quarter); + final notes = melody.events.cast(); + expect(notes[0].frequency, closeTo(Note.D4, 0.01)); + expect(notes[1].frequency, closeTo(Note.E4, 0.01)); + }); + + test('grace note is very short', () { + final melody = Melody(bpm: 120); + melody.graceNote(Note.D4, Note.E4, Dur.quarter); + final grace = melody.events[0] as NoteEvent; + // Grace note max 12% of total or 1/32nd + expect(grace.beats, lessThanOrEqualTo(Dur.thirtySecond)); + }); + + test('total beats equal original duration', () { + final melody = Melody(bpm: 120); + melody.graceNote(Note.D4, Note.E4, Dur.half); + expect(melody.totalBeats, closeTo(Dur.half, 0.001)); + }); + + test('grace note produces valid audio', () { + final melody = Melody(bpm: 120); + melody.graceNote(Note.B3, Note.C4, Dur.quarter); + final samples = melody.toSamples(sampleRate: 8000); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + }); + + group('Ornament DSL parsing', () { + test('C4.q~tr parses as trill', () { + final melody = Melody.parse('C4.q~tr'); + // Trill expands to multiple notes + expect(melody.events.length, greaterThanOrEqualTo(4)); + expect(melody.totalBeats, closeTo(Dur.quarter, 0.001)); + }); + + test('E4.h~mr parses as mordent', () { + final melody = Melody.parse('E4.h~mr'); + expect(melody.events, hasLength(4)); + expect(melody.totalBeats, closeTo(Dur.half, 0.001)); + }); + + test('G4.q~tn parses as turn', () { + final melody = Melody.parse('G4.q~tn'); + expect(melody.events, hasLength(5)); + expect(melody.totalBeats, closeTo(Dur.quarter, 0.001)); + }); + + test('ornament with dynamics: C4.q@ff~tr', () { + final melody = Melody.parse('C4.q@ff~tr'); + expect(melody.events.length, greaterThanOrEqualTo(4)); + // All trill notes should have ff volume + for (final event in melody.events) { + expect((event as NoteEvent).volume, equals(1.0)); + } + }); + + test('throws on unknown ornament code', () { + expect( + () => Melody.parse('C4.q~xx'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/audio/player_test.dart b/test/audio/player_test.dart new file mode 100644 index 0000000..fe7c1b2 --- /dev/null +++ b/test/audio/player_test.dart @@ -0,0 +1,67 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('AudioPlayer', () { + late AudioPlayer player; + + setUp(() { + player = AudioPlayer(); + }); + + tearDown(() { + player.stop(); + }); + + test('isAvailable returns a bool', () { + // Platform detection should not throw. + final result = player.isAvailable; + expect(result, isA()); + }); + + test('playWav completes without error for a valid WAV', () async { + if (!player.isAvailable) { + markTestSkipped('No audio player available on this platform'); + return; + } + + // Generate a very short tone (50ms) and encode to WAV. + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.05, + volume: 0.0, // silent so tests don't make noise + ); + final wavBytes = WavWriter.encode(samples); + + // Should complete without throwing. + await player.playWav(wavBytes); + }); + + test('stop kills running playback', () async { + if (!player.isAvailable) { + markTestSkipped('No audio player available on this platform'); + return; + } + + // Generate a longer tone so we have time to stop it. + final samples = Synthesizer.tone( + frequency: 440, + duration: 5.0, + volume: 0.0, // silent + ); + final wavBytes = WavWriter.encode(samples); + + // Start playback but don't await — we want to stop it mid-play. + final playFuture = player.playWav(wavBytes); + + // Give the process a moment to start. + await Future.delayed(const Duration(milliseconds: 200)); + + // Stop should kill the process. + player.stop(); + + // The future should complete (process was killed). + await playFuture; + }); + }); +} diff --git a/test/audio/presets_test.dart b/test/audio/presets_test.dart new file mode 100644 index 0000000..d677021 --- /dev/null +++ b/test/audio/presets_test.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('SFX presets', () { + for (final preset in SFX.values) { + test('${preset.name} produces non-empty, non-silent samples', () { + final samples = preset.toSamples(); + expect(samples, isA()); + expect(samples.length, greaterThan(0), reason: '${preset.name} should produce non-empty samples'); + expect(samples.any((s) => s != 0.0), isTrue, reason: '${preset.name} should not be completely silent'); + }); + } + + test('beep is short (< 0.5s at 44100 Hz)', () { + final samples = SFX.beep.toSamples(sampleRate: 44100); + expect(samples.length, lessThan(22050), reason: 'beep should be shorter than 0.5 seconds'); + }); + + test('error has descending frequencies', () { + final melody = SFX.error.melody; + final notes = melody.events.whereType().toList(); + expect(notes.length, greaterThanOrEqualTo(2)); + expect(notes.first.frequency, greaterThan(notes.last.frequency), + reason: 'error preset should have descending frequencies'); + }); + + test('success has ascending frequencies', () { + final melody = SFX.success.melody; + final notes = melody.events.whereType().toList(); + expect(notes.length, greaterThanOrEqualTo(2)); + expect(notes.first.frequency, lessThan(notes.last.frequency), + reason: 'success preset should have ascending frequencies'); + }); + }); +} diff --git a/test/audio/reverb_test.dart b/test/audio/reverb_test.dart new file mode 100644 index 0000000..47c8d9a --- /dev/null +++ b/test/audio/reverb_test.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Reverb', () { + test('default parameters are reasonable', () { + const r = Reverb(); + expect(r.mix, equals(0.3)); + expect(r.decay, equals(0.5)); + }); + + test('presets have expected characteristics', () { + expect(Reverb.church.decay, greaterThan(Reverb.room.decay)); + expect(Reverb.hall.decay, greaterThan(Reverb.church.decay)); + expect(Reverb.room.mix, lessThan(Reverb.hall.mix)); + }); + }); + + group('Reverb.apply', () { + test('zero mix returns copy of input', () { + final input = Float64List.fromList([0.5, -0.3, 0.8, -0.1]); + final output = const Reverb(mix: 0.0).apply(input); + for (var i = 0; i < input.length; i++) { + expect(output[i], equals(input[i])); + } + }); + + test('output has same length as input', () { + final input = Synthesizer.tone(frequency: 440, duration: 0.2); + final output = Reverb.church.apply(input); + expect(output.length, equals(input.length)); + }); + + test('output is within [-1, 1]', () { + final input = Synthesizer.tone(frequency: 440, duration: 0.3); + final output = Reverb.hall.apply(input); + for (final s in output) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('reverb modifies the signal (not identical to input)', () { + final input = Synthesizer.tone(frequency: 440, duration: 0.3); + final output = Reverb.church.apply(input); + var diffCount = 0; + for (var i = 0; i < input.length; i++) { + if ((input[i] - output[i]).abs() > 0.001) diffCount++; + } + expect(diffCount, greaterThan(0)); + }); + + test('empty input returns empty output', () { + final output = Reverb.church.apply(Float64List(0)); + expect(output.length, equals(0)); + }); + + test('reverb adds energy after signal ends', () { + // Create a short tone followed by silence + final tone = Synthesizer.tone(frequency: 440, duration: 0.05); + final input = Float64List(tone.length + 44100); // +1s silence + for (var i = 0; i < tone.length; i++) { + input[i] = tone[i]; + } + + final output = Reverb.church.apply(input); + + // Check that samples after the tone are non-zero (reverb tail) + var hasReverbTail = false; + for (var i = tone.length + 1000; i < output.length; i++) { + if (output[i].abs() > 0.001) { + hasReverbTail = true; + break; + } + } + expect(hasReverbTail, isTrue); + }); + }); + + group('Reverb.applyStereo', () { + test('processes both channels', () { + final mono = Synthesizer.tone(frequency: 440, duration: 0.2); + final stereo = StereoSamples.fromMono(mono, pan: -0.3); + final result = Reverb.room.applyStereo(stereo); + expect(result.length, equals(stereo.length)); + expect(result.left.any((s) => s != 0.0), isTrue); + expect(result.right.any((s) => s != 0.0), isTrue); + }); + }); +} diff --git a/test/audio/stereo_test.dart b/test/audio/stereo_test.dart new file mode 100644 index 0000000..865bdc1 --- /dev/null +++ b/test/audio/stereo_test.dart @@ -0,0 +1,149 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('StereoSamples', () { + test('constructor asserts equal channel lengths', () { + final left = Float64List(100); + final right = Float64List(100); + final stereo = StereoSamples(left, right); + expect(stereo.length, equals(100)); + }); + + test('length returns channel length', () { + final s = StereoSamples(Float64List(50), Float64List(50)); + expect(s.length, equals(50)); + }); + }); + + group('StereoSamples.fromMono', () { + test('center pan gives equal L and R', () { + final mono = Float64List.fromList([1.0, -1.0, 0.5]); + final stereo = StereoSamples.fromMono(mono, pan: 0.0); + for (var i = 0; i < mono.length; i++) { + expect(stereo.left[i], closeTo(stereo.right[i], 0.001)); + } + }); + + test('full left pan gives signal in left only', () { + final mono = Float64List.fromList([1.0, 0.5]); + final stereo = StereoSamples.fromMono(mono, pan: -1.0); + expect(stereo.left[0], closeTo(1.0, 0.001)); + expect(stereo.right[0], closeTo(0.0, 0.001)); + }); + + test('full right pan gives signal in right only', () { + final mono = Float64List.fromList([1.0, 0.5]); + final stereo = StereoSamples.fromMono(mono, pan: 1.0); + expect(stereo.left[0], closeTo(0.0, 0.001)); + expect(stereo.right[0], closeTo(1.0, 0.001)); + }); + + test('constant power: center is not -6dB', () { + final mono = Float64List.fromList([1.0]); + final stereo = StereoSamples.fromMono(mono, pan: 0.0); + // With constant-power panning, center = cos(pi/4) = ~0.707 + final gain = stereo.left[0]; + expect(gain, closeTo(cos(pi / 4), 0.001)); + }); + + test('pan clamps to [-1, 1]', () { + final mono = Float64List.fromList([1.0]); + final stereo = StereoSamples.fromMono(mono, pan: 5.0); + // Should behave like pan: 1.0 + expect(stereo.left[0], closeTo(0.0, 0.001)); + expect(stereo.right[0], closeTo(1.0, 0.001)); + }); + }); + + group('StereoSamples.mix', () { + test('mix empty list returns empty stereo', () { + final result = StereoSamples.mix([]); + expect(result.length, equals(0)); + }); + + test('mix single buffer returns same values', () { + final buf = StereoSamples( + Float64List.fromList([0.5, -0.3]), + Float64List.fromList([0.2, 0.7]), + ); + final result = StereoSamples.mix([buf]); + expect(result.left[0], closeTo(0.5, 0.001)); + expect(result.right[1], closeTo(0.7, 0.001)); + }); + + test('mix adds signals and clamps', () { + final a = StereoSamples( + Float64List.fromList([0.8]), + Float64List.fromList([0.5]), + ); + final b = StereoSamples( + Float64List.fromList([0.8]), + Float64List.fromList([0.3]), + ); + final result = StereoSamples.mix([a, b]); + expect(result.left[0], closeTo(1.0, 0.001)); // clamped + expect(result.right[0], closeTo(0.8, 0.001)); + }); + + test('mix handles different length buffers', () { + final short = StereoSamples( + Float64List.fromList([0.5]), + Float64List.fromList([0.5]), + ); + final long = StereoSamples( + Float64List.fromList([0.3, 0.7]), + Float64List.fromList([0.3, 0.7]), + ); + final result = StereoSamples.mix([short, long]); + expect(result.length, equals(2)); + }); + }); + + group('StereoSamples.toMono', () { + test('downmix averages L and R', () { + final stereo = StereoSamples( + Float64List.fromList([1.0, 0.0]), + Float64List.fromList([0.0, 1.0]), + ); + final mono = stereo.toMono(); + expect(mono[0], closeTo(0.5, 0.001)); + expect(mono[1], closeTo(0.5, 0.001)); + }); + }); + + group('WavWriter.encodeStereo', () { + test('produces valid WAV header for stereo', () { + final stereo = StereoSamples( + Float64List.fromList([0.5, -0.5]), + Float64List.fromList([0.3, -0.3]), + ); + final wav = WavWriter.encodeStereo(stereo); + + // Check RIFF header + expect(wav[0], equals(0x52)); // R + expect(wav[1], equals(0x49)); // I + expect(wav[2], equals(0x46)); // F + expect(wav[3], equals(0x46)); // F + + // Check channels = 2 (offset 22, little-endian) + expect(wav[22], equals(2)); + expect(wav[23], equals(0)); + }); + + test('stereo file is larger than mono for same frame count', () { + final mono = Float64List.fromList([0.5, -0.5, 0.3]); + final stereo = StereoSamples( + Float64List.fromList([0.5, -0.5, 0.3]), + Float64List.fromList([0.5, -0.5, 0.3]), + ); + final monoWav = WavWriter.encode(mono); + final stereoWav = WavWriter.encodeStereo(stereo); + // Stereo has 2x the samples (interleaved) + expect(stereoWav.length, greaterThan(monoWav.length)); + }); + }); +} diff --git a/test/audio/synthesizer_test.dart b/test/audio/synthesizer_test.dart new file mode 100644 index 0000000..c0e67d9 --- /dev/null +++ b/test/audio/synthesizer_test.dart @@ -0,0 +1,301 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Waveform', () { + const frequency = 440.0; + const duration = 0.1; + const sampleRate = 44100; + final expectedLength = (duration * sampleRate).round(); + + test('sine produces correct range and length', () { + final samples = Waveform.sine.generate(frequency, duration, sampleRate); + expect(samples.length, expectedLength); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('square produces correct range and length', () { + final samples = Waveform.square.generate(frequency, duration, sampleRate); + expect(samples.length, expectedLength); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('square wave values are exactly +1 or -1', () { + final samples = Waveform.square.generate(frequency, duration, sampleRate); + for (final s in samples) { + expect(s == 1.0 || s == -1.0, isTrue, + reason: 'Square wave sample should be +1 or -1, got $s'); + } + }); + + test('triangle produces correct range and length', () { + final samples = + Waveform.triangle.generate(frequency, duration, sampleRate); + expect(samples.length, expectedLength); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('triangle is symmetric', () { + // Generate exactly one period so symmetry is clean. + final onePeriodDuration = 1.0 / frequency; + final samples = + Waveform.triangle.generate(frequency, onePeriodDuration, sampleRate); + final n = samples.length; + final half = n ~/ 2; + // First half ramps up, second half ramps down — mirror symmetry. + for (var i = 1; i < half; i++) { + final mirrorIndex = n - i; + if (mirrorIndex < n) { + expect(samples[i], closeTo(samples[mirrorIndex], 0.05), + reason: 'Triangle should be symmetric at index $i'); + } + } + }); + + test('sawtooth produces correct range and length', () { + final samples = + Waveform.sawtooth.generate(frequency, duration, sampleRate); + expect(samples.length, expectedLength); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('sawtooth ramps correctly within a period', () { + // Generate one period. + final onePeriodDuration = 1.0 / frequency; + final samples = + Waveform.sawtooth.generate(frequency, onePeriodDuration, sampleRate); + // Samples should generally increase across the period. + // Check that the first sample is near -1 and the last is near +1. + expect(samples.first, closeTo(-1.0, 0.05)); + expect(samples.last, closeTo(1.0, 0.05)); + }); + + test('noise produces correct range and length', () { + final samples = Waveform.noise.generate(frequency, duration, sampleRate); + expect(samples.length, expectedLength); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('noise is random within range', () { + final samples = Waveform.noise.generate(frequency, 0.5, sampleRate); + // Check there is variance — not all the same value. + final unique = samples.toSet(); + expect(unique.length, greaterThan(1), + reason: 'Noise should produce varied values'); + // Check spread: should have positive and negative values. + expect(samples.any((s) => s > 0), isTrue); + expect(samples.any((s) => s < 0), isTrue); + }); + }); + + group('Envelope', () { + test('ADSR shapes amplitude correctly', () { + final env = Envelope( + attack: 0.1, + decay: 0.1, + sustain: 0.5, + release: 0.1, + ); + const duration = 0.5; + + // During attack: amplitude rises. + expect(env.amplitudeAt(0.0, duration), closeTo(0.0, 0.01)); + expect(env.amplitudeAt(0.05, duration), closeTo(0.5, 0.01)); + expect(env.amplitudeAt(0.1, duration), closeTo(1.0, 0.01)); + + // During decay: amplitude falls from 1 to sustain. + expect(env.amplitudeAt(0.15, duration), closeTo(0.75, 0.01)); + expect(env.amplitudeAt(0.2, duration), closeTo(0.5, 0.01)); + + // During sustain: amplitude stays at sustain level. + expect(env.amplitudeAt(0.3, duration), closeTo(0.5, 0.01)); + + // During release: amplitude falls to 0. + expect(env.amplitudeAt(0.4, duration), closeTo(0.5, 0.01)); + expect(env.amplitudeAt(0.45, duration), closeTo(0.25, 0.01)); + expect(env.amplitudeAt(0.5, duration), closeTo(0.0, 0.01)); + }); + + test('preset starts near 0 during attack', () { + final env = Envelope.preset(); + // At the very start, amplitude should be near 0. + expect(env.amplitudeAt(0.0, 0.5), closeTo(0.0, 0.01)); + // A tiny bit into attack, still low. + expect(env.amplitudeAt(0.001, 0.5), lessThan(0.2)); + }); + }); + + group('Synthesizer', () { + test('tone produces non-silent output', () { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.1, + ); + expect(samples.length, greaterThan(0)); + // At least some samples should be non-zero. + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('tone applies envelope (first and last samples near 0)', () { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.2, + envelope: Envelope( + attack: 0.05, + decay: 0.02, + sustain: 0.8, + release: 0.05, + ), + ); + // First sample should be near zero (start of attack). + expect(samples.first.abs(), lessThan(0.01)); + // Last sample should be near zero (end of release). + expect(samples.last.abs(), lessThan(0.01)); + }); + + test('mix combines buffers additively and clamps', () { + final a = Float64List.fromList([0.5, -0.5, 0.8]); + final b = Float64List.fromList([0.5, -0.5, 0.8]); + final result = Synthesizer.mix([a, b]); + expect(result.length, 3); + expect(result[0], closeTo(1.0, 0.001)); + expect(result[1], closeTo(-1.0, 0.001)); + // 0.8 + 0.8 = 1.6, clamped to 1.0. + expect(result[2], closeTo(1.0, 0.001)); + }); + + test('mix handles different length buffers', () { + final a = Float64List.fromList([0.5, 0.3]); + final b = Float64List.fromList([0.5, 0.3, 0.7, 0.9]); + final result = Synthesizer.mix([a, b]); + expect(result.length, 4); + expect(result[0], closeTo(1.0, 0.001)); + expect(result[2], closeTo(0.7, 0.001)); + }); + + test('mix of empty list returns empty buffer', () { + final result = Synthesizer.mix([]); + expect(result.length, 0); + }); + }); + + group('Synthesizer with vibrato', () { + test('tone with vibrato produces non-silent output', () { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.3, + vibrato: Vibrato.violin, + ); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('tone with vibrato differs from without', () { + final withVib = Synthesizer.tone( + frequency: 440, + duration: 0.5, + vibrato: const Vibrato(rate: 6.0, depth: 30.0, delay: 0.0), + ); + final without = Synthesizer.tone(frequency: 440, duration: 0.5); + var diffCount = 0; + for (var i = 0; i < withVib.length; i++) { + if ((withVib[i] - without[i]).abs() > 0.001) diffCount++; + } + expect(diffCount, greaterThan(0)); + }); + + test('vibrato with all waveforms produces valid output', () { + for (final wave in [ + Waveform.sine, + Waveform.square, + Waveform.triangle, + Waveform.sawtooth, + ]) { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.2, + waveform: wave, + vibrato: Vibrato.violin, + ); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + } + }); + + test('noise waveform ignores vibrato', () { + final withVib = Synthesizer.tone( + frequency: 440, + duration: 0.1, + waveform: Waveform.noise, + vibrato: Vibrato.violin, + ); + expect(withVib.length, greaterThan(0)); + }); + }); + + group('Synthesizer with timbre', () { + test('tone with timbre produces non-silent output', () { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.2, + timbre: Timbre.violin, + ); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('tone with timbre and vibrato combined', () { + final samples = Synthesizer.tone( + frequency: 440, + duration: 0.3, + timbre: Timbre.cello, + vibrato: Vibrato.cello, + ); + expect(samples.length, greaterThan(0)); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('timbre overrides waveform parameter', () { + // With timbre, the waveform param should be ignored + final withTimbre = Synthesizer.tone( + frequency: 440, + duration: 0.1, + waveform: Waveform.square, + timbre: Timbre.pure, + ); + final sine = Synthesizer.tone( + frequency: 440, + duration: 0.1, + waveform: Waveform.sine, + ); + // Pure timbre should be close to sine, not square + var closeToSine = 0; + for (var i = 0; i < withTimbre.length; i++) { + if ((withTimbre[i] - sine[i]).abs() < 0.1) closeToSine++; + } + expect(closeToSine / withTimbre.length, greaterThan(0.8)); + }); + }); +} diff --git a/test/audio/tempo_map_test.dart b/test/audio/tempo_map_test.dart new file mode 100644 index 0000000..ae8dab3 --- /dev/null +++ b/test/audio/tempo_map_test.dart @@ -0,0 +1,142 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('TempoChange', () { + test('stores beat and bpm', () { + const tc = TempoChange(8.0, 80); + expect(tc.beat, equals(8.0)); + expect(tc.bpm, equals(80)); + }); + }); + + group('TempoMap.bpmAt', () { + test('returns baseBpm when no changes', () { + final map = TempoMap(120, []); + expect(map.bpmAt(0.0), equals(120.0)); + expect(map.bpmAt(100.0), equals(120.0)); + }); + + test('returns baseBpm before first change', () { + final map = TempoMap(120, [TempoChange(8.0, 80)]); + expect(map.bpmAt(0.0), closeTo(120.0, 0.1)); + }); + + test('interpolates to first change', () { + final map = TempoMap(120, [TempoChange(4.0, 80)]); + // At beat 2, should be halfway between 120 and 80 = 100 + expect(map.bpmAt(2.0), closeTo(100.0, 0.5)); + }); + + test('returns last bpm after last change', () { + final map = TempoMap(120, [TempoChange(4.0, 80)]); + expect(map.bpmAt(10.0), equals(80.0)); + }); + + test('interpolates between two changes (ritardando)', () { + final map = TempoMap(120, [ + TempoChange(4.0, 120), + TempoChange(8.0, 60), + ]); + // Midpoint between beat 4 and 8 = beat 6 + expect(map.bpmAt(6.0), closeTo(90.0, 0.5)); + }); + + test('handles multiple change points', () { + final map = TempoMap(120, [ + TempoChange(4.0, 100), + TempoChange(8.0, 60), + TempoChange(12.0, 120), + ]); + expect(map.bpmAt(6.0), closeTo(80.0, 0.5)); + expect(map.bpmAt(10.0), closeTo(90.0, 0.5)); + expect(map.bpmAt(20.0), equals(120.0)); + }); + + test('sorts changes by beat position', () { + // Changes provided out of order + final map = TempoMap(120, [ + TempoChange(8.0, 60), + TempoChange(4.0, 100), + ]); + expect(map.bpmAt(6.0), closeTo(80.0, 0.5)); + }); + }); + + group('TempoMap.beatsToSeconds', () { + test('constant tempo matches simple calculation', () { + final map = TempoMap(120, []); + // At 120 BPM: 1 beat = 0.5s, 4 beats = 2.0s + expect(map.beatsToSeconds(0, 4.0), closeTo(2.0, 0.05)); + }); + + test('zero beats returns zero', () { + final map = TempoMap(120, []); + expect(map.beatsToSeconds(0, 0), equals(0.0)); + }); + + test('ritardando makes phrase longer', () { + final constant = TempoMap(120, []); + final rit = TempoMap(120, [ + TempoChange(0.0, 120), + TempoChange(4.0, 60), // slow to half speed + ]); + final normalDuration = constant.beatsToSeconds(0, 4.0); + final ritDuration = rit.beatsToSeconds(0, 4.0); + expect(ritDuration, greaterThan(normalDuration)); + }); + + test('accelerando makes phrase shorter', () { + final constant = TempoMap(60, []); + final accel = TempoMap(60, [ + TempoChange(0.0, 60), + TempoChange(4.0, 120), + ]); + final normalDuration = constant.beatsToSeconds(0, 4.0); + final accelDuration = accel.beatsToSeconds(0, 4.0); + expect(accelDuration, lessThan(normalDuration)); + }); + }); + + group('Melody with TempoMap', () { + test('totalSeconds accounts for tempo map', () { + final melody = Melody( + bpm: 120, + tempoMap: TempoMap(120, []), + ); + melody.note(440.0, 4.0); // 4 beats at 120 BPM = 2s + expect(melody.totalSeconds, closeTo(2.0, 0.05)); + }); + + test('tempo map produces valid samples', () { + final melody = Melody( + bpm: 120, + tempoMap: TempoMap(120, [ + TempoChange(0.0, 120), + TempoChange(2.0, 80), + ]), + ); + melody.note(440.0, 2.0); + melody.note(330.0, 2.0); + final samples = melody.toSamples(); + expect(samples.length, greaterThan(0)); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('ritardando produces longer output than fixed tempo', () { + final fixed = Melody(bpm: 120); + fixed.note(440.0, 4.0); + + final rit = Melody( + bpm: 120, + tempoMap: TempoMap(120, [ + TempoChange(0.0, 120), + TempoChange(4.0, 60), + ]), + ); + rit.note(440.0, 4.0); + + expect(rit.toSamples().length, greaterThan(fixed.toSamples().length)); + }); + }); +} diff --git a/test/audio/timbre_test.dart b/test/audio/timbre_test.dart new file mode 100644 index 0000000..2817a39 --- /dev/null +++ b/test/audio/timbre_test.dart @@ -0,0 +1,118 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Harmonic', () { + test('stores number and amplitude', () { + const h = Harmonic(3, 0.42); + expect(h.number, equals(3)); + expect(h.amplitude, equals(0.42)); + }); + }); + + group('Timbre presets', () { + test('violin has 8 harmonics starting with fundamental', () { + expect(Timbre.violin.harmonics.length, equals(8)); + expect(Timbre.violin.harmonics.first.number, equals(1)); + expect(Timbre.violin.harmonics.first.amplitude, equals(1.0)); + }); + + test('cello has strong low harmonics', () { + final cello = Timbre.cello; + // 2nd harmonic should be stronger than violin's + final cello2nd = cello.harmonics[1].amplitude; + final violin2nd = Timbre.violin.harmonics[1].amplitude; + expect(cello2nd, greaterThan(violin2nd)); + }); + + test('pure timbre has only fundamental', () { + expect(Timbre.pure.harmonics.length, equals(1)); + expect(Timbre.pure.harmonics.first.number, equals(1)); + }); + + test('all presets have decreasing amplitude', () { + for (final timbre in [Timbre.violin, Timbre.viola, Timbre.cello]) { + for (var i = 1; i < timbre.harmonics.length; i++) { + expect( + timbre.harmonics[i].amplitude, + lessThanOrEqualTo(timbre.harmonics[i - 1].amplitude), + reason: 'Harmonic ${timbre.harmonics[i].number} ' + 'should be <= harmonic ${timbre.harmonics[i - 1].number}', + ); + } + } + }); + }); + + group('Timbre.generate', () { + const sampleRate = 44100; + const frequency = 440.0; + const duration = 0.1; + + test('produces correct length', () { + final samples = Timbre.violin.generate(frequency, duration, sampleRate); + final expected = (duration * sampleRate).round(); + expect(samples.length, equals(expected)); + }); + + test('output is within [-1, 1]', () { + final samples = Timbre.violin.generate(frequency, duration, sampleRate); + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('pure timbre matches sine wave shape', () { + final pure = Timbre.pure.generate(frequency, duration, sampleRate); + final sine = Waveform.sine.generate(frequency, duration, sampleRate); + // Pure timbre with 1 harmonic should be very close to sine + for (var i = 0; i < pure.length; i++) { + expect(pure[i], closeTo(sine[i], 0.02)); + } + }); + + test('violin timbre differs from pure sine', () { + final violin = Timbre.violin.generate(frequency, duration, sampleRate); + final pure = Timbre.pure.generate(frequency, duration, sampleRate); + // Should have differences due to overtones + var diffSum = 0.0; + for (var i = 0; i < violin.length; i++) { + diffSum += (violin[i] - pure[i]).abs(); + } + expect(diffSum / violin.length, greaterThan(0.01)); + }); + + test('respects Nyquist — skips harmonics above half sample rate', () { + // Very high base frequency — most harmonics should be above Nyquist + final samples = Timbre.violin.generate(10000.0, 0.01, sampleRate); + expect(samples.length, greaterThan(0)); + // Should still produce valid output + for (final s in samples) { + expect(s, greaterThanOrEqualTo(-1.0)); + expect(s, lessThanOrEqualTo(1.0)); + } + }); + + test('generates non-silent output', () { + final samples = Timbre.cello.generate(220.0, 0.2, sampleRate); + expect(samples.any((s) => s != 0.0), isTrue); + }); + + test('works with vibrato parameter', () { + final withVib = Timbre.violin.generate( + frequency, + 0.5, + sampleRate, + vibrato: Vibrato.violin, + ); + final without = Timbre.violin.generate(frequency, 0.5, sampleRate); + // Should produce different samples due to pitch modulation + var diffCount = 0; + for (var i = 0; i < withVib.length; i++) { + if ((withVib[i] - without[i]).abs() > 0.001) diffCount++; + } + expect(diffCount, greaterThan(0)); + }); + }); +} diff --git a/test/audio/wav_writer_test.dart b/test/audio/wav_writer_test.dart new file mode 100644 index 0000000..22393c6 --- /dev/null +++ b/test/audio/wav_writer_test.dart @@ -0,0 +1,146 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('WavWriter', () { + group('RIFF/WAVE header', () { + test('starts with RIFF magic bytes', () { + final wav = WavWriter.encode(Float64List(0)); + // "RIFF" + expect(wav[0], equals(0x52)); // R + expect(wav[1], equals(0x49)); // I + expect(wav[2], equals(0x46)); // F + expect(wav[3], equals(0x46)); // F + }); + + test('contains WAVE format identifier', () { + final wav = WavWriter.encode(Float64List(0)); + // "WAVE" at offset 8 + expect(wav[8], equals(0x57)); // W + expect(wav[9], equals(0x41)); // A + expect(wav[10], equals(0x56)); // V + expect(wav[11], equals(0x45)); // E + }); + + test('contains fmt sub-chunk', () { + final wav = WavWriter.encode(Float64List(0)); + // "fmt " at offset 12 + expect(wav[12], equals(0x66)); // f + expect(wav[13], equals(0x6D)); // m + expect(wav[14], equals(0x74)); // t + expect(wav[15], equals(0x20)); // (space) + }); + + test('contains data sub-chunk', () { + final wav = WavWriter.encode(Float64List(0)); + // "data" at offset 36 + expect(wav[36], equals(0x64)); // d + expect(wav[37], equals(0x61)); // a + expect(wav[38], equals(0x74)); // t + expect(wav[39], equals(0x61)); // a + }); + + test('audio format is PCM (1)', () { + final wav = WavWriter.encode(Float64List(0)); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint16(20, Endian.little), equals(1)); + }); + + test('channel count is 1 (mono)', () { + final wav = WavWriter.encode(Float64List(0)); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint16(22, Endian.little), equals(1)); + }); + + test('sample rate is written correctly', () { + final wav = WavWriter.encode(Float64List(0), sampleRate: 22050); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint32(24, Endian.little), equals(22050)); + }); + + test('bits per sample is 16', () { + final wav = WavWriter.encode(Float64List(0)); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint16(34, Endian.little), equals(16)); + }); + }); + + group('output length', () { + test('empty samples produces 44-byte header only', () { + final wav = WavWriter.encode(Float64List(0)); + expect(wav.length, equals(44)); + }); + + test('output length equals 44 + numSamples * 2', () { + final samples = Float64List.fromList([0.0, 0.5, -0.5, 1.0, -1.0]); + final wav = WavWriter.encode(samples); + expect(wav.length, equals(44 + 5 * 2)); + }); + + test('RIFF chunk size is fileSize - 8', () { + final samples = Float64List.fromList([0.0, 0.5, -0.5]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint32(4, Endian.little), equals(wav.length - 8)); + }); + + test('data chunk size equals numSamples * 2', () { + final samples = Float64List.fromList([0.0, 0.5, -0.5]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getUint32(40, Endian.little), equals(3 * 2)); + }); + }); + + group('sample clamping', () { + test('values greater than 1.0 are clamped to 32767', () { + final samples = Float64List.fromList([2.0, 1.5, 100.0]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getInt16(44, Endian.little), equals(32767)); + expect(byteData.getInt16(46, Endian.little), equals(32767)); + expect(byteData.getInt16(48, Endian.little), equals(32767)); + }); + + test('values less than -1.0 are clamped to -32767', () { + final samples = Float64List.fromList([-2.0, -1.5, -100.0]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + // -1.0 * 32767 = -32767 + expect(byteData.getInt16(44, Endian.little), equals(-32767)); + expect(byteData.getInt16(46, Endian.little), equals(-32767)); + expect(byteData.getInt16(48, Endian.little), equals(-32767)); + }); + + test('exact 1.0 maps to 32767', () { + final samples = Float64List.fromList([1.0]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getInt16(44, Endian.little), equals(32767)); + }); + + test('exact -1.0 maps to -32767', () { + final samples = Float64List.fromList([-1.0]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getInt16(44, Endian.little), equals(-32767)); + }); + + test('0.0 maps to 0', () { + final samples = Float64List.fromList([0.0]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getInt16(44, Endian.little), equals(0)); + }); + + test('0.5 maps to approximately 16384', () { + final samples = Float64List.fromList([0.5]); + final wav = WavWriter.encode(samples); + final byteData = ByteData.sublistView(wav); + expect(byteData.getInt16(44, Endian.little), equals(16384)); + }); + }); + }); +} diff --git a/test/canvas_test.dart b/test/canvas_test.dart new file mode 100644 index 0000000..8b0741b --- /dev/null +++ b/test/canvas_test.dart @@ -0,0 +1,258 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Canvas', () { + late Screen screen; + late Canvas canvas; + + setUp(() { + screen = Screen(Size(20, 10)); + canvas = screen.canvas(); + }); + + group('initialization', () { + test('has correct size from screen', () { + expect(canvas.width, equals(20)); + expect(canvas.height, equals(10)); + }); + + test('has zero position by default', () { + expect(canvas.x, equals(0)); + expect(canvas.y, equals(0)); + }); + + test('custom canvas has specified size and position', () { + var custom = screen.canvas(Size(10, 5), Position(3, 2)); + expect(custom.width, equals(10)); + expect(custom.height, equals(5)); + expect(custom.x, equals(3)); + expect(custom.y, equals(2)); + }); + }); + + group('write delegation', () { + test('write at 0,0 writes to screen at canvas position', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(5, 3)); + offsetCanvas.write(0, 0, 'X'); + expect(screen.stringAt(5, 3), equals('X')); + }); + + test('write adds canvas offset to coordinates', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(2, 1)); + offsetCanvas.write(3, 2, 'Y'); + expect(screen.stringAt(5, 3), equals('Y')); // 2+3=5, 1+2=3 + }); + + test('write at origin canvas goes to screen origin', () { + canvas.write(0, 0, 'A'); + expect(screen.stringAt(0, 0), equals('A')); + }); + }); + + group('occluded delegation', () { + test('occluded checks screen with offset applied', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(5, 3)); + expect(offsetCanvas.occluded(0, 0), isFalse); + screen.write(5, 3, 'X'); + expect(offsetCanvas.occluded(0, 0), isTrue); + }); + + test('occluded returns true for out of bounds via screen', () { + // Canvas at offset 15,8 on 20x10 screen + var edgeCanvas = screen.canvas(Size(10, 5), Position(15, 8)); + // Writing at 5,2 would be 20,10 on screen - out of bounds + expect(edgeCanvas.occluded(5, 2), isTrue); + }); + }); + + group('stringAt delegation', () { + test('stringAt reads from screen with offset', () { + screen.write(7, 4, 'Z'); + var offsetCanvas = screen.canvas(Size(10, 5), Position(5, 3)); + expect(offsetCanvas.stringAt(2, 1), equals('Z')); // 5+2=7, 3+1=4 + }); + }); + + group('nested canvas', () { + test('canvas creates child canvas with combined offset', () { + var parent = screen.canvas(Size(15, 8), Position(2, 1)); + var child = parent.canvas(Size(10, 5), Position(3, 2)); + + expect(child.width, equals(10)); + expect(child.height, equals(5)); + expect(child.x, equals(5)); // 2+3 + expect(child.y, equals(3)); // 1+2 + }); + + test('nested canvas write goes to correct screen position', () { + var parent = screen.canvas(Size(15, 8), Position(2, 1)); + var child = parent.canvas(Size(10, 5), Position(3, 2)); + child.write(1, 1, 'N'); + expect(screen.stringAt(6, 4), equals('N')); // 2+3+1=6, 1+2+1=4 + }); + + test('deeply nested canvas accumulates offsets', () { + var level1 = screen.canvas(Size(18, 9), Position(1, 0)); + var level2 = level1.canvas(Size(15, 7), Position(1, 1)); + var level3 = level2.canvas(Size(10, 5), Position(2, 1)); + + expect(level3.x, equals(4)); // 1+1+2 + expect(level3.y, equals(2)); // 0+1+1 + + level3.write(0, 0, 'D'); + expect(screen.stringAt(4, 2), equals('D')); + }); + }); + + group('bounds safety with offset', () { + test('negative local coordinates are clipped', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(5, 5)); + offsetCanvas.write(-2, -2, 'V'); + // Negative local coords should be rejected + expect(screen.stringAt(3, 3), equals('')); + }); + + test('out of screen bounds is handled gracefully', () { + var offsetCanvas = screen.canvas(Size(10, 5), Position(18, 8)); + // Writing at 5,5 would be 23,13 - out of bounds, should be ignored + offsetCanvas.write(5, 5, 'X'); + // Should not throw + expect(screen.occluded(19, 9), isFalse); + }); + }); + + group('clipping', () { + test('rejects write at negative local x', () { + var child = screen.canvas(Size(10, 5), Position(5, 5)); + child.write(-1, 0, 'X'); + expect(screen.stringAt(4, 5), equals('')); + }); + + test('rejects write at negative local y', () { + var child = screen.canvas(Size(10, 5), Position(5, 5)); + child.write(0, -1, 'X'); + expect(screen.stringAt(5, 4), equals('')); + }); + + test('rejects write at x >= width', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + child.write(5, 0, 'X'); + expect(screen.stringAt(5, 0), equals('')); + }); + + test('rejects write at y >= height', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + child.write(0, 5, 'X'); + expect(screen.stringAt(0, 5), equals('')); + }); + + test('allows write within local bounds', () { + var child = screen.canvas(Size(5, 5), Position(2, 2)); + child.write(4, 4, 'V'); + expect(screen.stringAt(6, 6), equals('V')); + }); + + test('child canvas clips to parent bounds', () { + // Parent occupies screen columns 2-11 (width 10 at offset 2) + var parent = screen.canvas(Size(10, 5), Position(2, 0)); + // Child thinks it has width 8 starting at offset 5 within parent + // Absolute: 2+5=7, so child occupies screen columns 7-14 + // But parent ends at column 11, so writes at x>=5 should be clipped + var child = parent.canvas(Size(8, 3), Position(5, 0)); + child.write(0, 0, 'A'); // abs 7 — within parent, OK + child.write(4, 0, 'B'); // abs 11 — within parent (< clipRight 12), OK + child.write(5, 0, 'C'); // abs 12 — clipped (>= clipRight 12) + + expect(screen.stringAt(7, 0), equals('A')); + expect(screen.stringAt(11, 0), equals('B')); + expect(screen.stringAt(12, 0), equals('')); + }); + + test('nested canvases intersect clip bounds', () { + // Screen is 20x10 + // Level 1: width 15 at offset 0 → clipRight=15 + var level1 = screen.canvas(Size(15, 8), Position(0, 0)); + // Level 2: width 12 at offset 5 within level1 → abs 5, clipRight=min(17,15)=15 + var level2 = level1.canvas(Size(12, 6), Position(5, 0)); + // Level 3: width 10 at offset 3 within level2 → abs 8, clipRight=min(18,15)=15 + var level3 = level2.canvas(Size(10, 4), Position(3, 0)); + + level3.write(0, 0, 'X'); // abs 8 — OK + level3.write(6, 0, 'Y'); // abs 14 — OK (< 15) + level3.write(7, 0, 'Z'); // abs 15 — clipped (>= 15) + + expect(screen.stringAt(8, 0), equals('X')); + expect(screen.stringAt(14, 0), equals('Y')); + expect(screen.stringAt(15, 0), equals('')); + }); + + test('occluded returns true for out-of-bounds local coords', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + expect(child.occluded(-1, 0), isTrue); + expect(child.occluded(0, -1), isTrue); + expect(child.occluded(5, 0), isTrue); + expect(child.occluded(0, 5), isTrue); + }); + + test('occluded respects clip bounds', () { + var parent = screen.canvas(Size(10, 5), Position(0, 0)); + var child = parent.canvas(Size(15, 3), Position(5, 0)); + // child at abs 5, clipRight=min(20,10)=10 + // Local x=5 → abs 10, which is >= clipRight + expect(child.occluded(5, 0), isTrue); + // Local x=4 → abs 9, which is < clipRight + expect(child.occluded(4, 0), isFalse); + }); + + test('stringAt returns empty for out-of-bounds local coords', () { + var child = screen.canvas(Size(5, 5), Position(0, 0)); + expect(child.stringAt(-1, 0), equals('')); + expect(child.stringAt(5, 0), equals('')); + expect(child.stringAt(0, -1), equals('')); + expect(child.stringAt(0, 5), equals('')); + }); + + test('stringAt respects clip bounds', () { + var parent = screen.canvas(Size(10, 5), Position(0, 0)); + screen.write(12, 0, 'Z'); + var child = parent.canvas(Size(15, 3), Position(5, 0)); + // child at abs 5, clipRight=min(20,10)=10 + // Local x=7 → abs 12, which is >= clipRight + expect(child.stringAt(7, 0), equals('')); + // Local x=4 → abs 9, which is < clipRight + expect(child.stringAt(4, 0), equals('')); + }); + }); + + group('position immutability', () { + test('canvas() does not mutate passed Position object', () { + var parent = screen.canvas(Size(15, 8), Position(2, 1)); + var childOffset = Position(3, 2); + + // Store original values + var originalX = childOffset.x; + var originalY = childOffset.y; + + // Create child canvas + parent.canvas(Size(10, 5), childOffset); + + // Verify passed Position was not mutated + expect(childOffset.x, equals(originalX)); + expect(childOffset.y, equals(originalY)); + }); + + test('nested canvas calls do not accumulate mutations', () { + var level1 = screen.canvas(Size(18, 9), Position(1, 0)); + var offset = Position(2, 2); + + level1.canvas(Size(15, 7), offset); + level1.canvas(Size(15, 7), offset); + + // If mutations accumulated, offset would be wrong + expect(offset.x, equals(2)); + expect(offset.y, equals(2)); + }); + }); + }); +} diff --git a/test/focus_test.dart b/test/focus_test.dart new file mode 100644 index 0000000..66ac3ee --- /dev/null +++ b/test/focus_test.dart @@ -0,0 +1,447 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +/// Test class that uses FocusManager mixin +class TestFocusContainer with FocusManager { + View root; + TestFocusContainer(this.root); + + @override + View get focusRoot => root; +} + +/// Focusable test view that tracks callbacks +class FocusableView extends View { + bool onFocusCalled = false; + bool onBlurCalled = false; + List keysReceived = []; + List pastesReceived = []; + + FocusableView() { + focusable = true; + } + + @override + void onFocus() { + onFocusCalled = true; + } + + @override + void onBlur() { + onBlurCalled = true; + } + + @override + bool onKey(String key) { + keysReceived.add(key); + return true; + } + + @override + bool onPaste(String text) { + pastesReceived.add(text); + return true; + } +} + +/// Non-focusable test view +class NonFocusableView extends View {} + +void main() { + group('View focus properties', () { + test('focusable defaults to false', () { + var view = NonFocusableView(); + expect(view.focusable, isFalse); + }); + + test('focused defaults to false', () { + var view = FocusableView(); + expect(view.focused, isFalse); + }); + + test('focusable can be set to true', () { + var view = FocusableView(); + expect(view.focusable, isTrue); + }); + }); + + group('focusableViews collection', () { + test('returns empty list for non-focusable view without children', () { + var view = NonFocusableView(); + expect(view.focusableViews, isEmpty); + }); + + test('returns self if focusable', () { + var view = FocusableView(); + var focusable = view.focusableViews; + expect(focusable.length, equals(1)); + expect(focusable[0], same(view)); + }); + + test('collects focusable children recursively', () { + var parent = NonFocusableView(); + var child1 = FocusableView(); + var child2 = FocusableView(); + parent.children = [child1, child2]; + + var focusable = parent.focusableViews; + expect(focusable.length, equals(2)); + expect(focusable[0], same(child1)); + expect(focusable[1], same(child2)); + }); + + test('includes self and children if both focusable', () { + var parent = FocusableView(); + var child = FocusableView(); + parent.children = [child]; + + var focusable = parent.focusableViews; + expect(focusable.length, equals(2)); + expect(focusable[0], same(parent)); + expect(focusable[1], same(child)); + }); + + test('collects deeply nested focusable views in tree order', () { + // Build tree: + // root (non-focusable) + // ├── branch1 (non-focusable) + // │ ├── leaf1 (focusable) + // │ └── leaf2 (focusable) + // └── branch2 (focusable) + // └── leaf3 (focusable) + var root = NonFocusableView(); + var branch1 = NonFocusableView(); + var branch2 = FocusableView(); + var leaf1 = FocusableView(); + var leaf2 = FocusableView(); + var leaf3 = FocusableView(); + + branch1.children = [leaf1, leaf2]; + branch2.children = [leaf3]; + root.children = [branch1, branch2]; + + var focusable = root.focusableViews; + expect(focusable.length, equals(4)); + expect(focusable[0], same(leaf1)); + expect(focusable[1], same(leaf2)); + expect(focusable[2], same(branch2)); + expect(focusable[3], same(leaf3)); + }); + + test('skips non-focusable views in tree', () { + var root = NonFocusableView(); + var nonFocusable = NonFocusableView(); + var focusableChild = FocusableView(); + root.children = [nonFocusable, focusableChild]; + + var focusable = root.focusableViews; + expect(focusable.length, equals(1)); + expect(focusable[0], same(focusableChild)); + }); + }); + + group('onFocus and onBlur callbacks', () { + test('onFocus is called when focused', () { + var view = FocusableView(); + expect(view.onFocusCalled, isFalse); + view.focused = true; + view.onFocus(); + expect(view.onFocusCalled, isTrue); + }); + + test('onBlur is called when focus is lost', () { + var view = FocusableView(); + view.focused = true; + expect(view.onBlurCalled, isFalse); + view.onBlur(); + expect(view.onBlurCalled, isTrue); + }); + }); + + group('onKey handling', () { + test('onKey receives key and returns handled status', () { + var view = FocusableView(); + var handled = view.onKey('a'); + expect(handled, isTrue); + expect(view.keysReceived, contains('a')); + }); + + test('default onKey returns false (not handled)', () { + var view = NonFocusableView(); + var handled = view.onKey('a'); + expect(handled, isFalse); + }); + }); + + group('SplitView with focusable children', () { + test('collects focusable views through SplitView', () { + var split = SplitView(horizontal: true); + var left = FocusableView(); + var right = FocusableView(); + split.children = [left, right]; + + var focusable = split.focusableViews; + expect(focusable.length, equals(2)); + expect(focusable[0], same(left)); + expect(focusable[1], same(right)); + }); + + test('nested SplitViews collect all focusable views', () { + var outer = SplitView(horizontal: true); + var inner = SplitView(horizontal: false); + var view1 = FocusableView(); + var view2 = FocusableView(); + var view3 = FocusableView(); + + inner.children = [view1, view2]; + outer.children = [inner, view3]; + + var focusable = outer.focusableViews; + expect(focusable.length, equals(3)); + expect(focusable[0], same(view1)); + expect(focusable[1], same(view2)); + expect(focusable[2], same(view3)); + }); + }); + + group('FocusManager mixin', () { + late NonFocusableView root; + late FocusableView view1; + late FocusableView view2; + late FocusableView view3; + late TestFocusContainer manager; + + setUp(() { + root = NonFocusableView(); + view1 = FocusableView(); + view2 = FocusableView(); + view3 = FocusableView(); + root.children = [view1, view2, view3]; + manager = TestFocusContainer(root); + }); + + test('focusFirst focuses the first focusable view', () { + manager.focusFirst(); + expect(manager.focusedView, same(view1)); + expect(view1.focused, isTrue); + }); + + test('focus sets focused state and calls callbacks', () { + manager.focus(view2); + + expect(manager.focusedView, same(view2)); + expect(view2.focused, isTrue); + expect(view2.onFocusCalled, isTrue); + }); + + test('focus calls onBlur on previous view', () { + manager.focus(view1); + manager.focus(view2); + + expect(view1.focused, isFalse); + expect(view1.onBlurCalled, isTrue); + expect(view2.focused, isTrue); + }); + + test('focusNext cycles through views', () { + manager.focusFirst(); + expect(manager.focusedView, same(view1)); + + manager.focusNext(); + expect(manager.focusedView, same(view2)); + + manager.focusNext(); + expect(manager.focusedView, same(view3)); + + manager.focusNext(); + expect(manager.focusedView, same(view1)); // Cycles back + }); + + test('focusPrev cycles backwards', () { + manager.focus(view1); + + manager.focusPrev(); + expect(manager.focusedView, same(view3)); // Cycles to end + + manager.focusPrev(); + expect(manager.focusedView, same(view2)); + }); + + test('routeKeyToFocused sends key to focused view', () { + manager.focus(view1); + var handled = manager.routeKeyToFocused('x'); + + expect(handled, isTrue); + expect(view1.keysReceived, contains('x')); + }); + + test('routeKeyToFocused returns false when no view focused', () { + var handled = manager.routeKeyToFocused('x'); + expect(handled, isFalse); + }); + + test('focus ignores non-focusable views', () { + var nonFocusable = NonFocusableView(); + manager.focus(nonFocusable); + expect(manager.focusedView, isNull); + }); + }); + + group('Focus propagation to Frame', () { + late NonFocusableView root; + late TestFocusContainer manager; + + test('Frame.focused is true when child inside it is focused', () { + root = NonFocusableView(); + var frame = Frame(); + var child = FocusableView(); + frame.children = [child]; + root.children = [frame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + + expect(child.focused, isTrue); + expect(frame.focused, isTrue); + }); + + test('Frame.focused is false when focus moves outside', () { + root = NonFocusableView(); + var frame = Frame(); + var child = FocusableView(); + var outside = FocusableView(); + frame.children = [child]; + root.children = [frame, outside]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + expect(frame.focused, isTrue); + + manager.focus(outside); + expect(frame.focused, isFalse); + }); + + test('Frame stays focused when focus moves between its children', () { + root = NonFocusableView(); + var frame = Frame(); + var child1 = FocusableView(); + var child2 = FocusableView(); + frame.children = [child1, child2]; + root.children = [frame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child1); + expect(frame.focused, isTrue); + + manager.focus(child2); + expect(frame.focused, isTrue); + }); + + test('nested Frames: both ancestor Frames get focused', () { + root = NonFocusableView(); + var outerFrame = Frame(); + var innerFrame = Frame(); + var child = FocusableView(); + innerFrame.children = [child]; + outerFrame.children = [innerFrame]; + root.children = [outerFrame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(child); + + expect(innerFrame.focused, isTrue); + expect(outerFrame.focused, isTrue); + }); + + test('nested Frames: inner unfocuses when focus moves to outer-only child', () { + root = NonFocusableView(); + var outerFrame = Frame(); + var innerFrame = Frame(); + var innerChild = FocusableView(); + var outerChild = FocusableView(); + innerFrame.children = [innerChild]; + outerFrame.children = [innerFrame, outerChild]; + root.children = [outerFrame]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focus(innerChild); + expect(innerFrame.focused, isTrue); + expect(outerFrame.focused, isTrue); + + manager.focus(outerChild); + expect(innerFrame.focused, isFalse); + expect(outerFrame.focused, isTrue); + }); + + test('focusNext propagates focus to Frames correctly', () { + root = NonFocusableView(); + var frame = Frame(); + var child1 = FocusableView(); + var child2 = FocusableView(); + var outside = FocusableView(); + frame.children = [child1, child2]; + root.children = [frame, outside]; + root.resize(Size(80, 40), Position(0, 0)); + manager = TestFocusContainer(root); + + manager.focusFirst(); // child1 + expect(frame.focused, isTrue); + + manager.focusNext(); // child2 + expect(frame.focused, isTrue); + + manager.focusNext(); // outside + expect(frame.focused, isFalse); + + manager.focusNext(); // back to child1 + expect(frame.focused, isTrue); + }); + }); + + group('Paste routing', () { + late NonFocusableView root; + late FocusableView view1; + late FocusableView view2; + late TestFocusContainer manager; + + setUp(() { + root = NonFocusableView(); + view1 = FocusableView(); + view2 = FocusableView(); + root.children = [view1, view2]; + manager = TestFocusContainer(root); + }); + + test('routePasteToFocused sends paste to focused view', () { + manager.focus(view1); + var handled = manager.routePasteToFocused('pasted text'); + expect(handled, isTrue); + expect(view1.pastesReceived, equals(['pasted text'])); + }); + + test('routePasteToFocused returns false when no view focused', () { + var handled = manager.routePasteToFocused('text'); + expect(handled, isFalse); + }); + + test('paste goes to currently focused view only', () { + manager.focus(view1); + manager.routePasteToFocused('first'); + manager.focus(view2); + manager.routePasteToFocused('second'); + + expect(view1.pastesReceived, equals(['first'])); + expect(view2.pastesReceived, equals(['second'])); + }); + + test('default onPaste returns false', () { + var view = NonFocusableView(); + expect(view.onPaste('text'), isFalse); + }); + }); +} diff --git a/test/input_parser_test.dart b/test/input_parser_test.dart new file mode 100644 index 0000000..1a9e5ca --- /dev/null +++ b/test/input_parser_test.dart @@ -0,0 +1,201 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('InputParser', () { + late List keys; + late List pastes; + late InputParser parser; + + setUp(() { + keys = []; + pastes = []; + parser = InputParser( + onKey: (k) => keys.add(k), + onPaste: (t) => pastes.add(t), + ); + }); + + group('single characters', () { + test('emits printable ASCII one at a time', () { + parser.feed('abc'); + expect(keys, equals(['a', 'b', 'c'])); + }); + + test('emits space', () { + parser.feed(' '); + expect(keys, equals([' '])); + }); + + test('emits control characters that are not ESC', () { + parser.feed('\n'); + expect(keys, equals(['\n'])); + }); + + test('emits tab', () { + parser.feed('\t'); + expect(keys, equals(['\t'])); + }); + + test('emits backspace (0x7f)', () { + parser.feed('\x7f'); + expect(keys, equals(['\x7f'])); + }); + }); + + group('CSI escape sequences', () { + test('parses arrow keys', () { + parser.feed('\x1b[A'); + expect(keys, equals(['\x1b[A'])); + }); + + test('parses multiple escape sequences in one chunk', () { + parser.feed('\x1b[A\x1b[B'); + expect(keys, equals(['\x1b[A', '\x1b[B'])); + }); + + test('parses delete key (CSI with tilde)', () { + parser.feed('\x1b[3~'); + expect(keys, equals(['\x1b[3~'])); + }); + + test('parses page down (CSI 6~)', () { + parser.feed('\x1b[6~'); + expect(keys, equals(['\x1b[6~'])); + }); + + test('parses shift-tab (CSI Z)', () { + parser.feed('\x1b[Z'); + expect(keys, equals(['\x1b[Z'])); + }); + + test('parses sequence followed by regular chars', () { + parser.feed('\x1b[Aabc'); + expect(keys, equals(['\x1b[A', 'a', 'b', 'c'])); + }); + + test('parses regular chars followed by sequence', () { + parser.feed('xy\x1b[B'); + expect(keys, equals(['x', 'y', '\x1b[B'])); + }); + + test('parses CSI with semicolon parameters', () { + parser.feed('\x1b[1;5C'); // Ctrl+Right in many terminals + expect(keys, equals(['\x1b[1;5C'])); + }); + }); + + group('bracketed paste', () { + test('detects paste and emits via onPaste', () { + parser.feed('\x1b[200~hello world\x1b[201~'); + expect(keys, isEmpty); + expect(pastes, equals(['hello world'])); + }); + + test('paste with newlines', () { + parser.feed('\x1b[200~line1\nline2\nline3\x1b[201~'); + expect(pastes, equals(['line1\nline2\nline3'])); + }); + + test('paste followed by regular key', () { + parser.feed('\x1b[200~pasted\x1b[201~a'); + expect(pastes, equals(['pasted'])); + expect(keys, equals(['a'])); + }); + + test('regular key followed by paste', () { + parser.feed('x\x1b[200~pasted\x1b[201~'); + expect(keys, equals(['x'])); + expect(pastes, equals(['pasted'])); + }); + + test('paste split across chunks', () { + parser.feed('\x1b[200~hel'); + expect(pastes, isEmpty); + + parser.feed('lo\x1b[201~'); + expect(pastes, equals(['hello'])); + }); + + test('paste end marker split across chunks', () { + parser.feed('\x1b[200~text\x1b'); + expect(pastes, isEmpty); + + parser.feed('[201~'); + expect(pastes, equals(['text'])); + }); + + test('empty paste', () { + parser.feed('\x1b[200~\x1b[201~'); + expect(pastes, equals([''])); + }); + + test('paste with escape chars that are not end marker', () { + parser.feed('\x1b[200~has \x1b in it\x1b[201~'); + expect(pastes, equals(['has \x1b in it'])); + }); + }); + + group('incomplete sequences and timeout', () { + test('buffers lone ESC until more data arrives', () { + parser.feed('\x1b'); + expect(keys, isEmpty, reason: 'ESC should be buffered'); + + parser.feed('[A'); + expect(keys, equals(['\x1b[A'])); + }); + + test('buffers incomplete CSI until completed', () { + parser.feed('\x1b['); + expect(keys, isEmpty); + + parser.feed('3~'); + expect(keys, equals(['\x1b[3~'])); + }); + + test('lone ESC emits after timeout', () async { + parser.feed('\x1b'); + expect(keys, isEmpty); + + // Wait for timeout to fire + await Future.delayed(Duration(milliseconds: 80)); + expect(keys, equals(['\x1b'])); + }); + + test('incomplete CSI flushes after timeout', () async { + parser.feed('\x1b['); + expect(keys, isEmpty); + + await Future.delayed(Duration(milliseconds: 80)); + expect(keys, equals(['\x1b', '['])); + }); + + test('timeout is cancelled when more data arrives', () async { + parser.feed('\x1b'); + expect(keys, isEmpty); + + // Data arrives before timeout + await Future.delayed(Duration(milliseconds: 10)); + parser.feed('[B'); + expect(keys, equals(['\x1b[B'])); + + // Wait past timeout — no extra ESC should appear + await Future.delayed(Duration(milliseconds: 80)); + expect(keys, equals(['\x1b[B'])); + }); + + test('arrow key split across two chunks', () { + parser.feed('\x1b'); + parser.feed('[A'); + expect(keys, equals(['\x1b[A'])); + }); + + test('delete key split across three chunks', () { + parser.feed('\x1b'); + parser.feed('[3'); + parser.feed('~'); + expect(keys, equals(['\x1b[3~'])); + }); + }); + }); +} diff --git a/test/input_test.dart b/test/input_test.dart new file mode 100644 index 0000000..7ae12ef --- /dev/null +++ b/test/input_test.dart @@ -0,0 +1,84 @@ +import 'dart:math'; +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Input', () { + late Input input; + late List changes; + + setUp(() { + input = Input(); + input.focusable = true; + input.focused = true; + input.resize(Size(40, 1), Position(0, 0)); + changes = []; + input.onChange = (v) => changes.add(v); + }); + + group('onPaste', () { + test('inserts pasted text at cursor position', () { + input.onPaste('hello'); + expect(input.value, equals('hello')); + expect(input.cursorPosition, equals(5)); + }); + + test('inserts at cursor in middle of text', () { + input.value = 'ac'; + input.cursorPosition = 1; + input.onPaste('b'); + expect(input.value, equals('abc')); + expect(input.cursorPosition, equals(2)); + }); + + test('replaces newlines with spaces', () { + input.onPaste('line1\nline2\nline3'); + expect(input.value, equals('line1 line2 line3')); + }); + + test('respects maxLength', () { + input = Input(maxLength: 5); + input.focused = true; + input.resize(Size(40, 1), Position(0, 0)); + input.onPaste('hello world'); + expect(input.value, equals('hello')); + }); + + test('respects remaining maxLength with existing text', () { + input = Input(maxLength: 8); + input.focused = true; + input.resize(Size(40, 1), Position(0, 0)); + input.value = 'abc'; + input.cursorPosition = 3; + input.onPaste('defghij'); + expect(input.value, equals('abcdefgh')); + }); + + test('fires onChange', () { + input.onPaste('text'); + expect(changes, equals(['text'])); + }); + + test('returns true (handled)', () { + expect(input.onPaste('x'), isTrue); + }); + + test('strips carriage returns', () { + input.onPaste('win\r\nlines'); + expect(input.value, equals('win lines')); + }); + }); + + group('onKey printable char', () { + test('accepts regular ASCII', () { + input.onKey('a'); + expect(input.value, equals('a')); + }); + + test('rejects control characters', () { + input.onKey('\x01'); + expect(input.value, equals('')); + }); + }); + }); +} diff --git a/test/layout_test.dart b/test/layout_test.dart deleted file mode 100644 index 169ded0..0000000 --- a/test/layout_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -library tui.test.layout; - -import 'package:unittest/unittest.dart'; - -import 'package:tui/tui.dart'; - -void main() { - - group('layout tests', () { - test('window', () { - expect(1, equals(1)); - }); - }); -} \ No newline at end of file diff --git a/test/page_view_test.dart b/test/page_view_test.dart new file mode 100644 index 0000000..d001e97 --- /dev/null +++ b/test/page_view_test.dart @@ -0,0 +1,460 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +/// Test page that tracks lifecycle calls +class TestPage extends Page { + bool enterCalled = false; + bool leaveCalled = false; + bool _canProceed = true; + dynamic _data; + + TestPage({bool canProceed = true, dynamic data}) { + _canProceed = canProceed; + _data = data; + } + + @override + void onEnter() { + enterCalled = true; + } + + @override + void onLeave() { + leaveCalled = true; + } + + @override + bool canProceed() => _canProceed; + + void setCanProceed(bool value) => _canProceed = value; + + @override + dynamic getData() => _data; +} + +void main() { + group('PageView construction', () { + test('requires at least one page', () { + expect(() => PageView(pages: []), throwsArgumentError); + }); + + test('attaches pages to PageView', () { + var page = TestPage(); + PageView(pages: [page]); + expect(page.pageView, isNotNull); + }); + + test('starts at first page', () { + var pages = [TestPage(), TestPage(), TestPage()]; + var pv = PageView(pages: pages); + expect(pv.currentIndex, equals(0)); + expect(pv.currentPage, same(pages[0])); + }); + + test('reports correct page count', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + expect(pv.pageCount, equals(2)); + }); + }); + + group('PageView navigation', () { + test('goNext moves to next page', () { + var pages = [TestPage(), TestPage(), TestPage()]; + var pv = PageView(pages: pages); + pv.start(); + + expect(pv.goNext(), isTrue); + expect(pv.currentIndex, equals(1)); + }); + + test('goNext returns false at last page', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + pv.start(); + + pv.goNext(); + expect(pv.goNext(), isFalse); + expect(pv.currentIndex, equals(1)); + }); + + test('goPrev moves to previous page', () { + var pages = [TestPage(), TestPage(), TestPage()]; + var pv = PageView(pages: pages); + pv.start(); + pv.goNext(); + pv.goNext(); + + expect(pv.goPrev(), isTrue); + expect(pv.currentIndex, equals(1)); + }); + + test('goPrev returns false at first page', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + pv.start(); + + expect(pv.goPrev(), isFalse); + expect(pv.currentIndex, equals(0)); + }); + + test('goTo navigates to unlocked page', () { + var pv = PageView(pages: [TestPage(), TestPage(), TestPage()]); + pv.start(); + pv.unlock(2); + + expect(pv.goTo(2), isTrue); + expect(pv.currentIndex, equals(2)); + }); + + test('goTo fails for locked future page', () { + var pv = PageView(pages: [TestPage(), TestPage(), TestPage()]); + pv.start(); + + expect(pv.goTo(2), isFalse); + expect(pv.currentIndex, equals(0)); + }); + + test('goTo allows navigation to previous pages', () { + var pv = PageView(pages: [TestPage(), TestPage(), TestPage()]); + pv.start(); + pv.goNext(); + pv.goNext(); + + expect(pv.goTo(0), isTrue); + expect(pv.currentIndex, equals(0)); + }); + + test('goTo returns false for same page', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + pv.start(); + + expect(pv.goTo(0), isFalse); + }); + + test('goTo returns false for invalid index', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + pv.start(); + + expect(pv.goTo(-1), isFalse); + expect(pv.goTo(5), isFalse); + }); + }); + + group('PageView validation', () { + test('goNext blocked when canProceed returns false', () { + var page = TestPage(canProceed: false); + var pv = PageView(pages: [page, TestPage()]); + pv.start(); + + expect(pv.goNext(), isFalse); + expect(pv.currentIndex, equals(0)); + }); + + test('goNext succeeds when canProceed returns true', () { + var page = TestPage(canProceed: true); + var pv = PageView(pages: [page, TestPage()]); + pv.start(); + + expect(pv.goNext(), isTrue); + expect(pv.currentIndex, equals(1)); + }); + + test('canProceed checked dynamically', () { + var page = TestPage(canProceed: false); + var pv = PageView(pages: [page, TestPage()]); + pv.start(); + + expect(pv.goNext(), isFalse); + + page.setCanProceed(true); + expect(pv.goNext(), isTrue); + }); + }); + + group('PageView lifecycle', () { + test('start calls onEnter on first page', () { + var page = TestPage(); + var pv = PageView(pages: [page, TestPage()]); + + expect(page.enterCalled, isFalse); + pv.start(); + expect(page.enterCalled, isTrue); + }); + + test('goNext calls onLeave then onEnter', () { + var page1 = TestPage(); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + + pv.goNext(); + + expect(page1.leaveCalled, isTrue); + expect(page2.enterCalled, isTrue); + }); + + test('goPrev calls onLeave then onEnter', () { + var page1 = TestPage(); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + pv.goNext(); + + // Reset tracking + page1.enterCalled = false; + page2.leaveCalled = false; + + pv.goPrev(); + + expect(page2.leaveCalled, isTrue); + expect(page1.enterCalled, isTrue); + }); + + test('failed goNext does not call lifecycle methods', () { + var page1 = TestPage(canProceed: false); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + + pv.goNext(); + + expect(page1.leaveCalled, isFalse); + expect(page2.enterCalled, isFalse); + }); + }); + + group('PageView data collection', () { + test('getData returns page data after navigation', () { + var page = TestPage(data: 'test-value'); + var pv = PageView(pages: [page, TestPage()]); + pv.start(); + + pv.goNext(); + + expect(pv.getData(0), equals('test-value')); + }); + + test('getData returns null for page not yet left', () { + var page = TestPage(data: 'test-value'); + var pv = PageView(pages: [page, TestPage()]); + pv.start(); + + expect(pv.getData(0), isNull); + }); + + test('getAllData returns all collected data', () { + var page1 = TestPage(data: 'value1'); + var page2 = TestPage(data: {'key': 'value2'}); + var page3 = TestPage(); + var pv = PageView(pages: [page1, page2, page3]); + pv.start(); + + pv.goNext(); + pv.goNext(); + + var allData = pv.getAllData(); + expect(allData[0], equals('value1')); + expect(allData[1], equals({'key': 'value2'})); + expect(allData.containsKey(2), isFalse); + }); + + test('getAllData returns unmodifiable map', () { + var pv = PageView(pages: [TestPage(data: 'x'), TestPage()]); + pv.start(); + pv.goNext(); + + var allData = pv.getAllData(); + expect(() => allData[0] = 'modified', throwsUnsupportedError); + }); + }); + + group('PageView unlocking', () { + test('unlock adds page to unlockedPages', () { + var pv = PageView(pages: [TestPage(), TestPage(), TestPage()]); + + pv.unlock(2); + + expect(pv.unlockedPages.contains(2), isTrue); + }); + + test('unlock ignores invalid indices', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + + pv.unlock(-1); + pv.unlock(10); + + expect(pv.unlockedPages, isEmpty); + }); + + test('unlockedPages returns unmodifiable set', () { + var pv = PageView(pages: [TestPage(), TestPage()]); + pv.unlock(1); + + expect(() => pv.unlockedPages.add(0), throwsUnsupportedError); + }); + }); + + group('Page access to PageView', () { + test('page can access pageView after attachment', () { + var page = TestPage(); + PageView(pages: [page]); + + expect(() => page.pageView, returnsNormally); + }); + + test('page throws when accessing pageView before attachment', () { + var page = TestPage(); + + expect(() => page.pageView, throwsStateError); + }); + + test('page can call goNext on pageView', () { + var page1 = TestPage(); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + + page1.pageView.goNext(); + + expect(pv.currentIndex, equals(1)); + }); + }); + + group('PageIndicator', () { + test('StepIndicator creates step display', () { + var indicator = StepIndicator(); + indicator.updateState(1, 3, {}); + + expect(indicator.current, equals(1)); + expect(indicator.total, equals(3)); + expect(indicator.text.isNotEmpty, isTrue); + }); + + test('DotIndicator creates dot display', () { + var indicator = DotIndicator(); + indicator.updateState(0, 4, {}); + + expect(indicator.text.isNotEmpty, isTrue); + }); + + test('TextIndicator uses default formatter', () { + var indicator = TextIndicator(); + indicator.updateState(1, 3, {}); + + expect(indicator.text.first.text, equals("Krok 2 z 3")); + }); + + test('TextIndicator uses custom formatter', () { + var indicator = TextIndicator( + formatter: (c, t) => "Step ${c + 1} of $t", + ); + indicator.updateState(0, 5, {}); + + expect(indicator.text.first.text, equals("Step 1 of 5")); + }); + + test('PageView updates indicator on navigation', () { + var indicator = StepIndicator(); + var pv = PageView( + pages: [TestPage(), TestPage(), TestPage()], + indicator: indicator, + ); + pv.start(); + + expect(indicator.current, equals(0)); + + pv.goNext(); + expect(indicator.current, equals(1)); + + pv.goNext(); + expect(indicator.current, equals(2)); + }); + }); + + group('Button widget', () { + test('button is focusable', () { + var button = Button('Test'); + expect(button.focusable, isTrue); + }); + + test('button calls onPressed on Enter', () { + var pressed = false; + var button = Button('Test', onPressed: () => pressed = true); + + button.onKey(KeyCode.ENTER); + + expect(pressed, isTrue); + }); + + test('button calls onPressed on Space', () { + var pressed = false; + var button = Button('Test', onPressed: () => pressed = true); + + button.onKey(' '); + + expect(pressed, isTrue); + }); + + test('button ignores other keys', () { + var pressed = false; + var button = Button('Test', onPressed: () => pressed = true); + + var handled = button.onKey('x'); + + expect(pressed, isFalse); + expect(handled, isFalse); + }); + + test('displayWidth includes brackets and spaces', () { + var button = Button('OK'); + expect(button.displayWidth, equals(6)); // "[ OK ]" + }); + }); + + group('NavigationBar widget', () { + test('creates next button', () { + var page = TestPage(); + PageView(pages: [page, TestPage()]); + + var nav = NavigationBar(page, prevLabel: null); + + expect(nav.children.length, equals(1)); + }); + + test('creates prev and next buttons', () { + var page = TestPage(); + PageView(pages: [page, TestPage()]); + + var nav = NavigationBar(page); + + expect(nav.children.length, equals(2)); + }); + + test('next button triggers goNext', () { + var page1 = TestPage(); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + + var nav = NavigationBar(page1); + var nextButton = nav.children.last as Button; + + nextButton.onPressed?.call(); + + expect(pv.currentIndex, equals(1)); + }); + + test('prev button triggers goPrev', () { + var page1 = TestPage(); + var page2 = TestPage(); + var pv = PageView(pages: [page1, page2]); + pv.start(); + pv.goNext(); + + var nav = NavigationBar(page2); + var prevButton = nav.children.first as Button; + + prevButton.onPressed?.call(); + + expect(pv.currentIndex, equals(0)); + }); + }); +} diff --git a/test/screen_test.dart b/test/screen_test.dart new file mode 100644 index 0000000..a71d78f --- /dev/null +++ b/test/screen_test.dart @@ -0,0 +1,217 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Screen', () { + late Screen screen; + + setUp(() { + screen = Screen(Size(10, 5)); + }); + + group('initialization', () { + test('has correct dimensions', () { + expect(screen.width, equals(10)); + expect(screen.height, equals(5)); + }); + + test('starts with empty buffer', () { + expect(screen.stringAt(0, 0), equals('')); + expect(screen.stringAt(5, 2), equals('')); + }); + + test('all cells are not occluded initially', () { + expect(screen.occluded(0, 0), isFalse); + expect(screen.occluded(9, 4), isFalse); + }); + }); + + group('write and read', () { + test('write stores character at position', () { + screen.write(3, 2, 'X'); + expect(screen.stringAt(3, 2), equals('X')); + }); + + test('written cell is occluded', () { + expect(screen.occluded(3, 2), isFalse); + screen.write(3, 2, 'X'); + expect(screen.occluded(3, 2), isTrue); + }); + + test('write overwrites previous value', () { + screen.write(0, 0, 'A'); + screen.write(0, 0, 'B'); + expect(screen.stringAt(0, 0), equals('B')); + }); + }); + + group('bounds checking', () { + test('occluded returns true for negative x', () { + expect(screen.occluded(-1, 0), isTrue); + }); + + test('occluded returns true for negative y', () { + expect(screen.occluded(0, -1), isTrue); + }); + + test('occluded returns true for x >= width', () { + expect(screen.occluded(10, 0), isTrue); + expect(screen.occluded(100, 0), isTrue); + }); + + test('occluded returns true for y >= height', () { + expect(screen.occluded(0, 5), isTrue); + expect(screen.occluded(0, 100), isTrue); + }); + + test('write ignores out of bounds negative x', () { + screen.write(-1, 0, 'X'); + // Should not throw, just ignore + expect(screen.stringAt(0, 0), equals('')); + }); + + test('write ignores out of bounds negative y', () { + screen.write(0, -1, 'X'); + expect(screen.stringAt(0, 0), equals('')); + }); + + test('write ignores out of bounds x >= width', () { + screen.write(10, 0, 'X'); + expect(screen.stringAt(9, 0), equals('')); + }); + + test('write ignores out of bounds y >= height', () { + screen.write(0, 5, 'X'); + expect(screen.stringAt(0, 4), equals('')); + }); + + test('stringAt returns empty for out of bounds', () { + expect(screen.stringAt(-1, 0), equals('')); + expect(screen.stringAt(0, -1), equals('')); + expect(screen.stringAt(10, 0), equals('')); + expect(screen.stringAt(0, 5), equals('')); + }); + }); + + group('resize', () { + test('resize changes dimensions', () { + screen.resize(Size(20, 10)); + expect(screen.width, equals(20)); + expect(screen.height, equals(10)); + }); + + test('resize clears buffer', () { + screen.write(0, 0, 'X'); + screen.resize(Size(20, 10)); + expect(screen.stringAt(0, 0), equals('')); + }); + }); + + group('clear', () { + test('clear resets all cells', () { + screen.write(0, 0, 'A'); + screen.write(5, 2, 'B'); + screen.clear(); + expect(screen.stringAt(0, 0), equals('')); + expect(screen.stringAt(5, 2), equals('')); + expect(screen.occluded(0, 0), isFalse); + }); + }); + + group('toString', () { + test('renders empty screen as spaces', () { + var small = Screen(Size(3, 2)); + expect(small.toString(), equals(' \n ')); + }); + + test('renders written characters', () { + var small = Screen(Size(3, 2)); + small.write(0, 0, 'A'); + small.write(2, 1, 'B'); + expect(small.toString(), equals('A \n B')); + }); + }); + + group('canvas', () { + test('creates canvas with screen size by default', () { + var canvas = screen.canvas(); + expect(canvas.width, equals(10)); + expect(canvas.height, equals(5)); + expect(canvas.x, equals(0)); + expect(canvas.y, equals(0)); + }); + + test('creates canvas with custom size and offset', () { + var canvas = screen.canvas(Size(5, 3), Position(2, 1)); + expect(canvas.width, equals(5)); + expect(canvas.height, equals(3)); + expect(canvas.x, equals(2)); + expect(canvas.y, equals(1)); + }); + }); + + group('double-buffering', () { + test('hasWrites is false on fresh screen', () { + expect(screen.hasWrites, isFalse); + }); + + test('hasWrites is true after write', () { + screen.write(0, 0, 'A'); + expect(screen.hasWrites, isTrue); + }); + + test('hasWrites resets after clear', () { + screen.write(0, 0, 'A'); + screen.clear(); + expect(screen.hasWrites, isFalse); + }); + + test('diff returns empty when buffers match', () { + // Both buffers start empty — diff should be empty. + expect(screen.diff(), equals('')); + }); + + test('diff returns ANSI-positioned lines for changed rows', () { + screen.write(0, 0, 'A'); + final patch = screen.diff(); + // Line 1 changed, expect ANSI cursor move + rendered line. + expect(patch, contains('\x1B[1;1H')); + expect(patch, contains('A')); + }); + + test('diff skips unchanged lines', () { + // Write only to row 2 (index 1). + screen.write(0, 1, 'B'); + final patch = screen.diff(); + // Should contain row 2 cursor move, not row 1. + expect(patch, contains('\x1B[2;1H')); + expect(patch, isNot(contains('\x1B[1;1H'))); + }); + + test('swapBuffers makes diff return empty', () { + screen.write(0, 0, 'X'); + screen.swapBuffers(); + // After swap, front == back, diff should be empty. + expect(screen.diff(), equals('')); + }); + + test('clear + write + diff + swap cycle works', () { + // Frame 1: write A at (0,0). + screen.write(0, 0, 'A'); + screen.swapBuffers(); + + // Frame 2: clear, write B at (0,0). + screen.clear(); + screen.write(0, 0, 'B'); + final patch = screen.diff(); + expect(patch, contains('B')); + screen.swapBuffers(); + + // Frame 3: same content — no diff. + screen.clear(); + screen.write(0, 0, 'B'); + expect(screen.diff(), equals('')); + }); + }); + }); +} diff --git a/test/textarea_test.dart b/test/textarea_test.dart new file mode 100644 index 0000000..3e92d48 --- /dev/null +++ b/test/textarea_test.dart @@ -0,0 +1,85 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('TextArea', () { + late TextArea ta; + late List changes; + + setUp(() { + ta = TextArea(); + ta.focused = true; + ta.resize(Size(40, 10), Position(0, 0)); + changes = []; + ta.onChange = (v) => changes.add(v); + }); + + group('onPaste', () { + test('inserts single line at cursor', () { + ta.onPaste('hello'); + expect(ta.value, equals('hello')); + expect(ta.cursorX, equals(5)); + expect(ta.cursorY, equals(0)); + }); + + test('inserts multi-line paste', () { + ta.onPaste('line1\nline2\nline3'); + expect(ta.value, equals('line1\nline2\nline3')); + expect(ta.cursorY, equals(2)); + expect(ta.cursorX, equals(5)); + }); + + test('inserts at cursor in middle of existing text', () { + ta.value = 'hello world'; + ta.cursorX = 5; + ta.cursorY = 0; + ta.onPaste(' beautiful'); + expect(ta.value, equals('hello beautiful world')); + }); + + test('multi-line paste splits existing line', () { + ta.value = 'AABB'; + ta.cursorX = 2; + ta.cursorY = 0; + ta.onPaste('X\nY'); + expect(ta.value, equals('AAX\nYBB')); + expect(ta.cursorY, equals(1)); + expect(ta.cursorX, equals(1)); + }); + + test('respects maxLines', () { + ta = TextArea(maxLines: 3); + ta.focused = true; + ta.resize(Size(40, 10), Position(0, 0)); + ta.onPaste('1\n2\n3\n4\n5'); + expect(ta.value.split('\n').length, lessThanOrEqualTo(3)); + }); + + test('fires onChange', () { + ta.onPaste('text'); + expect(changes, isNotEmpty); + }); + + test('returns true', () { + expect(ta.onPaste('x'), isTrue); + }); + + test('handles carriage returns', () { + ta.onPaste('win\r\nlines'); + expect(ta.value, equals('win\nlines')); + }); + }); + + group('onKey printable char', () { + test('accepts regular ASCII', () { + ta.onKey('a'); + expect(ta.value, equals('a')); + }); + + test('rejects control characters', () { + ta.onKey('\x01'); + expect(ta.value, equals('')); + }); + }); + }); +} diff --git a/test/tree_test.dart b/test/tree_test.dart new file mode 100644 index 0000000..7b24fa4 --- /dev/null +++ b/test/tree_test.dart @@ -0,0 +1,193 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +class TestNode extends TreeNode { + String name; + TestNode(this.name); +} + +class TestTreeView extends TreeView { + TestTreeView(super.model); + + @override + String renderNode(TreeNode node) { + return ' ' * node.depth + (node as TestNode).name; + } +} + +void main() { + group('TreeNode', () { + test('depth is 0 for root node', () { + var node = TestNode('root'); + expect(node.depth, equals(0)); + }); + + test('depth increases with nesting', () { + var root = TestNode('root'); + var child = TestNode('child'); + var grandchild = TestNode('grandchild'); + + root.add(child); + child.add(grandchild); + + expect(root.depth, equals(0)); + expect(child.depth, equals(1)); + expect(grandchild.depth, equals(2)); + }); + + test('add returns the added node', () { + var parent = TestNode('parent'); + var child = TestNode('child'); + + var result = parent.add(child); + expect(result, same(child)); + }); + + test('leaf defaults to false', () { + var node = TestNode('test'); + expect(node.leaf, isFalse); + }); + + test('opened defaults to false', () { + var node = TestNode('test'); + expect(node.opened, isFalse); + }); + }); + + group('TreeModel', () { + late TreeModel model; + + setUp(() { + model = TreeModel(); + var child1 = TestNode('child1'); + var child2 = TestNode('child2'); + var grandchild = TestNode('grandchild'); + + model.root.add(child1); + model.root.add(child2); + child1.add(grandchild); + }); + + test('iterates only visible nodes (closed)', () { + var names = model.map((n) => (n as TestNode).name).toList(); + expect(names, equals(['child1', 'child2'])); + }); + + test('iterates including children when opened', () { + var child1 = model.first as TestNode; + child1.opened = true; + + var names = model.map((n) => (n as TestNode).name).toList(); + expect(names, equals(['child1', 'grandchild', 'child2'])); + }); + + test('indexOf finds correct position', () { + var nodes = model.toList(); + expect(model.indexOf(nodes[0]), equals(0)); + expect(model.indexOf(nodes[1]), equals(1)); + }); + + test('indexOf returns null for non-existent node', () { + var orphan = TestNode('orphan'); + expect(model.indexOf(orphan), isNull); + }); + }); + + group('TreeView', () { + late TreeModel model; + late TestTreeView treeView; + + setUp(() { + model = TreeModel(); + for (var i = 0; i < 10; i++) { + model.root.add(TestNode('item$i')); + } + treeView = TestTreeView(model); + treeView.resize(Size(40, 5), Position(0, 0)); + }); + + test('is focusable by default', () { + expect(treeView.focusable, isTrue); + }); + + test('cursor starts at 0', () { + expect(treeView.cursor, equals(0)); + }); + + test('scrollOffset starts at 0', () { + expect(treeView.scrollOffset, equals(0)); + }); + + test('moveDown increments cursor', () { + treeView.moveDown(); + expect(treeView.cursor, equals(1)); + }); + + test('moveUp decrements cursor', () { + treeView.cursor = 3; + treeView.moveUp(); + expect(treeView.cursor, equals(2)); + }); + + test('moveUp does not go below 0', () { + treeView.cursor = 0; + treeView.moveUp(); + expect(treeView.cursor, equals(0)); + }); + + test('moveDown does not exceed model length', () { + treeView.cursor = 9; + treeView.moveDown(); + expect(treeView.cursor, equals(9)); + }); + + test('scrollOffset adjusts when cursor goes below visible area', () { + // Height is 5, so visible items are 0-4 + for (var i = 0; i < 6; i++) { + treeView.moveDown(); + } + expect(treeView.cursor, equals(6)); + expect(treeView.scrollOffset, equals(2)); // 6-5+1 = 2 + }); + + test('scrollOffset adjusts when cursor goes above visible area', () { + treeView.scrollOffset = 3; + treeView.cursor = 3; + treeView.moveUp(); + expect(treeView.cursor, equals(2)); + expect(treeView.scrollOffset, equals(2)); + }); + + test('onKey handles arrow keys', () { + expect(treeView.onKey(KeyCode.DOWN), isTrue); + expect(treeView.cursor, equals(1)); + + expect(treeView.onKey(KeyCode.UP), isTrue); + expect(treeView.cursor, equals(0)); + }); + + test('onKey returns false for unhandled keys', () { + expect(treeView.onKey('x'), isFalse); + }); + + test('update generates text for visible rows', () { + treeView.update(); + expect(treeView.text.length, equals(5)); // height is 5 + }); + + test('expandNode opens non-leaf nodes', () { + var child = TestNode('child'); + var grandchild = TestNode('grandchild'); + child.add(grandchild); + + model = TreeModel(); + model.root.add(child); + treeView = TestTreeView(model); + treeView.resize(Size(40, 10), Position(0, 0)); + + expect(child.opened, isFalse); + treeView.expandNode(); + expect(child.opened, isTrue); + }); + }); +} diff --git a/test/view_test.dart b/test/view_test.dart new file mode 100644 index 0000000..8eb4ead --- /dev/null +++ b/test/view_test.dart @@ -0,0 +1,422 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +/// Simple concrete View for testing +class TestView extends View { + bool updateCalled = false; + + @override + void update() { + updateCalled = true; + } +} + +void main() { + group('View', () { + group('resize', () { + test('sets size and position', () { + var view = TestView(); + view.resize(Size(100, 50), Position(10, 5)); + + expect(view.width, equals(100)); + expect(view.height, equals(50)); + expect(view.x, equals(10)); + expect(view.y, equals(5)); + }); + + test('calls update after resize', () { + var view = TestView(); + expect(view.updateCalled, isFalse); + view.resize(Size(100, 50), Position(0, 0)); + expect(view.updateCalled, isTrue); + }); + + test('resizes children with parent size', () { + var parent = TestView(); + var child = TestView(); + parent.children = [child]; + + parent.resize(Size(80, 40), Position(0, 0)); + + // Default resize_children passes parent size to children + expect(child.width, equals(80)); + expect(child.height, equals(40)); + }); + }); + + group('children', () { + test('hasChildren returns false when empty', () { + var view = TestView(); + expect(view.hasChildren, isFalse); + }); + + test('hasChildren returns true with children', () { + var view = TestView(); + view.children = [TestView()]; + expect(view.hasChildren, isTrue); + }); + }); + + group('parent reference', () { + test('parent is null by default', () { + var view = TestView(); + expect(view.parent, isNull); + }); + + test('resize sets parent on children', () { + var parent = TestView(); + var child1 = TestView(); + var child2 = TestView(); + parent.children = [child1, child2]; + + parent.resize(Size(80, 40), Position(0, 0)); + + expect(child1.parent, same(parent)); + expect(child2.parent, same(parent)); + }); + + test('resize updates parent when children change', () { + var parent1 = TestView(); + var parent2 = TestView(); + var child = TestView(); + + parent1.children = [child]; + parent1.resize(Size(80, 40), Position(0, 0)); + expect(child.parent, same(parent1)); + + parent2.children = [child]; + parent2.resize(Size(80, 40), Position(0, 0)); + expect(child.parent, same(parent2)); + }); + + test('nested resize sets parent chain', () { + var grandparent = TestView(); + var parent = TestView(); + var child = TestView(); + + parent.children = [child]; + grandparent.children = [parent]; + + grandparent.resize(Size(80, 40), Position(0, 0)); + + expect(parent.parent, same(grandparent)); + expect(child.parent, same(parent)); + }); + }); + }); + + group('SplitView', () { + group('horizontal split', () { + test('divides width equally by default', () { + var split = SplitView(horizontal: true); + var child1 = TestView(); + var child2 = TestView(); + split.children = [child1, child2]; + + split.resize(Size(100, 50), Position(0, 0)); + + expect(child1.width, equals(50)); + expect(child2.width, equals(50)); + expect(child1.height, equals(50)); + expect(child2.height, equals(50)); + }); + + test('positions children side by side', () { + var split = SplitView(horizontal: true); + var child1 = TestView(); + var child2 = TestView(); + split.children = [child1, child2]; + + split.resize(Size(100, 50), Position(0, 0)); + + expect(child1.x, equals(0)); + expect(child2.x, equals(50)); + expect(child1.y, equals(0)); + expect(child2.y, equals(0)); + }); + + test('respects custom ratios', () { + var split = SplitView(horizontal: true, ratios: [1, 2, 1]); + var child1 = TestView(); + var child2 = TestView(); + var child3 = TestView(); + split.children = [child1, child2, child3]; + + split.resize(Size(100, 50), Position(0, 0)); + + // 1:2:1 ratio of 100 = 25:50:25 + expect(child1.width, equals(25)); + expect(child2.width, equals(50)); + expect(child3.width, equals(25)); + + expect(child1.x, equals(0)); + expect(child2.x, equals(25)); + expect(child3.x, equals(75)); + }); + + test('handles three equal children', () { + var split = SplitView(horizontal: true); + var children = [TestView(), TestView(), TestView()]; + split.children = children; + + split.resize(Size(90, 30), Position(0, 0)); + + expect(children[0].width, equals(30)); + expect(children[1].width, equals(30)); + expect(children[2].width, equals(30)); + + expect(children[0].x, equals(0)); + expect(children[1].x, equals(30)); + expect(children[2].x, equals(60)); + }); + }); + + group('vertical split', () { + test('divides height equally by default', () { + var split = SplitView(horizontal: false); + var child1 = TestView(); + var child2 = TestView(); + split.children = [child1, child2]; + + split.resize(Size(100, 50), Position(0, 0)); + + expect(child1.height, equals(25)); + expect(child2.height, equals(25)); + expect(child1.width, equals(100)); + expect(child2.width, equals(100)); + }); + + test('positions children stacked vertically', () { + var split = SplitView(horizontal: false); + var child1 = TestView(); + var child2 = TestView(); + split.children = [child1, child2]; + + split.resize(Size(100, 50), Position(0, 0)); + + expect(child1.x, equals(0)); + expect(child2.x, equals(0)); + expect(child1.y, equals(0)); + expect(child2.y, equals(25)); + }); + + test('respects custom ratios vertically', () { + var split = SplitView(horizontal: false, ratios: [1, 3]); + var child1 = TestView(); + var child2 = TestView(); + split.children = [child1, child2]; + + split.resize(Size(100, 80), Position(0, 0)); + + // 1:3 ratio of 80 = 20:60 + expect(child1.height, equals(20)); + expect(child2.height, equals(60)); + + expect(child1.y, equals(0)); + expect(child2.y, equals(20)); + }); + }); + + group('edge cases', () { + test('handles empty children', () { + var split = SplitView(horizontal: true); + // Should not throw + split.resize(Size(100, 50), Position(0, 0)); + }); + + test('handles single child', () { + var split = SplitView(horizontal: true); + var child = TestView(); + split.children = [child]; + + split.resize(Size(100, 50), Position(0, 0)); + + expect(child.width, equals(100)); + expect(child.height, equals(50)); + expect(child.x, equals(0)); + }); + + test('handles more children than ratios', () { + var split = SplitView(horizontal: true, ratios: [2, 1]); + var children = [TestView(), TestView(), TestView()]; + split.children = children; + + split.resize(Size(100, 50), Position(0, 0)); + + // First two use ratios 2:1, third defaults to 1 + // Total ratio: 2+1+1 = 4, so 50:25:25 + expect(children[0].width, equals(50)); + expect(children[1].width, equals(25)); + expect(children[2].width, equals(25)); + }); + }); + }); + + group('ProgressBar', () { + group('value clamping', () { + test('clamps value to 0-1 range', () { + var bar = ProgressBar(); + + bar.value = 0.5; + expect(bar.value, equals(0.5)); + + bar.value = 1.5; + expect(bar.value, equals(1.0)); + + bar.value = -0.5; + expect(bar.value, equals(0.0)); + }); + + test('accepts boundary values', () { + var bar = ProgressBar(); + + bar.value = 0.0; + expect(bar.value, equals(0.0)); + + bar.value = 1.0; + expect(bar.value, equals(1.0)); + }); + }); + + group('properties', () { + test('has default properties', () { + var bar = ProgressBar(); + expect(bar.showPercent, isTrue); + expect(bar.color, equals('2')); + expect(bar.filledChar, equals('█')); + expect(bar.emptyChar, equals('░')); + }); + + test('allows customization', () { + var bar = ProgressBar() + ..showPercent = false + ..color = '1' + ..label = 'Test'; + + expect(bar.showPercent, isFalse); + expect(bar.color, equals('1')); + expect(bar.label, equals('Test')); + }); + }); + }); + + group('Box', () { + test('reduces child size by 4 in each dimension', () { + var box = Box('*', '1'); + var child = TestView(); + box.children = [child]; + + box.resize(Size(20, 10), Position(0, 0)); + + expect(child.width, equals(16)); // 20 - 4 + expect(child.height, equals(6)); // 10 - 4 + }); + + test('positions child at offset 2,2', () { + var box = Box('*', '1'); + var child = TestView(); + box.children = [child]; + + box.resize(Size(20, 10), Position(0, 0)); + + expect(child.x, equals(2)); + expect(child.y, equals(2)); + }); + }); + + group('CenteredText', () { + test('creates text at centered position', () { + var centered = CenteredText('Hello'); + centered.resize(Size(20, 10), Position(0, 0)); + + expect(centered.text.length, equals(1)); + // "Hello" is 5 chars, width is 20 + // x = (20/2) - (5/2) = 10 - 2.5 = 7 + expect(centered.text[0].x, equals(7)); + // y = (10/2) - 1 = 4 + expect(centered.text[0].y, equals(4)); + }); + }); + + group('Frame', () { + test('draws border without title', () { + var frame = Frame(); + frame.resize(Size(10, 5), Position(0, 0)); + + // Should have: top border, side borders (3 rows x 2), bottom border + expect(frame.text.isNotEmpty, isTrue); + + // First text should be top border starting with ┌ + expect(frame.text[0].text!.startsWith('┌'), isTrue); + expect(frame.text[0].text!.endsWith('┐'), isTrue); + }); + + test('draws border with title', () { + var frame = Frame(title: 'Test'); + frame.resize(Size(20, 5), Position(0, 0)); + + // Top border should contain title + expect(frame.text[0].text!.contains('Test'), isTrue); + expect(frame.text[0].text!.startsWith('┌─ Test'), isTrue); + }); + + test('positions children inside frame with padding', () { + var frame = Frame(padding: 1); + var child = TestView(); + frame.children = [child]; + + frame.resize(Size(20, 10), Position(0, 0)); + + // Child should be inside frame: 1 (border) + 1 (padding) = 2 + expect(child.x, equals(2)); + expect(child.y, equals(2)); + // Size: 20 - 2 (borders) - 2 (padding) = 16 + expect(child.width, equals(16)); + expect(child.height, equals(6)); // 10 - 2 - 2 + }); + + test('positions children with zero padding', () { + var frame = Frame(padding: 0); + var child = TestView(); + frame.children = [child]; + + frame.resize(Size(20, 10), Position(0, 0)); + + // Child should be just inside border + expect(child.x, equals(1)); + expect(child.y, equals(1)); + expect(child.width, equals(18)); // 20 - 2 + expect(child.height, equals(8)); // 10 - 2 + }); + + test('uses custom color', () { + var frame = Frame(color: '31'); + frame.resize(Size(10, 5), Position(0, 0)); + + expect(frame.text[0].color, equals('31')); + }); + + test('handles small size gracefully', () { + var frame = Frame(title: 'Long Title'); + frame.resize(Size(3, 2), Position(0, 0)); + + // Should not crash, text may be empty for very small sizes + expect(frame.text, isEmpty); + }); + + test('resizes multiple children to same area', () { + var frame = Frame(padding: 0); + var child1 = TestView(); + var child2 = TestView(); + frame.children = [child1, child2]; + + frame.resize(Size(20, 10), Position(0, 0)); + + // Both children get same size and position (stacked) + expect(child1.width, equals(child2.width)); + expect(child1.height, equals(child2.height)); + expect(child1.x, equals(child2.x)); + expect(child1.y, equals(child2.y)); + }); + }); +} diff --git a/test/widgets_test.dart b/test/widgets_test.dart new file mode 100644 index 0000000..281da41 --- /dev/null +++ b/test/widgets_test.dart @@ -0,0 +1,414 @@ +import 'package:test/test.dart'; +import 'package:tui/tui.dart'; + +void main() { + group('Input', () { + late Input input; + + setUp(() { + input = Input(placeholder: 'Enter text...'); + input.size = Size(40, 1); + }); + + test('starts empty with cursor at 0', () { + expect(input.value, isEmpty); + expect(input.cursorPosition, equals(0)); + }); + + test('is focusable by default', () { + expect(input.focusable, isTrue); + }); + + test('handles character input', () { + input.onKey('h'); + input.onKey('i'); + expect(input.value, equals('hi')); + expect(input.cursorPosition, equals(2)); + }); + + test('handles backspace', () { + input.value = 'hello'; + input.cursorPosition = 5; + input.onKey(KeyCode.BACKSPACE); + expect(input.value, equals('hell')); + expect(input.cursorPosition, equals(4)); + }); + + test('handles delete', () { + input.value = 'hello'; + input.cursorPosition = 0; + input.onKey(KeyCode.DEL); + expect(input.value, equals('ello')); + expect(input.cursorPosition, equals(0)); + }); + + test('handles left arrow', () { + input.value = 'hello'; + input.cursorPosition = 3; + input.onKey(KeyCode.LEFT); + expect(input.cursorPosition, equals(2)); + }); + + test('handles right arrow', () { + input.value = 'hello'; + input.cursorPosition = 2; + input.onKey(KeyCode.RIGHT); + expect(input.cursorPosition, equals(3)); + }); + + test('handles home key', () { + input.value = 'hello'; + input.cursorPosition = 3; + input.onKey(KeyCode.HOME); + expect(input.cursorPosition, equals(0)); + }); + + test('handles end key', () { + input.value = 'hello'; + input.cursorPosition = 0; + input.onKey(KeyCode.END); + expect(input.cursorPosition, equals(5)); + }); + + test('respects maxLength', () { + input.maxLength = 5; + input.value = ''; + for (var c in 'hello world'.split('')) { + input.onKey(c); + } + expect(input.value, equals('hello')); + }); + + test('calls onSubmit on enter', () { + String? submitted; + input.onSubmit = (v) => submitted = v; + input.value = 'test'; + input.onKey(KeyCode.ENTER); + expect(submitted, equals('test')); + }); + + test('calls onChange on input', () { + String? changed; + input.onChange = (v) => changed = v; + input.onKey('a'); + expect(changed, equals('a')); + }); + }); + + group('Select', () { + late Select select; + + setUp(() { + select = Select( + options: ['Apple', 'Banana', 'Cherry'], + ); + select.size = Size(30, 10); + }); + + test('starts with first item selected', () { + expect(select.selectedIndex, equals(0)); + }); + + test('is focusable by default', () { + expect(select.focusable, isTrue); + }); + + test('moves down with down arrow', () { + select.onKey(KeyCode.DOWN); + expect(select.selectedIndex, equals(1)); + }); + + test('moves up with up arrow', () { + select.selectedIndex = 2; + select.onKey(KeyCode.UP); + expect(select.selectedIndex, equals(1)); + }); + + test('does not go below 0', () { + select.selectedIndex = 0; + select.onKey(KeyCode.UP); + expect(select.selectedIndex, equals(0)); + }); + + test('does not exceed last index', () { + select.selectedIndex = 2; + select.onKey(KeyCode.DOWN); + expect(select.selectedIndex, equals(2)); + }); + + test('home goes to first', () { + select.selectedIndex = 2; + select.onKey(KeyCode.HOME); + expect(select.selectedIndex, equals(0)); + }); + + test('end goes to last', () { + select.selectedIndex = 0; + select.onKey(KeyCode.END); + expect(select.selectedIndex, equals(2)); + }); + + test('calls onSelect on enter', () { + String? selected; + select.onSelect = (v) => selected = v; + select.selectedIndex = 1; + select.onKey(KeyCode.ENTER); + expect(selected, equals('Banana')); + }); + + test('multiSelect toggles with space', () { + select.multiSelect = true; + select.onKey(KeyCode.SPACE); + expect(select.selectedIndices.contains(0), isTrue); + + select.onKey(KeyCode.SPACE); + expect(select.selectedIndices.contains(0), isFalse); + }); + + test('multiSelect respects limit', () { + select.multiSelect = true; + select.limit = 2; + + select.onKey(KeyCode.SPACE); // Select 0 + select.onKey(KeyCode.DOWN); + select.onKey(KeyCode.SPACE); // Select 1 + select.onKey(KeyCode.DOWN); + select.onKey(KeyCode.SPACE); // Try select 2 (should fail) + + expect(select.selectedIndices.length, equals(2)); + expect(select.selectedIndices.contains(2), isFalse); + }); + }); + + group('Confirm', () { + late Confirm confirm; + + setUp(() { + confirm = Confirm(message: 'Are you sure?'); + confirm.size = Size(40, 2); + }); + + test('starts with Yes selected', () { + expect(confirm.selected, isTrue); + }); + + test('is focusable by default', () { + expect(confirm.focusable, isTrue); + }); + + test('left arrow selects Yes', () { + confirm.selected = false; + confirm.onKey(KeyCode.LEFT); + expect(confirm.selected, isTrue); + }); + + test('right arrow selects No', () { + confirm.selected = true; + confirm.onKey(KeyCode.RIGHT); + expect(confirm.selected, isFalse); + }); + + test('y selects Yes', () { + confirm.selected = false; + confirm.onKey('y'); + expect(confirm.selected, isTrue); + }); + + test('n selects No', () { + confirm.selected = true; + confirm.onKey('n'); + expect(confirm.selected, isFalse); + }); + + test('tab toggles selection', () { + confirm.selected = true; + confirm.onKey(KeyCode.TAB); + expect(confirm.selected, isFalse); + confirm.onKey(KeyCode.TAB); + expect(confirm.selected, isTrue); + }); + + test('calls onConfirm with result on enter', () { + bool? result; + confirm.onConfirm = (r) => result = r; + confirm.selected = false; + confirm.onKey(KeyCode.ENTER); + expect(result, isFalse); + }); + }); + + group('TextArea', () { + late TextArea textarea; + + setUp(() { + textarea = TextArea(); + textarea.size = Size(40, 10); + }); + + test('starts with empty line', () { + expect(textarea.value, isEmpty); + expect(textarea.cursorX, equals(0)); + expect(textarea.cursorY, equals(0)); + }); + + test('is focusable by default', () { + expect(textarea.focusable, isTrue); + }); + + test('handles character input', () { + textarea.onKey('h'); + textarea.onKey('i'); + expect(textarea.value, equals('hi')); + }); + + test('handles enter to create new line', () { + textarea.value = 'hello'; + textarea.cursorX = 5; + textarea.onKey(KeyCode.ENTER); + expect(textarea.value, equals('hello\n')); + expect(textarea.cursorY, equals(1)); + expect(textarea.cursorX, equals(0)); + }); + + test('handles enter in middle of line', () { + textarea.value = 'hello world'; + textarea.cursorX = 5; + textarea.cursorY = 0; + textarea.onKey(KeyCode.ENTER); + expect(textarea.value, equals('hello\n world')); + }); + + test('handles up/down navigation', () { + textarea.value = 'line1\nline2\nline3'; + textarea.cursorY = 1; + textarea.cursorX = 2; + + textarea.onKey(KeyCode.UP); + expect(textarea.cursorY, equals(0)); + + textarea.onKey(KeyCode.DOWN); + expect(textarea.cursorY, equals(1)); + }); + + test('handles backspace at line start (merge lines)', () { + textarea.value = 'hello\nworld'; + textarea.cursorY = 1; + textarea.cursorX = 0; + textarea.onKey(KeyCode.BACKSPACE); + expect(textarea.value, equals('helloworld')); + expect(textarea.cursorY, equals(0)); + expect(textarea.cursorX, equals(5)); + }); + + test('respects maxLines', () { + textarea.maxLines = 2; + textarea.value = 'line1'; + textarea.cursorX = 5; + textarea.onKey(KeyCode.ENTER); + textarea.onKey(KeyCode.ENTER); // Should not add third line + expect(textarea.value.split('\n').length, equals(2)); + }); + }); + + group('Spinner', () { + late Spinner spinner; + + setUp(() { + spinner = Spinner(label: 'Loading...'); + spinner.size = Size(20, 1); + }); + + test('starts stopped', () { + expect(spinner.running, isFalse); + }); + + test('start sets running to true', () { + spinner.start(); + expect(spinner.running, isTrue); + spinner.stop(); + }); + + test('stop sets running to false', () { + spinner.start(); + spinner.stop(); + expect(spinner.running, isFalse); + }); + + test('has multiple styles available', () { + expect(SpinnerStyle.values.length, greaterThan(5)); + }); + }); + + group('Table', () { + late Table> table; + + setUp(() { + table = Table( + columns: [ + TableColumn('Name', width: 10), + TableColumn('Age', width: 5), + ], + rows: [ + ['Alice', '30'], + ['Bob', '25'], + ['Charlie', '35'], + ], + rowBuilder: (row) => row, + ); + table.size = Size(30, 10); + }); + + test('starts with first row selected', () { + expect(table.selectedRow, equals(0)); + }); + + test('is focusable when selectable', () { + expect(table.focusable, isTrue); + }); + + test('is not focusable when created with selectable=false', () { + var nonSelectableTable = Table( + columns: [TableColumn('Name', width: 10)], + rows: [['Alice']], + rowBuilder: (row) => row, + selectable: false, + ); + expect(nonSelectableTable.focusable, isFalse); + }); + + test('moves down with down arrow', () { + table.onKey(KeyCode.DOWN); + expect(table.selectedRow, equals(1)); + }); + + test('moves up with up arrow', () { + table.selectedRow = 2; + table.onKey(KeyCode.UP); + expect(table.selectedRow, equals(1)); + }); + + test('home goes to first row', () { + table.selectedRow = 2; + table.onKey(KeyCode.HOME); + expect(table.selectedRow, equals(0)); + }); + + test('end goes to last row', () { + table.selectedRow = 0; + table.onKey(KeyCode.END); + expect(table.selectedRow, equals(2)); + }); + + test('calls onSelect on enter', () { + List? selected; + table.onSelect = (row) => selected = row; + table.selectedRow = 1; + table.onKey(KeyCode.ENTER); + expect(selected, equals(['Bob', '25'])); + }); + + test('has multiple border styles', () { + expect(TableBorder.values.length, greaterThan(3)); + }); + }); +}