From 785a21cae686fa6c3f9532498d5d9f473aa88adc Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Sat, 25 Jul 2026 21:15:20 -0300 Subject: [PATCH 01/26] fix(libghostty): isolate git when compiling ghostty from source CompileFromSource extracts the ghostty source under the consuming app's directory (.dart_tool/hooks_runner/.../ghostty-source-/), which is frequently inside the app's own git repository. Ghostty's build (Config.zig) shells out to git to derive its version, and because the extracted source has no .git of its own, that discovery walks up into the app's repo and reads the app's tag. When the app is checked out on a tag that isn't vX.Y.Z -- e.g. a CI release build tagged `myapp-v1.2.3` -- Config.zig panics: thread panic: tagged releases must be in vX.Y.Z format matching build.zig This breaks every tagged release build for any consumer whose tag scheme differs from ghostty's, on all four desktop targets. It only escapes local dev because an untagged HEAD falls into the branch/short-hash path. Fence git at the extracted source's parent via GIT_CEILING_DIRECTORIES (must be absolute) so the upward walk stops before reaching the app repo. Ghostty then finds no repository and cleanly falls back to a dev version through the existing Config.zig error.GitNotRepository path -- not a hack, the intended fallback. Verified end-to-end: with a `cockpit-v1.15.0` tag on the consumer repo (which otherwise reproduces the panic), a cold `zig build` completes and emits a dylib exporting the expected symbols. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/src/hook/library_provider.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index 1101189a..393323b2 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -116,12 +116,16 @@ final class CompileFromSource extends LibraryProvider { 'zig', zigArgs, workingDirectory: sourceDir.path, - environment: zigCacheDir == null - ? null - : { - 'ZIG_GLOBAL_CACHE_DIR': zigCacheDir, - 'ZIG_LOCAL_CACHE_DIR': zigCacheDir, - }, + environment: { + // Keep Ghostty's version detection from walking into the consuming + // repository. The absolute parent ceiling still allows a Ghostty + // checkout to use its own `.git`. + 'GIT_CEILING_DIRECTORIES': sourceDir.parent.absolute.path, + if (zigCacheDir != null) ...{ + 'ZIG_GLOBAL_CACHE_DIR': zigCacheDir, + 'ZIG_LOCAL_CACHE_DIR': zigCacheDir, + }, + }, ); if (result.exitCode != 0) { From c42444ae05546cca507cf16ed8ab56d54568e6a1 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Sun, 26 Jul 2026 16:35:37 +0300 Subject: [PATCH 02/26] docs: add contributing guide --- CONTRIBUTING.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d19ba4eb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing to libghostty + +Contributions are welcome, and thank you for taking an interest in libghostty. +I want the project to be easy to improve without making it harder to +understand or maintain. + +If you find a problem but do not understand how to fix it safely, please open +a clear issue instead of a pull request. Describe the problem, expected +behavior, affected packages and platforms, and anything else needed to +understand it. A well-written issue is a valuable contribution on its own. + +## Pull requests + +- **Scope.** Group each pull request around a clear purpose. Refactoring, + formatting, and other supporting work can be included when they belong to + the same logical change. Separate unrelated work so the pull request remains + easy to understand and review. Keep package boundaries intact. `libghostty` + must remain framework-agnostic. + +- **AI use.** While I am fine with the use of AI and coding agents, I will + not accept output you have not carefully reviewed, understood, and + verified. You must be able to explain the resulting code, the decisions + behind it, and its effect on supported platforms. Pull requests that do + not meet this bar will be closed. + +- **Testing.** Every behavior change must have appropriate test coverage. + Fixes should include a test that would fail without the fix. New behavior + should cover its expected use, important edge cases, and failures. Run + formatting, analysis, and the affected package tests before submitting. + +- **Description.** Keep the pull request description concise and specific. + Clearly describe the problem, the solution, and how it was verified. Do + not submit an unfiltered summary generated by AI or include detail that + does not help reviewers understand the change. + +Keep the commit history logical and free of temporary or cleanup commits. From 85f814b31810b638732e848c9a298d0a564847e0 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Tue, 21 Jul 2026 20:04:58 +0300 Subject: [PATCH 03/26] perf(flterm): add terminal workload benchmarks --- .github/workflows/benchmark-flterm.yml | 67 ++++ packages/flterm/.pubignore | 2 + .../integration_test/flterm_benchmark.dart | 10 + packages/flterm/example/pubspec.yaml | 2 + .../test_driver/flterm_benchmark_driver.dart | 12 + packages/flterm/pubspec.yaml | 3 + .../test/tool/benchmarks/commands_test.dart | 115 +++++++ .../benchmarks/fixture/terminal_test.dart | 122 +++++++ .../frame/render_environment_test.dart | 37 ++ .../tool/benchmarks/frame/result_test.dart | 73 ++++ .../tool/benchmarks/input_benchmark_test.dart | 162 +++++++++ .../tool/benchmarks/report/report_test.dart | 317 ++++++++++++++++++ packages/flterm/tool/benchmarks/README.md | 132 ++++++++ .../tool/benchmarks/fixture/terminal.dart | 202 +++++++++++ .../flterm/tool/benchmarks/frame/harness.dart | 159 +++++++++ .../benchmarks/frame/render_environment.dart | 124 +++++++ .../flterm/tool/benchmarks/frame/result.dart | 123 +++++++ .../flterm/tool/benchmarks/frame/suite.dart | 125 +++++++ .../tool/benchmarks/input_benchmark.dart | 205 +++++++++++ packages/flterm/tool/benchmarks/protocol.dart | 67 ++++ .../tool/benchmarks/report/markdown.dart | 134 ++++++++ .../flterm/tool/benchmarks/report/model.dart | 228 +++++++++++++ packages/flterm/tool/benchmarks/run.dart | 316 +++++++++++++++++ 23 files changed, 2737 insertions(+) create mode 100644 .github/workflows/benchmark-flterm.yml create mode 100644 packages/flterm/example/integration_test/flterm_benchmark.dart create mode 100644 packages/flterm/example/test_driver/flterm_benchmark_driver.dart create mode 100644 packages/flterm/test/tool/benchmarks/commands_test.dart create mode 100644 packages/flterm/test/tool/benchmarks/fixture/terminal_test.dart create mode 100644 packages/flterm/test/tool/benchmarks/frame/render_environment_test.dart create mode 100644 packages/flterm/test/tool/benchmarks/frame/result_test.dart create mode 100644 packages/flterm/test/tool/benchmarks/input_benchmark_test.dart create mode 100644 packages/flterm/test/tool/benchmarks/report/report_test.dart create mode 100644 packages/flterm/tool/benchmarks/README.md create mode 100644 packages/flterm/tool/benchmarks/fixture/terminal.dart create mode 100644 packages/flterm/tool/benchmarks/frame/harness.dart create mode 100644 packages/flterm/tool/benchmarks/frame/render_environment.dart create mode 100644 packages/flterm/tool/benchmarks/frame/result.dart create mode 100644 packages/flterm/tool/benchmarks/frame/suite.dart create mode 100644 packages/flterm/tool/benchmarks/input_benchmark.dart create mode 100644 packages/flterm/tool/benchmarks/protocol.dart create mode 100644 packages/flterm/tool/benchmarks/report/markdown.dart create mode 100644 packages/flterm/tool/benchmarks/report/model.dart create mode 100644 packages/flterm/tool/benchmarks/run.dart diff --git a/.github/workflows/benchmark-flterm.yml b/.github/workflows/benchmark-flterm.yml new file mode 100644 index 00000000..521ed708 --- /dev/null +++ b/.github/workflows/benchmark-flterm.yml @@ -0,0 +1,67 @@ +name: benchmark flterm + +on: + push: + branches: [main] + paths: + - 'packages/flterm/**' + - '!packages/flterm/**/*.md' + - 'packages/libghostty/**' + - '!packages/libghostty/**/*.md' + - 'pubspec.yaml' + - 'pubspec.lock' + - '.github/workflows/benchmark-flterm.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: benchmark-flterm-main + cancel-in-progress: false + +env: + ZIG_VERSION: "0.16.0" + +jobs: + benchmark: + name: benchmark-flterm-macos + runs-on: macos-15 + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: stable + cache: true + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: ${{ env.ZIG_VERSION }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pub-cache + key: pub-benchmark-flterm-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-benchmark-flterm- + + - name: run benchmark + id: benchmark + run: | + flutter pub get + dart run packages/flterm/tool/benchmarks/run.dart \ + --output "$RUNNER_TEMP/flterm-benchmark-results" \ + --revision "$GITHUB_SHA" + + - name: publish benchmark summary + if: steps.benchmark.outcome == 'success' + run: cat "$RUNNER_TEMP/flterm-benchmark-results/report.md" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ always() && steps.benchmark.outcome != 'skipped' }} + with: + name: flterm-benchmark-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/flterm-benchmark-results + retention-days: 90 + if-no-files-found: warn diff --git a/packages/flterm/.pubignore b/packages/flterm/.pubignore index 0ee1422b..47a36b8d 100644 --- a/packages/flterm/.pubignore +++ b/packages/flterm/.pubignore @@ -10,3 +10,5 @@ /dart_test.yaml /test/ /tool/ +/example/integration_test/flterm_benchmark.dart +/example/test_driver/flterm_benchmark_driver.dart diff --git a/packages/flterm/example/integration_test/flterm_benchmark.dart b/packages/flterm/example/integration_test/flterm_benchmark.dart new file mode 100644 index 00000000..70349432 --- /dev/null +++ b/packages/flterm/example/integration_test/flterm_benchmark.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../tool/benchmarks/frame/suite.dart' as benchmark; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + benchmark.FltermBenchmarkSuite(binding).register(); +} diff --git a/packages/flterm/example/pubspec.yaml b/packages/flterm/example/pubspec.yaml index 112af9f0..feb497f8 100644 --- a/packages/flterm/example/pubspec.yaml +++ b/packages/flterm/example/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/packages/flterm/example/test_driver/flterm_benchmark_driver.dart b/packages/flterm/example/test_driver/flterm_benchmark_driver.dart new file mode 100644 index 00000000..b00de80c --- /dev/null +++ b/packages/flterm/example/test_driver/flterm_benchmark_driver.dart @@ -0,0 +1,12 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() { + return integrationDriver( + responseDataCallback: (data) { + if (data == null) { + throw const FormatException('Benchmark returned no report data.'); + } + return writeResponseData(data, testOutputFilename: 'flterm_benchmarks'); + }, + ); +} diff --git a/packages/flterm/pubspec.yaml b/packages/flterm/pubspec.yaml index 28f5cd78..23dcb90d 100644 --- a/packages/flterm/pubspec.yaml +++ b/packages/flterm/pubspec.yaml @@ -34,6 +34,9 @@ dependencies: meta: ^1.18.0 dev_dependencies: + crypto: ^3.0.7 fake_async: ^1.3.3 flutter_test: sdk: flutter + integration_test: + sdk: flutter diff --git a/packages/flterm/test/tool/benchmarks/commands_test.dart b/packages/flterm/test/tool/benchmarks/commands_test.dart new file mode 100644 index 00000000..032cf82b --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/commands_test.dart @@ -0,0 +1,115 @@ +import 'dart:io' show Directory, File; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../tool/benchmarks/protocol.dart' show BenchmarkWorkload; +import '../../../tool/benchmarks/report/model.dart' + show BenchmarkEnvironment, BenchmarkWorkloadResult; +import '../../../tool/benchmarks/run.dart' + as run + show + BenchmarkArtifacts, + benchmarkDriveArguments, + benchmarkReportFromFlutterResponse; + +void main() { + group('benchmark commands', () { + group('drive arguments', () { + test('disable DDS for integration performance tracing', () { + expect(run.benchmarkDriveArguments, contains('--no-dds')); + }); + }); + + group('BenchmarkArtifacts', () { + group('prepare', () { + test('removes only artifacts from a prior run', () { + final directory = Directory.systemTemp.createTempSync( + 'flterm-benchmark-artifacts-', + ); + addTearDown(() => directory.deleteSync(recursive: true)); + File.fromUri( + directory.uri.resolve('results.json'), + ).writeAsStringSync('old results'); + File.fromUri( + directory.uri.resolve('report.md'), + ).writeAsStringSync('old report'); + File.fromUri( + directory.uri.resolve('raw-flutter.json'), + ).writeAsStringSync('old raw data'); + File.fromUri( + directory.uri.resolve('flutter.stdout.log'), + ).writeAsStringSync('old stdout'); + File.fromUri( + directory.uri.resolve('flutter.stderr.log'), + ).writeAsStringSync('old stderr'); + final unrelated = File.fromUri(directory.uri.resolve('keep.txt')) + ..writeAsStringSync('keep'); + final artifacts = run.BenchmarkArtifacts(directory); + + artifacts.prepare(); + + expect( + directory.listSync().map((entry) => entry.uri.pathSegments.last), + ['keep.txt'], + ); + expect(unrelated.readAsStringSync(), 'keep'); + }); + }); + }); + + group('benchmarkReportFromFlutterResponse', () { + Map response() => { + 'font_digest': 'sha256:fonts', + 'workloads': [ + for (final workload in BenchmarkWorkload.values) + BenchmarkWorkloadResult( + id: workload.id, + label: workload.label, + metrics: const [], + ).toJson(), + ], + }; + + const environment = BenchmarkEnvironment( + operatingSystem: 'macos', + operatingSystemVersion: '15.5', + architecture: 'arm64', + dartVersion: '3.12.0', + flutterVersion: '3.44.0', + ); + + test('creates a report from every workload', () { + final result = run.benchmarkReportFromFlutterResponse( + response(), + revision: 'abc123', + environment: environment, + ); + + expect( + result.workloads.map((workload) => workload.id), + BenchmarkWorkload.values.map((workload) => workload.id), + ); + }); + + test('rejects a missing workload', () { + final incompleteResponse = response(); + (incompleteResponse['workloads']! as List).removeLast(); + + expect( + () => run.benchmarkReportFromFlutterResponse( + incompleteResponse, + revision: 'abc123', + environment: environment, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('every protocol workload'), + ), + ), + ); + }); + }); + }); +} diff --git a/packages/flterm/test/tool/benchmarks/fixture/terminal_test.dart b/packages/flterm/test/tool/benchmarks/fixture/terminal_test.dart new file mode 100644 index 00000000..cc66d8a3 --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/fixture/terminal_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert' show utf8; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../tool/benchmarks/fixture/terminal.dart' + show TerminalBenchmarkFixture; + +void main() { + group('TerminalBenchmarkFixture', () { + group('streamingOutput', () { + test('provides the streaming benchmark corpus', () { + final bytes = TerminalBenchmarkFixture.streamingOutput; + final text = utf8.decode(bytes); + + expect(bytes, hasLength(4 * 1024 * 1024)); + expect(() => bytes[0] = bytes[0], throwsUnsupportedError); + expect(text, contains('\x1b[38;5;39m')); + expect(text, contains('\x1b[38;2;')); + expect(text, contains('\x1b[32m')); + expect(text, contains('界')); + expect(text, contains('e\u0301')); + }); + }); + + group('interactiveTuiOutput', () { + test('provides the interactive TUI benchmark corpus', () { + final bytes = TerminalBenchmarkFixture.interactiveTuiOutput; + final text = utf8.decode(bytes); + + expect(bytes, hasLength(4 * 1024 * 1024)); + expect(() => bytes[0] = bytes[0], throwsUnsupportedError); + expect(text, contains('\x1b[?1049h')); + expect(text, contains('\x1b[?2026h')); + }); + }); + + group('chunks', () { + test('creates fixed-size views over every input byte', () { + final input = Uint8List.fromList([1, 2, 3, 4]); + + final result = TerminalBenchmarkFixture.chunks(input, 2); + + expect(result.map((chunk) => chunk.length), [2, 2]); + expect(result.expand((chunk) => chunk), orderedEquals(input)); + }); + + test('rejects a partial final chunk', () { + expect( + () => TerminalBenchmarkFixture.chunks(Uint8List(3), 2), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('divisible by chunkSize'), + ), + ), + ); + }); + }); + + group('partialFrames', () { + test('creates the requested three-row updates', () { + final result = TerminalBenchmarkFixture.partialFrames(count: 12); + final addresses = RegExp( + r'\x1b\[\d+;1H', + ).allMatches(utf8.decode(result.first)); + + expect(result, hasLength(12)); + expect(addresses, hasLength(3)); + }); + }); + + group('fullFrames', () { + test('addresses every terminal row', () { + final result = TerminalBenchmarkFixture.fullFrames(count: 1); + + final addresses = RegExp( + r'\x1b\[\d+;1H', + ).allMatches(utf8.decode(result.single)); + + expect(addresses, hasLength(80)); + }); + + test('rejects a non-positive frame count', () { + expect( + () => TerminalBenchmarkFixture.fullFrames(count: 0), + throwsRangeError, + ); + }); + }); + + group('glyphMissFrames', () { + test('creates distinct frames covering uncached glyph classes', () { + final result = TerminalBenchmarkFixture.glyphMissFrames(count: 120); + final text = utf8.decode(result.expand((frame) => frame).toList()); + + expect(result.toSet(), hasLength(120)); + expect(text, contains('一')); + expect(text, contains('😀')); + }); + }); + + group('inputDigest', () { + const fixtureDigest = + 'sha256:2ab7fcf523ee15f013dea8481916414a' + '851b357db62e08603ff2d104ceba50cf'; + + test('captures fixture and font identity', () { + final first = TerminalBenchmarkFixture.benchmarkDigest( + 'sha256:font-one', + ); + final second = TerminalBenchmarkFixture.benchmarkDigest( + 'sha256:font-two', + ); + + expect(TerminalBenchmarkFixture.inputDigest, fixtureDigest); + expect(first, isNot(second)); + }); + }); + }); +} diff --git a/packages/flterm/test/tool/benchmarks/frame/render_environment_test.dart b/packages/flterm/test/tool/benchmarks/frame/render_environment_test.dart new file mode 100644 index 00000000..d5569606 --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/frame/render_environment_test.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../tool/benchmarks/frame/render_environment.dart' + show benchmarkFontDigest; + +void main() { + group('benchmarkFontDigest', () { + ByteData data(List bytes) { + return Uint8List.fromList(bytes).buffer.asByteData(); + } + + String digest({ + List regular = const [1], + List bold = const [2], + List textFallback = const [3], + List emojiFallback = const [4], + }) { + return benchmarkFontDigest( + jetBrainsMonoRegular: data(regular), + jetBrainsMonoBold: data(bold), + notoSansJp: data(textFallback), + notoEmoji: data(emojiFallback), + ); + } + + test('captures every bundled font', () { + final baseline = digest(); + + expect(digest(regular: [5]), isNot(baseline)); + expect(digest(bold: [5]), isNot(baseline)); + expect(digest(textFallback: [5]), isNot(baseline)); + expect(digest(emojiFallback: [5]), isNot(baseline)); + }); + }); +} diff --git a/packages/flterm/test/tool/benchmarks/frame/result_test.dart b/packages/flterm/test/tool/benchmarks/frame/result_test.dart new file mode 100644 index 00000000..bbc677d3 --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/frame/result_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../tool/benchmarks/frame/result.dart' + show framePerformanceResult; + +void main() { + group('framePerformanceResult', () { + Map summary({ + List buildTimes = const [500, 800, 1200], + List rasterTimes = const [700, 1000, 1500], + }) => { + 'average_frame_build_time_millis': 0.5, + '90th_percentile_frame_build_time_millis': 0.8, + '99th_percentile_frame_build_time_millis': 1.2, + 'worst_frame_build_time_millis': 2.0, + 'missed_frame_build_budget_count': 1, + 'average_frame_rasterizer_time_millis': 0.7, + '90th_percentile_frame_rasterizer_time_millis': 1.0, + '99th_percentile_frame_rasterizer_time_millis': 1.5, + 'worst_frame_rasterizer_time_millis': 2.5, + 'missed_frame_rasterizer_budget_count': 2, + 'frame_count': 300, + 'frame_build_times': buildTimes, + 'frame_rasterizer_times': rasterTimes, + }; + + group('conversion', () { + test('preserves the Flutter frame summary', () { + final result = framePerformanceResult( + workload: .fullOutput, + summary: summary(), + ); + + expect(result.metric('ui_p99').value, 1.2); + expect(result.metric('raster_p99').value, 1.5); + expect(result.metric('raster_missed_budget').value, 2); + expect(result.details['frame_build_times'], [500, 800, 1200]); + }); + }); + + group('validation', () { + test('rejects a summary without required metrics', () { + expect( + () => + framePerformanceResult(workload: .fullOutput, summary: const {}), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('average_frame_build_time_millis'), + ), + ), + ); + }); + + test('rejects an input workload', () { + expect( + () => framePerformanceResult( + workload: .streamingOutput, + summary: summary(), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('frame workload'), + ), + ), + ); + }); + }); + }); +} diff --git a/packages/flterm/test/tool/benchmarks/input_benchmark_test.dart b/packages/flterm/test/tool/benchmarks/input_benchmark_test.dart new file mode 100644 index 00000000..4b437fbc --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/input_benchmark_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../../tool/benchmarks/input_benchmark.dart' show measureBenchmark; +import '../../../tool/benchmarks/report/model.dart' + show BenchmarkMetric, BenchmarkWorkloadResult; + +void main() { + group('measureBenchmark', () { + BenchmarkWorkloadResult measure(List samples) { + final clock = _FakeClock(samples); + return measureBenchmark( + workload: .streamingOutput, + warmupIterations: 0, + minimumSamples: samples.length, + minimumDuration: .zero, + clock: clock.read, + step: clock.step, + ); + } + + BenchmarkMetric metric(BenchmarkWorkloadResult result, String id) { + return result.metrics.singleWhere((metric) => metric.id == id); + } + + group('statistics', () { + test('computes timing distribution statistics', () { + final result = measure([1000, 2000, 3000, 4000]); + + expect(metric(result, 'mean').value, 2.5); + expect( + metric(result, 'standard_deviation').value, + closeTo(1.291, 0.001), + ); + expect( + ( + p50: metric(result, 'p50').value, + p95: metric(result, 'p95').value, + p99: metric(result, 'p99').value, + ), + (p50: 2.0, p95: 4.0, p99: 4.0), + ); + }); + + test('computes aggregate throughput', () { + final clock = _FakeClock([2000, 3000]); + + final result = measureBenchmark( + workload: .streamingOutput, + warmupIterations: 0, + minimumSamples: 2, + minimumDuration: .zero, + bytesPerIteration: 1024 * 1024, + clock: clock.read, + step: clock.step, + ); + + expect(metric(result, 'throughput').value, 400); + }); + }); + + group('sampling', () { + test('preserves raw samples as diagnostic details', () { + final result = measure([2000, 3000]); + + expect(result.details['samples_microseconds'], [2000, 3000]); + }); + + test('excludes preparation from measured samples', () { + final clock = _FakeClock([10, 10]); + + final result = measureBenchmark( + workload: .streamingOutput, + warmupIterations: 0, + minimumSamples: 2, + minimumDuration: .zero, + clock: clock.read, + prepare: () => clock.advance(100), + step: clock.step, + ); + + expect(result.details['samples_microseconds'], [10, 10]); + }); + + test('runs unmeasured warmup iterations', () { + final clock = _FakeClock([10]); + var steps = 0; + + measureBenchmark( + workload: .streamingOutput, + warmupIterations: 2, + minimumSamples: 1, + minimumDuration: .zero, + clock: clock.read, + step: () { + steps++; + clock.step(); + }, + ); + + expect(steps, 3); + }); + + test('meets the minimum measured duration', () { + final clock = _FakeClock([10, 10, 10]); + + final result = measureBenchmark( + workload: .streamingOutput, + warmupIterations: 0, + minimumSamples: 1, + minimumDuration: const Duration(microseconds: 25), + clock: clock.read, + step: clock.step, + ); + + expect(result.details['samples_microseconds'], [10, 10, 10]); + }); + }); + + group('workload', () { + test('rejects a frame workload', () { + final clock = _FakeClock([10]); + + expect( + () => measureBenchmark( + workload: .cleanFrame, + warmupIterations: 0, + minimumSamples: 1, + minimumDuration: .zero, + clock: clock.read, + step: clock.step, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('input workload'), + ), + ), + ); + }); + }); + }); +} + +final class _FakeClock { + final List samples; + var _microseconds = 0; + var _sample = 0; + + _FakeClock(this.samples); + + int read() => _microseconds; + + void step() { + advance(samples[_sample]); + _sample = (_sample + 1) % samples.length; + } + + void advance(int microseconds) { + _microseconds += microseconds; + } +} diff --git a/packages/flterm/test/tool/benchmarks/report/report_test.dart b/packages/flterm/test/tool/benchmarks/report/report_test.dart new file mode 100644 index 00000000..12505707 --- /dev/null +++ b/packages/flterm/test/tool/benchmarks/report/report_test.dart @@ -0,0 +1,317 @@ +import 'dart:convert' show jsonDecode, jsonEncode; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../tool/benchmarks/frame/result.dart' + show framePerformanceResult; +import '../../../../tool/benchmarks/report/markdown.dart' + show formatBenchmarkReport; +import '../../../../tool/benchmarks/report/model.dart' + show + BenchmarkEnvironment, + BenchmarkMetric, + BenchmarkReport, + BenchmarkWorkloadResult; + +void main() { + group('benchmark reporting', () { + List inputMetrics({required double value}) { + return [ + BenchmarkMetric( + id: 'throughput', + label: 'throughput', + value: value, + unit: .mebibytesPerSecond, + direction: .higherIsBetter, + ), + for (final id in const [ + 'mean', + 'standard_deviation', + 'p50', + 'p95', + 'p99', + ]) + BenchmarkMetric( + id: id, + label: id, + value: id == 'standard_deviation' ? 0.25 : 1, + unit: .milliseconds, + direction: .lowerIsBetter, + ), + ]; + } + + BenchmarkReport report({double value = 100, bool completeMetrics = true}) { + final metrics = inputMetrics(value: value); + return BenchmarkReport( + protocolVersion: 1, + fixtureDigest: 'sha256:one', + revision: 'abc123', + environment: const BenchmarkEnvironment( + operatingSystem: 'macos', + operatingSystemVersion: '15.5', + architecture: 'arm64', + dartVersion: '3.12.0', + flutterVersion: '3.44.0', + runnerImage: 'macos-15', + ), + workloads: [ + BenchmarkWorkloadResult( + id: 'input.streaming_output', + label: 'streaming output', + metrics: completeMetrics ? metrics : metrics.take(1).toList(), + details: const {'samples': 100}, + ), + ], + ); + } + + BenchmarkReport frameReport() { + return BenchmarkReport( + protocolVersion: 1, + fixtureDigest: 'sha256:one', + revision: 'abc123', + environment: const BenchmarkEnvironment( + operatingSystem: 'macos', + operatingSystemVersion: '15.5', + architecture: 'arm64', + dartVersion: '3.12.0', + flutterVersion: '3.44.0', + runnerImage: 'macos-15', + ), + workloads: [ + framePerformanceResult( + workload: .fullOutput, + summary: const { + 'average_frame_build_time_millis': 1, + '90th_percentile_frame_build_time_millis': 2, + '99th_percentile_frame_build_time_millis': 3, + 'worst_frame_build_time_millis': 4, + 'missed_frame_build_budget_count': 1, + 'average_frame_rasterizer_time_millis': 2, + '90th_percentile_frame_rasterizer_time_millis': 3, + '99th_percentile_frame_rasterizer_time_millis': 4, + 'worst_frame_rasterizer_time_millis': 5, + 'missed_frame_rasterizer_budget_count': 2, + 'frame_count': 100, + }, + ), + ], + ); + } + + group('BenchmarkWorkloadResult.fromJson', () { + test('round trips every workload field', () { + final workload = report().workloads.single; + + final result = BenchmarkWorkloadResult.fromJson( + jsonDecode(jsonEncode(workload.toJson())) as Map, + ); + + expect(result.toJson(), workload.toJson()); + }); + + test('rejects a metric with an unknown unit', () { + final json = report().workloads.single.toJson(); + final metrics = json['metrics']! as List; + final metric = metrics.first! as Map; + metric['unit'] = 'frames_per_fortnight'; + + expect( + () => BenchmarkWorkloadResult.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('frames_per_fortnight'), + ), + ), + ); + }); + + test('reports invalid metric values as malformed data', () { + final json = report().workloads.single.toJson(); + final metrics = json['metrics']! as List; + final metric = metrics.first! as Map; + metric['value'] = -1; + + expect( + () => BenchmarkWorkloadResult.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('finite and non-negative'), + ), + ), + ); + }); + }); + + group('model validation', () { + test('rejects duplicate result identities', () { + final original = report(); + final workload = original.workloads.single; + final metric = inputMetrics(value: 100).first; + + expect( + () => BenchmarkReport( + protocolVersion: original.protocolVersion, + fixtureDigest: original.fixtureDigest, + revision: original.revision, + environment: original.environment, + workloads: [workload, workload], + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('duplicate workload id'), + ), + ), + ); + expect( + () => BenchmarkWorkloadResult( + id: 'input.streaming_output', + label: 'streaming output', + metrics: [metric, metric], + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('duplicate metric id'), + ), + ), + ); + }); + + test('rejects an empty workload identifier', () { + expect( + () => BenchmarkWorkloadResult( + id: ' ', + label: 'streaming output', + metrics: inputMetrics(value: 100), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('workload id must not be empty'), + ), + ), + ); + }); + + test('rejects invalid metric values', () { + final invalidMeasurement = throwsA( + isA().having( + (error) => error.message, + 'message', + contains('finite and non-negative'), + ), + ); + + expect( + () => BenchmarkMetric( + id: 'throughput', + label: 'throughput', + value: -1, + unit: .mebibytesPerSecond, + direction: .higherIsBetter, + ), + invalidMeasurement, + ); + expect( + () => BenchmarkMetric( + id: 'throughput', + label: 'throughput', + value: double.nan, + unit: .mebibytesPerSecond, + direction: .higherIsBetter, + ), + invalidMeasurement, + ); + }); + + test('rejects a missing required metric', () { + final workload = BenchmarkWorkloadResult( + id: 'input.streaming_output', + label: 'streaming output', + metrics: inputMetrics(value: 100).take(1).toList(), + ); + + expect( + () => workload.metric('mean'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('missing metric "mean"'), + ), + ), + ); + }); + + test('rejects a non-integer count detail', () { + final workload = BenchmarkWorkloadResult( + id: 'input.streaming_output', + label: 'streaming output', + metrics: inputMetrics(value: 100), + details: const {'samples': 1.5}, + ); + + expect( + () => workload.detailCount('samples'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('count detail "samples"'), + ), + ), + ); + }); + }); + + group('formatBenchmarkReport', () { + test('formats an input benchmark report', () { + final benchmarkReport = report(value: 120.25); + + final result = formatBenchmarkReport(benchmarkReport); + + expect( + result, + contains( + '| streaming output | 120.25 MiB/s | 1.00 ms ± 0.25 ms | ' + '1.00 ms | 1.00 ms | 1.00 ms | 100 |', + ), + ); + expect(result, contains('Runner: macos-15')); + }); + + test('formats frame misses as a count and rate', () { + final benchmarkReport = frameReport(); + + final result = formatBenchmarkReport(benchmarkReport); + + expect(result, contains('| 2/100 (2.0%) | 100 |')); + }); + + test('rejects an incomplete input workload', () { + final benchmarkReport = report(completeMetrics: false); + + expect( + () => formatBenchmarkReport(benchmarkReport), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('missing metric "mean"'), + ), + ), + ); + }); + }); + }); +} diff --git a/packages/flterm/tool/benchmarks/README.md b/packages/flterm/tool/benchmarks/README.md new file mode 100644 index 00000000..2a6ad327 --- /dev/null +++ b/packages/flterm/tool/benchmarks/README.md @@ -0,0 +1,132 @@ +# flterm benchmarks + +This suite measures the performance boundaries that directly affect terminal +users: + +- processing output through `TerminalController.write`; +- drawing unchanged, partially updated, and fully updated terminal frames; +- rendering glyphs that are not already present in the atlas; +- presenting the first frame of a new terminal renderer. + +It intentionally does not benchmark individual parsers, painters, caches, or +other implementation details. Those measurements are useful only when +investigating a specific subsystem and do not describe the experience of using +flterm. + +## Measurement protocol + +Rendering follows Flutter's +[integration performance testing](https://docs.flutter.dev/cookbook/testing/integration/profiling) +guidance. The suite runs through `flutter drive --profile` and records Flutter's +UI and raster frame timings. Debug widget-test timings are not performance +results. + +Every workload uses: + +- a terminal with 120 columns and 80 rows; +- fixed cell metrics and device pixel ratio 1; +- the example's bundled JetBrains Mono, Noto Sans JP, and Noto Emoji fonts; +- fixture generation and chunk preparation outside measured regions; +- sequential execution, with no concurrent performance workloads. + +Input workloads perform ten unmeasured warmups, then collect at least 100 +samples and five seconds of measured work. Each sample processes exactly +4 MiB. Rendering workloads schedule and await one real engine frame per +iteration at the runner display's cadence. + +The input timer surrounds only the prepared calls to +`TerminalController.write`. Rendering results come from Flutter frame timings. +Terminal updates are applied before each measured frame, but their parser time +is not added to Flutter's UI or raster duration. Flutter can omit frame timings +at collection boundaries, so steady workloads require at least 95 percent of +their scheduled samples. + +## Deterministic input + +The suite uses two complementary input sources: + +- A generated streaming-output corpus represents build logs and command output. + It contains plain text, wrapping, 16/256/RGB styling, paths, numbers, CR/LF + handling, and representative Unicode. +- A checked-in normalized editor-session transcript represents cursor-addressed + TUI updates, status lines, commands, alternate-screen operation, and + synchronized output. It contains no host paths, timing values, locale-derived + content, or terminal replies. + +Both corpora are exactly 4 MiB and are sliced into views before timing begins. +The report's fixture digest identifies the deterministic fixtures and bundled +fonts used for a run. + +The workload mix follows established terminal benchmark practice: combine +realistic application-shaped traffic with focused synthetic stress cases. +Throughput alone is not treated as overall terminal performance because it +cannot describe frame consistency or latency. + +## Workloads + +### Input throughput + +`streaming output, 128 KiB chunks` models batched output using the repository's +default PTY output batch size. + +`interactive TUI, 4 KiB chunks` models smaller, escape-heavy interactive +updates. + +Both workloads keep scrollback disabled so a sample measures terminal input +processing rather than unbounded history growth. + +### Frame performance + +`clean frame` measures steady frames without a terminal mutation. + +`partial TUI update` changes exactly three rows using the editor transcript. + +`full-screen output` changes every row using styled streaming output. + +These workloads each record 300 frames after an initial warm frame. + +`new glyphs` records 120 frames. Every frame introduces a disjoint set of CJK +glyphs and also exercises bold text, combining text, and emoji. The bundled +fonts keep glyph selection stable across machines. + +`first terminal frame` records at least 30 independent first frames from fresh +renderer and render-cache instances. Terminal state and fonts are prepared +first. This measures flterm's first presentation, not Flutter process startup. + +## Run locally + +From `packages/flterm`: + +```sh +dart run tool/benchmarks/run.dart +``` + +The command writes the following files under +`example/build/benchmark-results/`: + +- `report.md`: the human-readable result; +- `results.json`: normalized, versioned benchmark data; +- `raw-flutter.json`: Flutter's complete integration response; +- `flutter.stdout.log` and `flutter.stderr.log`: runner diagnostics. + +Use a custom output directory or revision label when needed: + +```sh +dart run tool/benchmarks/run.dart \ + --output /tmp/flterm-results \ + --revision working-tree +``` + +For repeatable measurements, keep other applications idle and record the +hardware, Flutter version, display configuration, and power state. + +## Continuous measurement + +The `benchmark flterm` workflow runs after relevant flterm or libghostty +changes land on `main`, and it can also be started manually. It measures the +checked-out revision once, writes its report to the job summary, and uploads +the complete result bundle for 90 days. + +Preserve result data and generated Markdown elsewhere when a report must +outlive artifact retention. Harness failures fail the benchmark workflow; +measured values alone do not fail it. diff --git a/packages/flterm/tool/benchmarks/fixture/terminal.dart b/packages/flterm/tool/benchmarks/fixture/terminal.dart new file mode 100644 index 00000000..a933a4e1 --- /dev/null +++ b/packages/flterm/tool/benchmarks/fixture/terminal.dart @@ -0,0 +1,202 @@ +import 'dart:convert' show utf8; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' show sha256; + +import '../protocol.dart'; + +/// Normalized content from an editor session. +/// +/// Host paths, timing data, and terminal replies are intentionally absent. +/// The benchmark adds cursor addressing and synchronized-update boundaries. +const _editorTranscript = [ + ( + code: ' final controller = TerminalController();', + status: ' NORMAL terminal_view.dart 42,7 63%', + command: ':set number cursorline', + ), + ( + code: ' controller.write(output);', + status: ' INSERT terminal_view.dart 43,29 65%', + command: '-- INSERT --', + ), + ( + code: ' await tester.pump(const Duration(milliseconds: 16));', + status: ' NORMAL frame_render_benchmark.dart 98,12 51%', + command: '/watchPerformance', + ), + ( + code: ' expect(result, isNotEmpty);', + status: ' NORMAL benchmark_report_test.dart 76,5 38%', + command: ':write', + ), + ( + code: ' const symbols = "λ界é";', + status: ' INSERT terminal_benchmark_fixture.dart 31,28 24%', + command: '-- INSERT --', + ), + ( + code: ' return renderer.buildFrame();', + status: ' NORMAL terminal_renderer.dart 211,3 72%', + command: ':nohlsearch', + ), +]; +const _partialFrameDirtyRows = 3; + +/// Deterministic input prepared outside every timed benchmark region. +abstract final class TerminalBenchmarkFixture { + static const corpusLength = 4 * 1024 * 1024; + + static final streamingOutput = _corpus( + _streamingRecords(), + ).asUnmodifiableView(); + static final interactiveTuiOutput = _corpus( + _tuiRecords(), + ).asUnmodifiableView(); + + static final inputDigest = _digest(); + + /// Combines deterministic input and font identities for report provenance. + static String benchmarkDigest(String fontDigest) { + final input = utf8.encode('$inputDigest:$fontDigest'); + return 'sha256:${sha256.convert(input)}'; + } + + /// Splits [input] into views allocated before the timed write loop. + static List chunks(Uint8List input, int chunkSize) { + if (chunkSize <= 0) { + throw RangeError.range(chunkSize, 1, null, 'chunkSize'); + } + if (input.length % chunkSize != 0) { + throw ArgumentError.value( + input.length, + 'input', + 'length must be divisible by chunkSize', + ); + } + return List.unmodifiable([ + for (var offset = 0; offset < input.length; offset += chunkSize) + Uint8List.sublistView(input, offset, offset + chunkSize), + ]); + } + + /// Produces editor updates affecting three rows of the protocol surface. + static List partialFrames({required int count}) { + _validateCount(count); + return List.unmodifiable([ + for (var frame = 0; frame < count; frame++) _partialFrame(frame), + ]); + } + + /// Produces streaming-output updates affecting every protocol row. + static List fullFrames({required int count}) { + _validateCount(count); + return List.unmodifiable([ + for (var frame = 0; frame < count; frame++) _fullFrame(frame), + ]); + } + + /// Creates frames whose CJK glyph set is disjoint from earlier frames. + static List glyphMissFrames({required int count}) { + _validateCount(count); + return List.unmodifiable([ + for (var frame = 0; frame < count; frame++) _glyphFrame(frame), + ]); + } + + static List _streamingRecords() => [ + for (var line = 0; line < 64; line++) + Uint8List.fromList( + utf8.encode( + '\x1b[38;5;39mINFO\x1b[0m ' + 'build/module_${line.toString().padLeft(2, '0')}.dart ' + '\x1b[38;2;120;200;80mcompleted\x1b[0m in ${17 + line} ms; ' + '\x1b[32m${100 + line} checks passed\x1b[0m; λ界 e\u0301\r\n', + ), + ), + ]; + + static List _tuiRecords() => [ + Uint8List.fromList(utf8.encode('\x1b[?1049h\x1b[2J\x1b[H')), + for (var index = 0; index < _editorTranscript.length; index++) + _partialFrame(index), + ]; + + static Uint8List _corpus(List records) { + final output = Uint8List(corpusLength); + var offset = 0; + var record = 0; + while (offset + records[record].length <= output.length) { + final bytes = records[record]; + output.setRange(offset, offset + bytes.length, bytes); + offset += bytes.length; + record = (record + 1) % records.length; + } + output.fillRange(offset, output.length, 0x20); + return output; + } + + static Uint8List _partialFrame(int frame) { + final transcript = _editorTranscript[frame % _editorTranscript.length]; + final content = [transcript.code, transcript.status, transcript.command]; + final buffer = StringBuffer('\x1b[?2026h'); + for (var index = 0; index < _partialFrameDirtyRows; index++) { + final row = benchmarkRows - _partialFrameDirtyRows + index + 1; + buffer + ..write('\x1b[$row;1H\x1b[2K') + ..write(content[index % content.length]) + ..write(' #${frame.toString().padLeft(3, '0')}'); + } + buffer.write('\x1b[?2026l'); + return Uint8List.fromList(utf8.encode(buffer.toString())); + } + + static Uint8List _fullFrame(int frame) { + final buffer = StringBuffer('\x1b[?2026h'); + for (var row = 0; row < benchmarkRows; row++) { + buffer.write( + '\x1b[${row + 1};1H\x1b[2K' + '\x1b[38;5;${32 + row % 96}m' + 'worker ${row.toString().padLeft(2, '0')} ' + 'frame ${frame.toString().padLeft(3, '0')} ' + 'compile λ界 e\u0301 \x1b[0m', + ); + } + buffer.write('\x1b[?2026l'); + return Uint8List.fromList(utf8.encode(buffer.toString())); + } + + static Uint8List _glyphFrame(int frame) { + final cjk = String.fromCharCodes([ + for (var offset = 0; offset < 4; offset++) 0x4E00 + frame * 4 + offset, + ]); + final emoji = String.fromCharCode(0x1F600 + frame % 16); + final frameNumber = frame.toString().padLeft(3, '0'); + return Uint8List.fromList( + utf8.encode( + [ + '\x1b[?2026h', + '\x1b[20;1H\x1b[2K\x1b[1m$cjk\x1b[0m', + '\x1b[21;1H\x1b[2K$emoji grapheme e\u0301', + '\x1b[22;1H\x1b[2Kcache miss $frameNumber', + '\x1b[?2026l', + ].join(), + ), + ); + } + + static String _digest() { + final components = [ + sha256.convert(streamingOutput), + sha256.convert(interactiveTuiOutput), + ...partialFrames(count: benchmarkSteadyFrames).map(sha256.convert), + ...fullFrames(count: benchmarkSteadyFrames).map(sha256.convert), + ...glyphMissFrames(count: benchmarkGlyphMissFrames).map(sha256.convert), + ].join(':'); + return 'sha256:${sha256.convert(utf8.encode(components))}'; + } + + static void _validateCount(int count) { + if (count <= 0) throw RangeError.range(count, 1, null, 'count'); + } +} diff --git a/packages/flterm/tool/benchmarks/frame/harness.dart b/packages/flterm/tool/benchmarks/frame/harness.dart new file mode 100644 index 00000000..ef968f6c --- /dev/null +++ b/packages/flterm/tool/benchmarks/frame/harness.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:libghostty/libghostty.dart' show Terminal; + +import '../fixture/terminal.dart'; +import '../protocol.dart'; +import '../report/model.dart'; +import 'render_environment.dart'; +import 'result.dart'; + +const _firstFrameReportKey = 'pending_first_frame_report'; +const _reportKey = 'pending_frame_report'; + +/// Drives profile-mode terminal frames and returns Flutter timing summaries. +final class FrameBenchmarkHarness { + final IntegrationTestWidgetsFlutterBinding _binding; + final WidgetTester _tester; + + const FrameBenchmarkHarness(this._binding, this._tester); + + Map get _reportData => + _binding.reportData ??= {}; + + Future initialize() async { + _tester.view + ..devicePixelRatio = 1 + ..physicalSize = benchmarkSurfaceSize; + addTearDown(_tester.view.resetDevicePixelRatio); + addTearDown(_tester.view.resetPhysicalSize); + final digest = await loadBenchmarkFonts(); + _reportData['font_digest'] = digest; + } + + /// Measures fresh renderer mounts while excluding old-atlas disposal. + Future measureFirstTerminalFrames() async { + final states = TerminalBenchmarkFixture.fullFrames( + count: benchmarkFirstFrameMounts, + ); + final resources = [ + for (final state in states) + ( + terminal: Terminal(cols: benchmarkColumns, rows: benchmarkRows) + ..write(state), + cache: TerminalRenderCache(), + ), + ]; + final retainedAtlases = []; + try { + await _tester.pumpWidget(const SizedBox.shrink()); + await _binding.watchPerformance(() async { + for (var sample = 0; sample < resources.length; sample++) { + final resource = resources[sample]; + _binding.attachRootWidget( + _binding.wrapWithDefaultView( + BenchmarkTerminalSurface( + key: ValueKey(sample), + terminal: resource.terminal, + cache: resource.cache, + ), + ), + ); + _binding.scheduleFrame(); + await _binding.endOfFrame; + retainedAtlases.add(retainBenchmarkAtlas(resource.cache)); + } + }, reportKey: _firstFrameReportKey); + final summary = Map.from( + _reportData.remove(_firstFrameReportKey)! as Map, + ); + return framePerformanceResult( + workload: .firstTerminalFrame, + summary: summary, + ); + } finally { + await _tester.pumpWidget(const SizedBox.shrink()); + for (final handle in retainedAtlases) { + handle.release(); + } + for (final resource in resources) { + resource.cache.dispose(); + resource.terminal.dispose(); + } + } + } + + Future measureGlyphMissFrames() { + final updates = TerminalBenchmarkFixture.glyphMissFrames( + count: benchmarkGlyphMissFrames, + ); + return _measureFrames(workload: .glyphMisses, updates: updates); + } + + Future measureSteadyFrames({ + required BenchmarkWorkload workload, + List? updates, + }) async { + final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); + final cache = TerminalRenderCache(); + addTearDown(terminal.dispose); + addTearDown(cache.dispose); + addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); + + await _tester.pumpWidget( + BenchmarkTerminalSurface(terminal: terminal, cache: cache), + ); + terminal.write(TerminalBenchmarkFixture.fullFrames(count: 1).single); + await _tester.pump(); + + var update = 0; + final summary = await _capture(() async { + for (var frame = 0; frame < benchmarkSteadyFrames; frame++) { + if (updates != null) { + terminal.write(updates[update]); + update = (update + 1) % updates.length; + } + await _renderFrame(); + } + }); + return framePerformanceResult(workload: workload, summary: summary); + } + + Future> _capture(Future Function() action) async { + await _binding.watchPerformance(action, reportKey: _reportKey); + return Map.from( + _reportData.remove(_reportKey)! as Map, + ); + } + + Future _measureFrames({ + required BenchmarkWorkload workload, + required List updates, + }) async { + final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); + final cache = TerminalRenderCache(); + addTearDown(terminal.dispose); + addTearDown(cache.dispose); + addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); + await _tester.pumpWidget( + BenchmarkTerminalSurface(terminal: terminal, cache: cache), + ); + + final summary = await _capture(() async { + for (final update in updates) { + terminal.write(update); + await _renderFrame(); + } + }); + return framePerformanceResult(workload: workload, summary: summary); + } + + Future _renderFrame() async { + _binding.scheduleFrame(); + await _binding.endOfFrame; + } +} diff --git a/packages/flterm/tool/benchmarks/frame/render_environment.dart b/packages/flterm/tool/benchmarks/frame/render_environment.dart new file mode 100644 index 00000000..ec045e45 --- /dev/null +++ b/packages/flterm/tool/benchmarks/frame/render_environment.dart @@ -0,0 +1,124 @@ +import 'dart:convert' show utf8; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flterm/src/foundation/cell_metrics.dart'; +import 'package:flterm/src/foundation/terminal_render_observer.dart'; +import 'package:flterm/src/foundation/terminal_theme.dart'; +import 'package:flterm/src/rendering/atlas/atlas_config.dart'; +import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/terminal_renderer.dart'; +import 'package:flutter/rendering.dart' show ViewportOffset; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:libghostty/libghostty.dart' show Terminal; + +import '../protocol.dart'; + +const _metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); + +/// Logical size of the benchmark grid at device pixel ratio 1. +final benchmarkSurfaceSize = Size( + benchmarkColumns * _metrics.cellWidth, + benchmarkRows * _metrics.cellHeight, +); +final _theme = TerminalTheme.dark().copyWith( + fontFamilyFallback: const ['Noto Sans JP', 'Noto Emoji'], +); +final _atlasConfig = AtlasConfig.fromTheme( + theme: _theme, + metrics: _metrics, + devicePixelRatio: 1, +); +Future? _loadingFonts; + +/// Loads every bundled font before rendering and returns their identity. +Future loadBenchmarkFonts() => _loadingFonts ??= _loadFonts(); + +Future _loadFonts() async { + final assets = await Future.wait([ + rootBundle.load('assets/fonts/JetBrainsMono-Regular.ttf'), + rootBundle.load('assets/fonts/JetBrainsMono-Bold.ttf'), + rootBundle.load('assets/fonts/NotoSansJP-Regular.ttf'), + rootBundle.load('assets/fonts/NotoEmoji-Regular.ttf'), + ]); + final textLoader = FontLoader('Noto Sans JP') + ..addFont(Future.value(assets[2])); + final emojiLoader = FontLoader('Noto Emoji') + ..addFont(Future.value(assets[3])); + await Future.wait([textLoader.load(), emojiLoader.load()]); + + return benchmarkFontDigest( + jetBrainsMonoRegular: assets[0], + jetBrainsMonoBold: assets[1], + notoSansJp: assets[2], + notoEmoji: assets[3], + ); +} + +/// Identifies every bundled font that can affect benchmark rendering. +String benchmarkFontDigest({ + required ByteData jetBrainsMonoRegular, + required ByteData jetBrainsMonoBold, + required ByteData notoSansJp, + required ByteData notoEmoji, +}) { + final componentDigests = [ + sha256.convert(Uint8List.sublistView(jetBrainsMonoRegular)), + sha256.convert(Uint8List.sublistView(jetBrainsMonoBold)), + sha256.convert(Uint8List.sublistView(notoSansJp)), + sha256.convert(Uint8List.sublistView(notoEmoji)), + ].join(':'); + return 'sha256:${sha256.convert(utf8.encode(componentDigests))}'; +} + +/// Fixed terminal surface shared by every rendering workload. +final class BenchmarkTerminalSurface extends StatelessWidget { + final Terminal terminal; + final TerminalRenderCache cache; + + const BenchmarkTerminalSurface({ + super.key, + required this.terminal, + required this.cache, + }); + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: .ltr, + child: Align( + alignment: .topLeft, + child: SizedBox( + width: benchmarkSurfaceSize.width, + height: benchmarkSurfaceSize.height, + child: TerminalRenderer( + terminal: terminal, + theme: _theme, + metrics: _metrics, + offset: ViewportOffset.zero(), + renderObserver: const _FocusedRenderObserver(), + renderCache: cache, + ), + ), + ), + ); + } +} + +/// Keeps an already-populated atlas alive after its renderer is detached. +TerminalAtlasHandle retainBenchmarkAtlas(TerminalRenderCache cache) { + return cache.acquireAtlas(_atlasConfig); +} + +final class _FocusedRenderObserver implements TerminalRenderObserver { + const _FocusedRenderObserver(); + + @override + bool get hasFocus => true; + + @override + void addListener(VoidCallback listener) {} + + @override + void removeListener(VoidCallback listener) {} +} diff --git a/packages/flterm/tool/benchmarks/frame/result.dart b/packages/flterm/tool/benchmarks/frame/result.dart new file mode 100644 index 00000000..ffdc2652 --- /dev/null +++ b/packages/flterm/tool/benchmarks/frame/result.dart @@ -0,0 +1,123 @@ +import '../protocol.dart'; +import '../report/model.dart'; + +enum _FrameMetric { + /// Mean framework build duration. + uiAverage( + 'ui_average', + 'UI average', + 'average_frame_build_time_millis', + .milliseconds, + ), + + /// 90th-percentile framework build duration. + uiP90( + 'ui_p90', + 'UI p90', + '90th_percentile_frame_build_time_millis', + .milliseconds, + ), + + /// 99th-percentile framework build duration. + uiP99( + 'ui_p99', + 'UI p99', + '99th_percentile_frame_build_time_millis', + .milliseconds, + ), + + /// Longest framework build duration. + uiWorst( + 'ui_worst', + 'UI worst', + 'worst_frame_build_time_millis', + .milliseconds, + ), + + /// Framework builds exceeding Flutter's frame budget. + uiMissedBudget( + 'ui_missed_budget', + 'UI missed 16 ms budget', + 'missed_frame_build_budget_count', + .count, + ), + + /// Mean engine raster duration. + rasterAverage( + 'raster_average', + 'raster average', + 'average_frame_rasterizer_time_millis', + .milliseconds, + ), + + /// 90th-percentile engine raster duration. + rasterP90( + 'raster_p90', + 'raster p90', + '90th_percentile_frame_rasterizer_time_millis', + .milliseconds, + ), + + /// 99th-percentile engine raster duration. + rasterP99( + 'raster_p99', + 'raster p99', + '99th_percentile_frame_rasterizer_time_millis', + .milliseconds, + ), + + /// Longest engine raster duration. + rasterWorst( + 'raster_worst', + 'raster worst', + 'worst_frame_rasterizer_time_millis', + .milliseconds, + ), + + /// Raster passes exceeding Flutter's frame budget. + rasterMissedBudget( + 'raster_missed_budget', + 'raster missed 16 ms budget', + 'missed_frame_rasterizer_budget_count', + .count, + ); + + const _FrameMetric(this.id, this.label, this.source, this.unit); + + final String id; + final String label; + final String source; + final BenchmarkMetricUnit unit; +} + +/// Converts Flutter's frame summary into stable benchmark metrics. +BenchmarkWorkloadResult framePerformanceResult({ + required BenchmarkWorkload workload, + required Map summary, +}) { + if (workload.kind != .frame) { + throw ArgumentError.value(workload, 'workload', 'must be a frame workload'); + } + return BenchmarkWorkloadResult( + id: workload.id, + label: workload.label, + metrics: [ + for (final metric in _FrameMetric.values) + BenchmarkMetric( + id: metric.id, + label: metric.label, + value: _metric(summary, metric.source).toDouble(), + unit: metric.unit, + direction: .lowerIsBetter, + ), + ], + details: Map.unmodifiable(summary), + ); +} + +num _metric(Map summary, String name) { + return switch (summary[name]) { + final num value => value, + _ => throw FormatException('Frame summary metric "$name" is missing.'), + }; +} diff --git a/packages/flterm/tool/benchmarks/frame/suite.dart b/packages/flterm/tool/benchmarks/frame/suite.dart new file mode 100644 index 00000000..3540f921 --- /dev/null +++ b/packages/flterm/tool/benchmarks/frame/suite.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../fixture/terminal.dart'; +import '../input_benchmark.dart'; +import '../protocol.dart'; +import '../report/model.dart'; +import 'harness.dart'; + +/// Owns workload registration and result collection for one profile run. +/// +/// ```dart +/// FltermBenchmarkSuite(binding).register(); +/// ``` +final class FltermBenchmarkSuite { + final IntegrationTestWidgetsFlutterBinding _binding; + + const FltermBenchmarkSuite(this._binding); + + void register() { + group('flterm performance', () { + group('input throughput', () { + testWidgets('records public controller workloads', (tester) async { + final results = runControllerWriteBenchmarks(); + + _record(results); + + expect(results, hasLength(2)); + }); + }); + + group('frame rendering', () { + testWidgets('records clean frames', (tester) async { + final harness = await _harness(tester); + + final result = await harness.measureSteadyFrames( + workload: .cleanFrame, + ); + _record([result]); + + _expectCapturedFrames(result, benchmarkSteadyFrames); + }); + + testWidgets('records partial TUI updates', (tester) async { + final harness = await _harness(tester); + final updates = TerminalBenchmarkFixture.partialFrames( + count: benchmarkSteadyFrames, + ); + + final result = await harness.measureSteadyFrames( + workload: .partialTui, + updates: updates, + ); + _record([result]); + + _expectCapturedFrames(result, benchmarkSteadyFrames); + }); + + testWidgets('records full-screen output', (tester) async { + final harness = await _harness(tester); + final updates = TerminalBenchmarkFixture.fullFrames( + count: benchmarkSteadyFrames, + ); + + final result = await harness.measureSteadyFrames( + workload: .fullOutput, + updates: updates, + ); + _record([result]); + + _expectCapturedFrames(result, benchmarkSteadyFrames); + }); + + testWidgets('records glyph cache misses', (tester) async { + final harness = await _harness(tester); + + final result = await harness.measureGlyphMissFrames(); + _record([result]); + + _expectCapturedFrames(result, benchmarkGlyphMissFrames); + }); + + testWidgets('records first terminal frames', (tester) async { + final harness = await _harness(tester); + + final result = await harness.measureFirstTerminalFrames(); + _record([result]); + + expect( + result.details['frame_count'], + greaterThanOrEqualTo(benchmarkFirstFrameSamples), + ); + }); + }); + }); + } + + Future _harness(WidgetTester tester) async { + final harness = FrameBenchmarkHarness(_binding, tester); + await harness.initialize(); + return harness; + } + + void _expectCapturedFrames( + BenchmarkWorkloadResult result, + int scheduledFrames, + ) { + final minimum = (scheduledFrames * benchmarkMinimumFrameCaptureRatio) + .floor(); + expect(result.details['frame_count'], greaterThanOrEqualTo(minimum)); + } + + void _record(List results) { + final data = _binding.reportData ??= {}; + final existing = switch (data['workloads']) { + final List values => values, + null => [], + _ => throw const FormatException('Benchmark workloads must be a list.'), + }; + data['workloads'] = [ + ...existing, + for (final result in results) result.toJson(), + ]; + } +} diff --git a/packages/flterm/tool/benchmarks/input_benchmark.dart b/packages/flterm/tool/benchmarks/input_benchmark.dart new file mode 100644 index 00000000..410a47e2 --- /dev/null +++ b/packages/flterm/tool/benchmarks/input_benchmark.dart @@ -0,0 +1,205 @@ +import 'dart:math' show sqrt; +import 'dart:typed_data'; + +import 'package:flterm/flterm.dart' show TerminalConfig, TerminalController; + +import 'fixture/terminal.dart'; +import 'protocol.dart'; +import 'report/model.dart'; + +const _interactiveChunkSize = 4 * 1024; +const _streamingChunkSize = 128 * 1024; + +/// Measures fixed work after warmup until both sample minima are met. +BenchmarkWorkloadResult measureBenchmark({ + required BenchmarkWorkload workload, + required void Function() step, + void Function()? prepare, + int warmupIterations = 10, + int minimumSamples = 100, + Duration minimumDuration = const Duration(seconds: 5), + int? bytesPerIteration, + int Function()? clock, +}) { + if (workload.kind != .input) { + throw ArgumentError.value( + workload, + 'workload', + 'must be an input workload', + ); + } + if (warmupIterations < 0) { + throw RangeError.range(warmupIterations, 0, null, 'warmupIterations'); + } + if (minimumSamples <= 0) { + throw RangeError.range(minimumSamples, 1, null, 'minimumSamples'); + } + if (minimumDuration.isNegative) { + throw ArgumentError.value(minimumDuration, 'minimumDuration'); + } + if (bytesPerIteration != null && bytesPerIteration <= 0) { + throw RangeError.range(bytesPerIteration, 1, null, 'bytesPerIteration'); + } + for (var iteration = 0; iteration < warmupIterations; iteration++) { + prepare?.call(); + step(); + } + + final readClock = clock ?? _stopwatchClock(); + final samples = []; + var measuredMicroseconds = 0; + do { + prepare?.call(); + final start = readClock(); + step(); + final elapsed = readClock() - start; + if (elapsed <= 0) { + throw StateError( + 'Benchmark "${workload.id}" completed below the clock resolution.', + ); + } + samples.add(elapsed); + measuredMicroseconds += elapsed; + } while (samples.length < minimumSamples || + measuredMicroseconds < minimumDuration.inMicroseconds); + + return _workloadResult( + workload: workload, + samples: samples, + bytesPerIteration: bytesPerIteration, + ); +} + +/// Measures the public input boundary with realistic precomputed streams. +List runControllerWriteBenchmarks() { + return [ + _measureWrites( + workload: .streamingOutput, + chunks: TerminalBenchmarkFixture.chunks( + TerminalBenchmarkFixture.streamingOutput, + _streamingChunkSize, + ), + ), + _measureWrites( + workload: .interactiveTui, + chunks: TerminalBenchmarkFixture.chunks( + TerminalBenchmarkFixture.interactiveTuiOutput, + _interactiveChunkSize, + ), + ), + ]; +} + +BenchmarkWorkloadResult _measureWrites({ + required BenchmarkWorkload workload, + required List chunks, +}) { + final controller = TerminalController( + config: const TerminalConfig(scrollbackLimit: 0), + ); + try { + return measureBenchmark( + workload: workload, + step: () => _writeChunks(controller, chunks), + bytesPerIteration: TerminalBenchmarkFixture.corpusLength, + ); + } finally { + controller.dispose(); + } +} + +int Function() _stopwatchClock() { + final stopwatch = Stopwatch()..start(); + return () => stopwatch.elapsedMicroseconds; +} + +BenchmarkMetric _timingMetric(String id, num microseconds) { + return BenchmarkMetric( + id: id, + label: id, + value: microseconds / Duration.microsecondsPerMillisecond, + unit: .milliseconds, + direction: .lowerIsBetter, + ); +} + +BenchmarkWorkloadResult _workloadResult({ + required BenchmarkWorkload workload, + required List samples, + required int? bytesPerIteration, +}) { + final statistics = _SampleStatistics(samples); + final throughput = bytesPerIteration == null + ? null + : bytesPerIteration / + (1024 * 1024) / + (statistics.mean / Duration.microsecondsPerSecond); + return BenchmarkWorkloadResult( + id: workload.id, + label: workload.label, + metrics: [ + if (throughput != null) + BenchmarkMetric( + id: 'throughput', + label: 'throughput', + value: throughput, + unit: .mebibytesPerSecond, + direction: .higherIsBetter, + ), + _timingMetric('mean', statistics.mean), + _timingMetric('standard_deviation', statistics.standardDeviation), + _timingMetric('p50', statistics.p50), + _timingMetric('p95', statistics.p95), + _timingMetric('p99', statistics.p99), + ], + details: { + 'samples': samples.length, + 'samples_microseconds': List.unmodifiable(samples), + 'bytes_per_iteration': ?bytesPerIteration, + }, + ); +} + +void _writeChunks(TerminalController controller, List chunks) { + for (var index = 0; index < chunks.length; index++) { + controller.write(chunks[index]); + } +} + +final class _SampleStatistics { + final double mean; + final double standardDeviation; + final int p50; + final int p95; + final int p99; + + factory _SampleStatistics(List samples) { + final sorted = [...samples]..sort(); + final mean = + samples.fold(0, (sum, sample) => sum + sample) / samples.length; + final squaredDifferenceSum = samples.fold(0, (sum, sample) { + final difference = sample - mean; + return sum + difference * difference; + }); + + int percentile(double value) => sorted[(sorted.length * value).ceil() - 1]; + + return _SampleStatistics._( + mean: mean, + standardDeviation: samples.length == 1 + ? 0 + : sqrt(squaredDifferenceSum / (samples.length - 1)), + p50: percentile(0.50), + p95: percentile(0.95), + p99: percentile(0.99), + ); + } + + const _SampleStatistics._({ + required this.mean, + required this.standardDeviation, + required this.p50, + required this.p95, + required this.p99, + }); +} diff --git a/packages/flterm/tool/benchmarks/protocol.dart b/packages/flterm/tool/benchmarks/protocol.dart new file mode 100644 index 00000000..fcd72653 --- /dev/null +++ b/packages/flterm/tool/benchmarks/protocol.dart @@ -0,0 +1,67 @@ +const benchmarkProtocolVersion = 2; +const benchmarkColumns = 120; +const benchmarkRows = 80; +const benchmarkSteadyFrames = 300; +const benchmarkGlyphMissFrames = 120; +const benchmarkFirstFrameSamples = 30; +const benchmarkFirstFrameMounts = 36; +const benchmarkMinimumFrameCaptureRatio = 0.95; + +/// The user-facing performance boundary exercised by a workload. +enum BenchmarkWorkloadKind { + /// Terminal input processing measured outside Flutter frame timing. + input, + + /// Flutter UI and raster work measured from engine frame timings. + frame, +} + +/// Stable workload identities shared by measurement and reporting. +enum BenchmarkWorkload { + /// Batched command output written in 128 KiB chunks. + streamingOutput( + 'input.streaming_output', + 'streaming output, 128 KiB chunks', + .input, + ), + + /// Escape-heavy interactive output written in 4 KiB chunks. + interactiveTui( + 'input.interactive_tui', + 'interactive TUI, 4 KiB chunks', + .input, + ), + + /// An unchanged terminal rendered repeatedly. + cleanFrame('render.clean', 'clean frame', .frame), + + /// An editor-like update affecting three terminal rows. + partialTui('render.partial_tui', 'partial TUI update', .frame), + + /// Styled output replacing every visible terminal row. + fullOutput('render.full_output', 'full-screen output', .frame), + + /// Frames introducing glyphs not already present in the atlas. + glyphMisses('render.glyph_misses', 'new glyphs', .frame), + + /// The first presentation of a fresh terminal renderer. + firstTerminalFrame( + 'render.first_terminal_frame', + 'first terminal frame', + .frame, + ); + + const BenchmarkWorkload(this.id, this.label, this.kind); + + final String id; + final String label; + final BenchmarkWorkloadKind kind; + + static final _byId = {for (final workload in values) workload.id: workload}; + + /// Resolves the workload whose stable protocol identity is [id]. + static BenchmarkWorkload fromId(String id) { + return _byId[id] ?? + (throw FormatException('Unknown benchmark workload "$id".')); + } +} diff --git a/packages/flterm/tool/benchmarks/report/markdown.dart b/packages/flterm/tool/benchmarks/report/markdown.dart new file mode 100644 index 00000000..3ecfa132 --- /dev/null +++ b/packages/flterm/tool/benchmarks/report/markdown.dart @@ -0,0 +1,134 @@ +import '../protocol.dart'; +import 'model.dart'; + +enum _FramePhase { + /// Framework build work recorded on Flutter's UI thread. + ui('UI', 'ui'), + + /// Engine raster work recorded after the UI phase. + raster('raster', 'raster'); + + const _FramePhase(this.label, this.metricPrefix); + + final String label; + final String metricPrefix; +} + +String formatBenchmarkReport(BenchmarkReport report) { + final environment = report.environment; + final input = _workloads(report, .input); + final frames = _workloads(report, .frame); + final platform = [ + 'Platform: ${environment.operatingSystem}', + environment.operatingSystemVersion, + '(${environment.architecture}) ', + ].join(' '); + final runner = environment.runnerImage; + final frameHeader = [ + '| workload | phase | average | p90 | p99 | worst |', + 'missed >16 ms | frames |', + ].join(' '); + final directionNote = [ + 'Throughput is higher-is-better.', + 'Frame timings and missed budgets are lower-is-better.', + 'Missed budgets are shown as count over captured frames and rate.', + ].join(' '); + + return [ + '# flterm benchmark', + '', + 'Revision: `${report.revision}` ', + platform, + if (runner != null) 'Runner: $runner ', + 'Flutter: ${environment.flutterVersion} ', + 'Dart: ${environment.dartVersion} ', + 'Mode: profile ', + 'Terminal: $benchmarkColumns×$benchmarkRows at device pixel ratio 1 ', + 'Protocol: ${report.protocolVersion} ', + 'Fixture: `${report.fixtureDigest}`', + '', + '## Input throughput', + '', + '| workload | throughput | mean ± std dev | p50 | p95 | p99 | samples |', + '|---|---:|---:|---:|---:|---:|---:|', + for (final workload in input) _inputRow(workload), + '', + '## Frame performance', + '', + frameHeader, + '|---|---|---:|---:|---:|---:|---:|---:|', + for (final workload in frames) ...[ + for (final phase in _FramePhase.values) _frameRow(workload, phase), + ], + '', + directionNote, + ].join('\n'); +} + +String _inputRow(BenchmarkWorkloadResult workload) { + final mean = _metricValue(workload, 'mean'); + final deviation = _metricValue(workload, 'standard_deviation'); + final values = [ + workload.label, + _metricValue(workload, 'throughput'), + '$mean ± $deviation', + _metricValue(workload, 'p50'), + _metricValue(workload, 'p95'), + _metricValue(workload, 'p99'), + '${workload.detailCount('samples')}', + ]; + return '| ${values.join(' | ')} |'; +} + +String _frameRow(BenchmarkWorkloadResult workload, _FramePhase phase) { + final prefix = phase.metricPrefix; + final values = [ + workload.label, + phase.label, + _metricValue(workload, '${prefix}_average'), + _metricValue(workload, '${prefix}_p90'), + _metricValue(workload, '${prefix}_p99'), + _metricValue(workload, '${prefix}_worst'), + _missedFrames(workload, '${prefix}_missed_budget'), + '${workload.detailCount('frame_count')}', + ]; + return '| ${values.join(' | ')} |'; +} + +String _metricValue(BenchmarkWorkloadResult workload, String id) { + return _value(workload.metric(id)); +} + +String _value(BenchmarkMetric metric) { + final digits = metric.unit == .count ? 0 : 2; + final suffix = metric.unit.displayName; + final value = metric.value.toStringAsFixed(digits); + return suffix.isEmpty ? value : '$value $suffix'; +} + +String _missedFrames(BenchmarkWorkloadResult workload, String metricId) { + final frames = workload.detailCount('frame_count'); + if (frames == 0) { + throw FormatException( + 'Benchmark workload "${workload.id}" contains no frame timings.', + ); + } + final missed = workload.metric(metricId).value; + if (missed != missed.roundToDouble()) { + throw FormatException( + 'Benchmark workload "${workload.id}" has a fractional missed count.', + ); + } + final count = missed.toInt(); + final rate = count / frames * 100; + return '$count/$frames (${rate.toStringAsFixed(1)}%)'; +} + +Iterable _workloads( + BenchmarkReport report, + BenchmarkWorkloadKind kind, +) { + return report.workloads.where( + (result) => BenchmarkWorkload.fromId(result.id).kind == kind, + ); +} diff --git a/packages/flterm/tool/benchmarks/report/model.dart b/packages/flterm/tool/benchmarks/report/model.dart new file mode 100644 index 00000000..9d0ac303 --- /dev/null +++ b/packages/flterm/tool/benchmarks/report/model.dart @@ -0,0 +1,228 @@ +const benchmarkReportSchemaVersion = 1; + +/// Runtime properties recorded with a benchmark result. +final class BenchmarkEnvironment { + final String operatingSystem; + final String operatingSystemVersion; + final String architecture; + final String dartVersion; + final String flutterVersion; + final String? runnerImage; + + const BenchmarkEnvironment({ + required this.operatingSystem, + required this.operatingSystemVersion, + required this.architecture, + required this.dartVersion, + required this.flutterVersion, + this.runnerImage, + }); + + Map toJson() => { + 'operating_system': operatingSystem, + 'operating_system_version': operatingSystemVersion, + 'architecture': architecture, + 'dart_version': dartVersion, + 'flutter_version': flutterVersion, + ...switch (runnerImage) { + final value? => {'runner_image': value}, + null => const {}, + }, + }; +} + +/// One measurement produced by a benchmark workload. +final class BenchmarkMetric { + final String id; + final String label; + final double value; + final BenchmarkMetricUnit unit; + final BenchmarkMetricDirection direction; + + BenchmarkMetric({ + required String id, + required String label, + required double value, + required this.unit, + required this.direction, + }) : id = _requiredText(id, 'metric id'), + label = _requiredText(label, 'metric label'), + value = _measurement(value); + + factory BenchmarkMetric.fromJson(Map json) { + return BenchmarkMetric( + id: json['id']! as String, + label: json['label']! as String, + value: (json['value']! as num).toDouble(), + unit: switch (json['unit']! as String) { + 'milliseconds' => .milliseconds, + 'mebibytes_per_second' => .mebibytesPerSecond, + 'count' => .count, + final value => throw FormatException( + 'Unknown benchmark metric unit "$value".', + ), + }, + direction: switch (json['direction']! as String) { + 'higher_is_better' => .higherIsBetter, + 'lower_is_better' => .lowerIsBetter, + final value => throw FormatException( + 'Unknown benchmark metric direction "$value".', + ), + }, + ); + } + + Map toJson() => { + 'id': id, + 'label': label, + 'value': value, + 'unit': unit.jsonName, + 'direction': direction.jsonName, + }; +} + +enum BenchmarkMetricDirection { + higherIsBetter('higher_is_better'), + lowerIsBetter('lower_is_better'); + + const BenchmarkMetricDirection(this.jsonName); + + final String jsonName; +} + +enum BenchmarkMetricUnit { + milliseconds('milliseconds', 'ms'), + mebibytesPerSecond('mebibytes_per_second', 'MiB/s'), + count('count', ''); + + const BenchmarkMetricUnit(this.jsonName, this.displayName); + + final String jsonName; + final String displayName; +} + +/// Versioned output from one benchmark run. +final class BenchmarkReport { + final int schemaVersion = benchmarkReportSchemaVersion; + final int protocolVersion; + final String fixtureDigest; + final String revision; + final BenchmarkEnvironment environment; + final List workloads; + + BenchmarkReport({ + required this.protocolVersion, + required this.fixtureDigest, + required this.revision, + required this.environment, + required List workloads, + }) : workloads = _uniqueWorkloads(workloads); + + Map toJson() => { + 'schema_version': schemaVersion, + 'protocol_version': protocolVersion, + 'fixture_digest': fixtureDigest, + 'revision': revision, + 'environment': environment.toJson(), + 'workloads': [for (final workload in workloads) workload.toJson()], + }; +} + +/// Results and diagnostic details for one stable workload identity. +final class BenchmarkWorkloadResult { + final String id; + final String label; + final List metrics; + final Map details; + + BenchmarkWorkloadResult({ + required String id, + required String label, + required List metrics, + Map details = const {}, + }) : id = _requiredText(id, 'workload id'), + label = _requiredText(label, 'workload label'), + metrics = _uniqueMetrics(metrics), + details = Map.unmodifiable(details); + + factory BenchmarkWorkloadResult.fromJson(Map json) { + return BenchmarkWorkloadResult( + id: json['id']! as String, + label: json['label']! as String, + metrics: [ + for (final value in json['metrics']! as List) + BenchmarkMetric.fromJson(value! as Map), + ], + details: json['details']! as Map, + ); + } + + /// Returns the required non-negative integer detail identified by [id]. + int detailCount(String id) { + return switch (details[id]) { + final int value when value >= 0 => value, + _ => throw FormatException( + 'Benchmark workload "${this.id}" is missing count detail "$id".', + ), + }; + } + + /// Returns the required metric identified by [id]. + BenchmarkMetric metric(String id) { + for (final metric in metrics) { + if (metric.id == id) return metric; + } + throw FormatException( + 'Benchmark workload "${this.id}" is missing metric "$id".', + ); + } + + Map toJson() => { + 'id': id, + 'label': label, + 'metrics': [for (final metric in metrics) metric.toJson()], + 'details': details, + }; +} + +double _measurement(double value) { + if (!value.isFinite || value < 0) { + throw const FormatException( + 'Benchmark metric "value" must be finite and non-negative.', + ); + } + return value; +} + +String _requiredText(String value, String name) { + if (value.trim().isEmpty) { + throw FormatException('Benchmark $name must not be empty.'); + } + return value; +} + +List _uniqueMetrics(List metrics) { + final ids = {}; + for (final metric in metrics) { + if (!ids.add(metric.id)) { + throw FormatException( + 'Benchmark workload contains duplicate metric id "${metric.id}".', + ); + } + } + return List.unmodifiable(metrics); +} + +List _uniqueWorkloads( + List workloads, +) { + final ids = {}; + for (final workload in workloads) { + if (!ids.add(workload.id)) { + throw FormatException( + 'Benchmark report contains duplicate workload id "${workload.id}".', + ); + } + } + return List.unmodifiable(workloads); +} diff --git a/packages/flterm/tool/benchmarks/run.dart b/packages/flterm/tool/benchmarks/run.dart new file mode 100644 index 00000000..bcd03fe8 --- /dev/null +++ b/packages/flterm/tool/benchmarks/run.dart @@ -0,0 +1,316 @@ +import 'dart:convert'; +import 'dart:ffi' show Abi; +import 'dart:io'; + +import 'fixture/terminal.dart'; +import 'protocol.dart'; +import 'report/markdown.dart'; +import 'report/model.dart'; + +const _usageExitCode = 64; + +/// Flutter drive arguments for the profile benchmark. +const benchmarkDriveArguments = [ + 'drive', + '--no-dds', + '--driver', + 'test_driver/flterm_benchmark_driver.dart', + '--target', + 'integration_test/flterm_benchmark.dart', + '--profile', + '--dart-define=INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE=false', + '-d', + 'macos', +]; + +Future main(List arguments) async { + if (!Platform.isMacOS) { + stderr.writeln( + 'The flterm example currently provides only a macOS desktop runner.', + ); + exitCode = _usageExitCode; + return; + } + + late final ({Directory? outputDirectory, String revision}) options; + try { + options = _parseOptions(arguments); + } on FormatException catch (error) { + stderr.writeln(error.message); + exitCode = _usageExitCode; + return; + } + + try { + await _BenchmarkRun(options).execute(); + } on ProcessException catch (error) { + stderr.writeln(error); + exitCode = error.errorCode; + } on FileSystemException catch (error) { + stderr.writeln(error.message); + exitCode = 1; + } on FormatException catch (error) { + stderr.writeln(error.message); + exitCode = 1; + } +} + +final class _BenchmarkRun { + final String revision; + final Directory exampleDirectory; + final BenchmarkArtifacts artifacts; + + factory _BenchmarkRun( + ({Directory? outputDirectory, String revision}) options, + ) { + final packageDirectory = File.fromUri(Platform.script).parent.parent.parent; + final exampleDirectory = Directory.fromUri( + packageDirectory.uri.resolve('example/'), + ); + return _BenchmarkRun._( + revision: options.revision, + exampleDirectory: exampleDirectory, + artifacts: BenchmarkArtifacts( + options.outputDirectory ?? + Directory.fromUri( + exampleDirectory.uri.resolve('build/benchmark-results/'), + ), + ), + ); + } + + const _BenchmarkRun._({ + required this.revision, + required this.exampleDirectory, + required this.artifacts, + }); + + Future execute() async { + artifacts.prepare(); + final rawFlutter = File.fromUri( + exampleDirectory.uri.resolve('build/flterm_benchmarks.json'), + ); + if (rawFlutter.existsSync()) rawFlutter.deleteSync(); + + final flutterVersion = await _flutterVersion(); + final processExitCode = await _runFlutter(); + if (processExitCode != 0) { + exitCode = processExitCode; + return; + } + + final report = benchmarkReportFromFlutterResponse( + _readJsonObject(rawFlutter), + revision: revision, + environment: _environment(flutterVersion), + ); + final markdown = formatBenchmarkReport(report); + artifacts.writeResults( + report: report, + markdown: markdown, + rawFlutter: rawFlutter, + ); + stdout.writeln(markdown); + } + + Future _flutterVersion() async { + final result = await Process.run('flutter', const [ + '--version', + '--machine', + ]); + if (result.exitCode != 0) { + throw ProcessException( + 'flutter', + const ['--version', '--machine'], + result.stderr as String, + result.exitCode, + ); + } + final Object? json = jsonDecode(result.stdout as String); + final data = _jsonObject(json, 'Flutter version'); + return switch (data['frameworkVersion']) { + final String value when value.isNotEmpty => value, + _ => throw const FormatException( + 'Flutter version output contains no framework version.', + ), + }; + } + + Future _runFlutter() async { + final process = await Process.start( + 'flutter', + benchmarkDriveArguments, + workingDirectory: exampleDirectory.path, + ); + final output = _pipe(process.stdout, artifacts.stdoutLog, stdout); + final errors = _pipe(process.stderr, artifacts.stderrLog, stderr); + final (processExitCode, _, _) = await ( + process.exitCode, + output, + errors, + ).wait; + return processExitCode; + } + + Future _pipe( + Stream> input, + File logFile, + IOSink console, + ) async { + final log = logFile.openWrite(); + try { + await for (final text in input.transform(utf8.decoder)) { + log.write(text); + console.write(text); + } + } finally { + await log.close(); + } + } + + BenchmarkEnvironment _environment(String flutterVersion) { + final runnerImage = [ + ?Platform.environment['ImageOS'], + ?Platform.environment['ImageVersion'], + ].join(' '); + return BenchmarkEnvironment( + operatingSystem: Platform.operatingSystem, + operatingSystemVersion: Platform.operatingSystemVersion, + architecture: Abi.current().toString(), + dartVersion: Platform.version.split(' ').first, + flutterVersion: flutterVersion, + runnerImage: runnerImage.isEmpty ? null : runnerImage, + ); + } +} + +/// Validates Flutter's response and attaches host-side run metadata. +BenchmarkReport benchmarkReportFromFlutterResponse( + Map response, { + required String revision, + required BenchmarkEnvironment environment, +}) { + final workloadData = switch (response['workloads']) { + final List values => values, + _ => throw const FormatException( + 'Flutter benchmark response contains no workloads.', + ), + }; + final fontDigest = switch (response['font_digest']) { + final String value when value.isNotEmpty => value, + _ => throw const FormatException( + 'Flutter benchmark response contains no font digest.', + ), + }; + final report = BenchmarkReport( + protocolVersion: benchmarkProtocolVersion, + fixtureDigest: TerminalBenchmarkFixture.benchmarkDigest(fontDigest), + revision: revision, + environment: environment, + workloads: [ + for (final value in workloadData) + BenchmarkWorkloadResult.fromJson(_jsonObject(value, 'workload')), + ], + ); + final expected = { + for (final workload in BenchmarkWorkload.values) workload.id, + }; + final actual = {for (final workload in report.workloads) workload.id}; + if (report.workloads.length != expected.length || + !actual.containsAll(expected)) { + throw const FormatException( + 'Flutter benchmark response must contain every protocol workload.', + ); + } + return report; +} + +Map _readJsonObject(File file) { + if (!file.existsSync()) { + throw FormatException('Benchmark response file is missing: ${file.path}'); + } + final Object? json = jsonDecode(file.readAsStringSync()); + return _jsonObject(json, 'benchmark response'); +} + +Map _jsonObject(Object? value, String name) { + return switch (value) { + final Map value => Map.from(value), + _ => throw FormatException('$name must be a JSON object.'), + }; +} + +/// Owns the files produced by one host-side benchmark run. +/// +/// [prepare] removes only known benchmark artifacts, leaving other entries in +/// the output directory intact. +/// +/// ```dart +/// final artifacts = BenchmarkArtifacts(Directory('/tmp/flterm-results')); +/// artifacts.prepare(); +/// ``` +final class BenchmarkArtifacts { + static const _names = [ + 'results.json', + 'report.md', + 'raw-flutter.json', + 'flutter.stdout.log', + 'flutter.stderr.log', + ]; + + final Directory directory; + + const BenchmarkArtifacts(this.directory); + + File get stdoutLog => _file('flutter.stdout.log'); + File get stderrLog => _file('flutter.stderr.log'); + + void prepare() { + directory.createSync(recursive: true); + for (final name in _names) { + final artifact = _file(name); + if (artifact.existsSync()) artifact.deleteSync(); + } + } + + void writeResults({ + required BenchmarkReport report, + required String markdown, + required File rawFlutter, + }) { + _writeText( + 'results.json', + const JsonEncoder.withIndent(' ').convert(report.toJson()), + ); + _writeText('report.md', markdown); + rawFlutter.copySync(_file('raw-flutter.json').path); + } + + File _file(String name) => File.fromUri(directory.uri.resolve(name)); + + void _writeText(String name, String contents) { + _file(name).writeAsStringSync('$contents\n'); + } +} + +({Directory? outputDirectory, String revision}) _parseOptions( + List arguments, +) { + Directory? output; + var revision = 'working-tree'; + for (var index = 0; index < arguments.length; index += 2) { + if (index + 1 >= arguments.length) { + throw FormatException('Missing value for ${arguments[index]}.'); + } + final value = arguments[index + 1]; + switch (arguments[index]) { + case '--output': + output = Directory(value); + case '--revision': + revision = value; + default: + throw FormatException('Unknown option ${arguments[index]}.'); + } + } + return (outputDirectory: output, revision: revision); +} From 3db7554c7a0758a9edafb21fafadad69cb012fe3 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Tue, 28 Jul 2026 15:17:38 +0300 Subject: [PATCH 04/26] chore(libghostty): prepare v0.0.12 release --- packages/libghostty/CHANGELOG.md | 43 ++++++++++++++++++++++++++++++-- packages/libghostty/README.md | 2 +- packages/libghostty/pubspec.yaml | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index b0a23192..35c9b5f2 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.0.12 ### Breaking @@ -11,14 +11,53 @@ ### Added +- **Clipboard writes**: `Terminal.onClipboardWrite` exposes atomic, + binary-safe OSC 52 and iTerm2 clipboard requests with normalized locations + and MIME representations. Clipboard reads remain disabled. +- **Scrollback compression**: `Terminal.compress()` and + `Terminal.compressionActivity` support incremental or full compression of + eligible scrollback storage and report unsupported targets. +- **Terminal geometry**: `Terminal.geometry` returns cell and pixel dimensions + in one query. - **VT processing diagnostics**: `Terminal.hasVtProcessingError` reports - non-gracefully handled terminal-owned semantic update failures. + errors during terminal-owned semantic updates that cannot be handled + gracefully. +- **Kitty image usage hints**: transient images are prioritized for eviction + under memory pressure. ### Changed +- **Resize side effects**: `Terminal.resize()` updates cell pixel geometry, + disables synchronized output, and emits an in-band size report when mode + 2048 is enabled. +- **Batched state queries**: `RenderState`, `RowIterator`, `CellIterator`, + `SelectionGesture`, and Kitty graphics reads use consolidated native and WASM + queries to reduce binding overhead. +- **Formatter allocations**: `Formatter` and `Terminal.formatSelection()` reuse + growable buffers instead of allocating a native result for each call. +- **Kitty graphics processing**: large APC payloads are dispatched in bulk, + reducing VT processing overhead for image transfers. - **Terminal callback errors**: `Terminal.write()` and callback-emitting `Terminal.resize()` rethrow the first effect error after processing completes. +### Fixed + +- **Resize failure consistency**: failed resizes preserve screen dimensions, + tab stops, pixel geometry, and synchronized-output state. +- **Selection and grid boundaries**: selection gestures, cloned selections, + and grid references clamp or reject invalid coordinates across mixed-width + pages, row boundaries, and extreme input values instead of failing at + runtime. +- **Page traversal and references**: count-limited traversal crosses page + boundaries correctly, and row shifts, splits, erasures, and replacements + invalidate stale page references. +- **Terminal boundary inputs**: empty cell and tab-stop ranges, minimum scroll + deltas, oversized cursor positions, and non-monotonic selection timestamps + are handled without runtime safety failures. +- **Source builds in tagged repositories**: compiling Ghostty from source no + longer reads a consuming application's Git tag, preventing tagged builds + with non-Ghostty version formats from failing. + ## 0.0.11 ### Added diff --git a/packages/libghostty/README.md b/packages/libghostty/README.md index 96c869ff..95b36ddc 100644 --- a/packages/libghostty/README.md +++ b/packages/libghostty/README.md @@ -16,7 +16,7 @@ the terminal emulator library from [Ghostty](https://ghostty.org). ```yaml # pubspec.yaml dependencies: - libghostty: ^0.0.11 + libghostty: ^0.0.12 ``` On web, initialize the WASM module once before using any bindings: diff --git a/packages/libghostty/pubspec.yaml b/packages/libghostty/pubspec.yaml index 3a3d519b..f32fde41 100644 --- a/packages/libghostty/pubspec.yaml +++ b/packages/libghostty/pubspec.yaml @@ -1,6 +1,6 @@ name: libghostty description: Dart bindings to libghostty-vt, the terminal emulator library from Ghostty. -version: 0.0.11 +version: 0.0.12 repository: https://github.com/elias8/libghostty/tree/main/packages/libghostty resolution: workspace From de2e794dcb63ed61955f695330f46e90d1000857 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Tue, 28 Jul 2026 16:13:09 +0300 Subject: [PATCH 05/26] chore: update asset hashes for v0.0.12 --- .../libghostty/lib/src/hook/asset_hashes.dart | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/libghostty/lib/src/hook/asset_hashes.dart b/packages/libghostty/lib/src/hook/asset_hashes.dart index 13ff748a..1481d960 100644 --- a/packages/libghostty/lib/src/hook/asset_hashes.dart +++ b/packages/libghostty/lib/src/hook/asset_hashes.dart @@ -9,22 +9,22 @@ // ignore_for_file: unnecessary_ignore, lines_longer_than_80_chars /// The release tag used for downloading prebuilt binaries. -const releaseTag = 'libghostty-v0.0.11'; +const releaseTag = 'libghostty-v0.0.12'; /// SHA256 hashes for pre-built native binaries. Keys are the artifact /// filenames. const assetHashes = { - 'libghostty-aarch64-ios-simulator.dylib': 'a48d21d2a7ff0649276621d4d4aa6a433d73175d80dd59a3036758ed026e9acf', - 'libghostty-aarch64-ios.dylib': '567fc3dce3916d84416ee58efef824e6b702f65ca3ce6c84bfde7f8c07fcc080', - 'libghostty-aarch64-linux-android.so': '867bfec3b1e19836e00c686d3c1204e49be20c3fd93ecb38f33725a9d39b28c3', - 'libghostty-aarch64-linux-gnu.so': 'b73e1e5e6ed4d60278a60aef5e0b07877f1902febc36df57cb5e85dffae150b0', - 'libghostty-aarch64-macos.dylib': '2dabdc1f968394f5edeece23988828ea3f18374907ed0eabf3228f12a6de60cc', - 'libghostty-arm-linux-androideabi.so': 'eb9f93b799dbe5251c637293c2421617d62984c91c28a7c145bee6213f7ba606', - 'libghostty-wasm32-freestanding.wasm': 'b0f39cfe981af36745c6b9c6919e9ac9cdf20aa507764e78bfb7a63ddc945c5b', - 'libghostty-x86_64-ios-simulator.dylib': 'fff7f93a4e25fb7fe869b13bd8143e8fd947dae8ace5e5b3cf35b30441ce4239', - 'libghostty-x86_64-linux-android.so': 'f08e56ce87b1da496e80488c6b54ec242185077e00c6f8721c9eba8c89fa859e', - 'libghostty-x86_64-linux-gnu.so': '71232ea0852b1f7d6772156978f2295fda3db5d0b8ee96769cc26e17d0665800', - 'libghostty-x86_64-linux-musl.so': 'a094cfad5953fe5cf5ac687ba2d4a3c4f5b5fcbe5d52a8db955e9aedda078035', - 'libghostty-x86_64-macos.dylib': 'e06ce6a1a020d86ac29904aee7dff21fb48cc1482f923093e8547e98e9393b9c', - 'libghostty-x86_64-windows.dll': '4d262e0931d1b1ac44d2bcf290003b540adf7a6542dd4d3d01605204e185d600', + 'libghostty-aarch64-ios-simulator.dylib': '4e9188f56c6738f813c1aa289f6e38291332797ba21cac189305b1b7b3434602', + 'libghostty-aarch64-ios.dylib': '3e5a5560bf5e6899f5c7e339da357a3a48ba6cd821f42ed9ac72a158d2a1c93d', + 'libghostty-aarch64-linux-android.so': 'e47f9b2a9410bfecd51a6e56d2974eb2ed4ba21a5b41078791a391a66e667a50', + 'libghostty-aarch64-linux-gnu.so': '4077bb9c47130278fc08d00e2058fc5a2851da1b4bf34c0d2bd5054c5b900aaf', + 'libghostty-aarch64-macos.dylib': 'a148591a7853cbbc2a491c54b35dbe7ae1ee71692bcf2535e543c0108b84eaa5', + 'libghostty-arm-linux-androideabi.so': 'b21d85003be4714368abda156388d59dc60ca604b611122a5d95c8dc86a8f126', + 'libghostty-wasm32-freestanding.wasm': 'f6233fc8f4d723451660504959cac5eff80851eabc63578b0e218ce3669d1b8b', + 'libghostty-x86_64-ios-simulator.dylib': 'f1a0f53f23f71d879b07f54664bf194c276a4349ba4fdc8ef6b7058e08e454fd', + 'libghostty-x86_64-linux-android.so': '373e1ff319c34fb9197baf0314be7b935a48e0d73f31733695b5dba87bd82662', + 'libghostty-x86_64-linux-gnu.so': '5f169d9dba40edf996e4565db3eed5a996ba25c7b3a20df918b0b63164d7e068', + 'libghostty-x86_64-linux-musl.so': 'e78ce07d19b4aeade3b1e5b4b7f9778da6a1248b1b181cd2958aca7f1ec97e0e', + 'libghostty-x86_64-macos.dylib': 'a39459ad9f5ff8f5ec44d96e70158db7171db9445b84bbda2963d78e16df3fac', + 'libghostty-x86_64-windows.dll': 'c3f2073c387539fcfcb5c2e9f0bef6f28b21c35a60a152a424f0cb4836ed90fc', }; From 492d38085b2c7af1d46d5e524afd2adce675960f Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Tue, 28 Jul 2026 16:40:40 +0300 Subject: [PATCH 06/26] chore(flterm): prepare v0.0.5 release --- packages/flterm/CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ packages/flterm/README.md | 2 +- packages/flterm/pubspec.yaml | 4 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index d07fffbb..e882918c 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.0.5 + +### Added + +- **Clipboard writes**: `TerminalController.onClipboardWrite` forwards atomic, + binary-safe OSC 52 and iTerm2 clipboard requests so applications can apply + their own platform and security policies. If the callback throws, the + initiating write finishes terminal processing before rethrowing the error. +- **Idle scrollback compression**: attached terminal views schedule bounded + compression after terminal and viewport activity settles, reducing retained + scrollback memory without competing with rendering. + +### Changed + +- **Kitty graphics caching**: unchanged placement snapshots are reused across + frames, while image generations invalidate cached images and stale pending + decodes. This avoids repeated traversal, sorting, and eviction work without + displaying stale image data. +- **Color behavior**: `ColorPalette.generated()` uses libghostty palette + generation, and terminal color-scheme reports use perceived background + luminance. +- **Published package contents**: tests, benchmark tooling, generated API + documentation, build outputs, and coverage data are excluded. + +### Fixed + +- **IME preedit layout**: ZWJ emoji, skin-tone emoji sequences, combining + marks, and variation selectors are measured as grapheme clusters using + libghostty's terminal width rules. +- **Viewport scrolling**: scroll-controller pixel offsets map to absolute + terminal rows regardless of the current viewport position. +- **Windows text input**: platform text input binds to the `TerminalView`'s + owning Flutter view and reconnects when that view changes. + ## 0.0.4 ### Breaking diff --git a/packages/flterm/README.md b/packages/flterm/README.md index 7b1d1671..3c94ec4e 100644 --- a/packages/flterm/README.md +++ b/packages/flterm/README.md @@ -37,7 +37,7 @@ libghostty-vt engine. ```yaml dependencies: - flterm: ^0.0.4 + flterm: ^0.0.5 ``` On web, initialize the wasm module once before mounting any terminal: diff --git a/packages/flterm/pubspec.yaml b/packages/flterm/pubspec.yaml index 23dcb90d..cffa7415 100644 --- a/packages/flterm/pubspec.yaml +++ b/packages/flterm/pubspec.yaml @@ -1,6 +1,6 @@ name: flterm description: Flutter terminal widget on top of Ghostty's libghostty-vt engine. -version: 0.0.4 +version: 0.0.5 homepage: https://github.com/elias8/libghostty repository: https://github.com/elias8/libghostty/tree/main/packages/flterm issue_tracker: https://github.com/elias8/libghostty/issues @@ -30,7 +30,7 @@ dependencies: flutter: sdk: flutter image: ^4.8.0 - libghostty: ^0.0.11 + libghostty: ^0.0.12 meta: ^1.18.0 dev_dependencies: From c99cfd2ffbe1808a75e3ba32b06b964a4bdf34c0 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Wed, 29 Jul 2026 11:43:38 +0300 Subject: [PATCH 07/26] fix(flterm): recover orphaned text input connection --- packages/flterm/CHANGELOG.md | 8 +++++ .../src/widgets/terminal_input_client.dart | 23 ++++++++++--- .../widgets/terminal_input_client_test.dart | 34 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index e882918c..8e72e18d 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixed + +- **Text input recovery**: terminal clients reconnect when another input client + takes the platform text input connection, including while composition is + active. + ## 0.0.5 ### Added diff --git a/packages/flterm/lib/src/widgets/terminal_input_client.dart b/packages/flterm/lib/src/widgets/terminal_input_client.dart index 3247a93d..275f9448 100644 --- a/packages/flterm/lib/src/widgets/terminal_input_client.dart +++ b/packages/flterm/lib/src/widgets/terminal_input_client.dart @@ -44,12 +44,20 @@ final class TerminalInputClient with DeltaTextInputClient { /// preedit text should be rendered. bool get hasActiveComposition => _value.hasTerminalComposingRange; - bool get isAttached => _connection != null; + /// Whether this client owns the platform text input connection. + /// + /// Text input accepts one client at a time. Another client can replace this + /// connection without invoking [connectionClosed], so non-nullness alone + /// does not prove ownership. + bool get isAttached => _connection?.attached ?? false; set keyboardAppearance(Brightness value) { if (_keyboardAppearance == value) return; _keyboardAppearance = value; - _connection?.updateConfig(_configuration); + final connection = _connection; + if (connection != null && connection.attached) { + connection.updateConfig(_configuration); + } } set onDelete(ValueChanged? callback) => _onDelete = callback; @@ -125,6 +133,10 @@ final class TerminalInputClient with DeltaTextInputClient { _keyboardAppearance = keyboardAppearance; final connection = _connection; if (connection == null) return _openConnection(); + if (!connection.attached) { + _closeConnection(); + return _openConnection(); + } connection.updateConfig(_configuration); } @@ -209,7 +221,7 @@ final class TerminalInputClient with DeltaTextInputClient { required Rect composingRect, }) { final connection = _connection; - if (connection == null) return; + if (connection == null || !connection.attached) return; connection ..setEditableSizeAndTransform(editableSize, transform) ..setCaretRect(caretRect) @@ -359,7 +371,10 @@ final class TerminalInputClient with DeltaTextInputClient { void _resetBuffer() { _value = _sentinel; - _connection?.setEditingState(_value); + final connection = _connection; + if (connection != null && connection.attached) { + connection.setEditingState(_value); + } } void _resetInputState() { diff --git a/packages/flterm/test/widgets/terminal_input_client_test.dart b/packages/flterm/test/widgets/terminal_input_client_test.dart index 0eec6898..d6d5d370 100644 --- a/packages/flterm/test/widgets/terminal_input_client_test.dart +++ b/packages/flterm/test/widgets/terminal_input_client_test.dart @@ -810,6 +810,40 @@ void main() { expect(textInputSetClientCalls(calls), hasLength(1)); }); + + test('reopens a connection orphaned by another client', () { + final calls = recordTextInputCalls(); + handler.attach(); + final other = TerminalInputClient()..viewId = 0; + addTearDown(other.detach); + other.attach(); + calls.clear(); + + handler.ensureAttached(); + + expect(textInputSetClientCalls(calls), hasLength(1)); + }); + + test('clears visible preedit when reopening an orphaned connection', () { + final preedit = []; + handler.onPreeditChanged = preedit.add; + handler.attach(); + handler.updateEditingValue( + const TextEditingValue( + text: ' ni', + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 1, end: 3), + ), + ); + final other = TerminalInputClient()..viewId = 0; + addTearDown(other.detach); + other.attach(); + preedit.clear(); + + handler.ensureAttached(); + + expect(preedit, ['']); + }); }); group('viewId', () { From fac9eb6c1216303e80bcd8b5b0668cc51589e749 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 31 Jul 2026 16:42:35 +0300 Subject: [PATCH 08/26] chore(libghostty): sync ghostty source and bindings --- .../src/widgets/terminal_controller_impl.dart | 6 +- packages/libghostty/ghostty.version | 2 +- .../lib/src/bindings/interface.dart | 6 +- .../lib/src/bindings/native/native.dart | 34 ++++-- .../lib/src/bindings/wasm/layouts.dart | 10 -- .../lib/src/bindings/wasm/wasm.dart | 37 ++++-- .../libghostty/lib/src/ffi/libghostty.g.dart | 108 +++++++++++++--- .../lib/src/ffi/libghostty_enums.g.dart | 115 +++++++++++++++++- .../lib/src/ffi/libghostty_wasm.g.dart | 13 +- .../test/bindings/bindings_native_test.dart | 42 +++---- .../impl/terminal/tracked_grid_ref_test.dart | 2 +- .../test/wasm/bindings_wasm_test.dart | 42 +++---- .../impl/terminal/tracked_grid_ref_test.dart | 2 +- 13 files changed, 315 insertions(+), 104 deletions(-) diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index dd6196d9..437d7151 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -78,11 +78,7 @@ class TerminalControllerImpl extends TerminalController _keyEvent = vt.KeyEvent(), _mouseEvent = MouseEvent(), _textInput = TerminalInputClient(), - terminal = Terminal( - cols: config.cols, - rows: config.rows, - maxScrollback: config.scrollbackLimit, - ), + terminal = Terminal(cols: config.cols, rows: config.rows), super.base() { _selectionGesture = SelectionGestureDriver(terminal); _compressionScheduler = CompressionScheduler( diff --git a/packages/libghostty/ghostty.version b/packages/libghostty/ghostty.version index 911afa8a..fd6d9877 100644 --- a/packages/libghostty/ghostty.version +++ b/packages/libghostty/ghostty.version @@ -1 +1 @@ -15484b607eb5a518dedf1548247c923b8abaae7c +4d605bf0d819df901a0332bbb320dc849fdd82e4 diff --git a/packages/libghostty/lib/src/bindings/interface.dart b/packages/libghostty/lib/src/bindings/interface.dart index b73cfc68..7fc0336b 100644 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ b/packages/libghostty/lib/src/bindings/interface.dart @@ -107,7 +107,7 @@ abstract interface class GhosttyBindings { int unicodeCodepointWidth(int codepoint); ({int consumed, int width}) unicodeGraphemeWidth(List codepoints); - CResult terminalNew(int cols, int rows, int maxScrollback); + CResult terminalNew(int cols, int rows); void terminalFree(int handle); void terminalVtWrite(int handle, Uint8List data); Result terminalResize( @@ -143,6 +143,8 @@ abstract interface class GhosttyBindings { CResult terminalGetPwd(int handle); CResult terminalGetTotalRows(int handle); CResult terminalGetScrollbackRows(int handle); + CResult terminalGetScrollbackMaxBytes(int handle); + CResult terminalGetScrollbackMaxLines(int handle); CResult terminalGetWidthPx(int handle); CResult terminalGetHeightPx(int handle); CResult terminalGetGeometry(int handle); @@ -181,6 +183,8 @@ abstract interface class GhosttyBindings { Result terminalSetKittyImageMediumSharedMem(int handle, {bool? enabled}); Result terminalSetApcBufferLimit(int handle, int? bytes); Result terminalSetKittyApcBufferLimit(int handle, int? bytes); + Result terminalSetScrollbackMaxBytes(int handle, int? bytes); + Result terminalSetScrollbackMaxLines(int handle, int? lines); CResult pasteEncode(String data, {required bool bracketed}); diff --git a/packages/libghostty/lib/src/bindings/native/native.dart b/packages/libghostty/lib/src/bindings/native/native.dart index d097c9df..2d006be6 100644 --- a/packages/libghostty/lib/src/bindings/native/native.dart +++ b/packages/libghostty/lib/src/bindings/native/native.dart @@ -917,14 +917,10 @@ class NativeBindings implements GhosttyBindings { } @override - CResult terminalNew(int cols, int rows, int maxScrollback) { + CResult terminalNew(int cols, int rows) { return using((arena) { final ptr = arena>(); - final opts = arena(); - opts.ref.cols = cols; - opts.ref.rows = rows; - opts.ref.max_scrollback = maxScrollback; - final result = ghostty_terminal_new(nullptr, ptr, opts.ref); + final result = ghostty_terminal_new(nullptr, ptr, cols, rows); return (result, ptr.value.address); }); } @@ -1166,6 +1162,16 @@ class NativeBindings implements GhosttyBindings { return _terminalGetSize(handle, .scrollbackRows); } + @override + CResult terminalGetScrollbackMaxBytes(int handle) { + return _terminalGetSize(handle, .scrollbackMaxBytes); + } + + @override + CResult terminalGetScrollbackMaxLines(int handle) { + return _terminalGetSize(handle, .scrollbackMaxLines); + } + @override CResult terminalGetWidthPx(int handle) { return _terminalGetU32(handle, .widthPx); @@ -1358,12 +1364,22 @@ class NativeBindings implements GhosttyBindings { @override Result terminalSetApcBufferLimit(int handle, int? bytes) { - return _terminalSetApcSize(handle, .apcMaxBytes, bytes); + return _terminalSetSize(handle, .apcMaxBytes, bytes); } @override Result terminalSetKittyApcBufferLimit(int handle, int? bytes) { - return _terminalSetApcSize(handle, .apcMaxBytesKitty, bytes); + return _terminalSetSize(handle, .apcMaxBytesKitty, bytes); + } + + @override + Result terminalSetScrollbackMaxBytes(int handle, int? bytes) { + return _terminalSetSize(handle, .scrollbackMaxBytes, bytes); + } + + @override + Result terminalSetScrollbackMaxLines(int handle, int? lines) { + return _terminalSetSize(handle, .scrollbackMaxLines, lines); } @override @@ -2208,7 +2224,7 @@ class NativeBindings implements GhosttyBindings { ); } - Result _terminalSetApcSize(int handle, TerminalOption option, int? value) { + Result _terminalSetSize(int handle, TerminalOption option, int? value) { if (value == null) { return ghostty_terminal_set(Pointer.fromAddress(handle), option, nullptr); } diff --git a/packages/libghostty/lib/src/bindings/wasm/layouts.dart b/packages/libghostty/lib/src/bindings/wasm/layouts.dart index 438432a6..4847737f 100644 --- a/packages/libghostty/lib/src/bindings/wasm/layouts.dart +++ b/packages/libghostty/lib/src/bindings/wasm/layouts.dart @@ -210,11 +210,6 @@ class Layouts { late final int styleColorG; late final int styleColorB; - // GhosttyTerminalOptions - late final int terminalOptsSize; - late final int terminalOptsRows; - late final int terminalOptsMaxScrollback; - // GhosttyTerminalScrollbar late final int scrollbarSize; late final int scrollbarOffset; @@ -458,11 +453,6 @@ class Layouts { styleColorG = scValueOff + sub['g']; styleColorB = scValueOff + sub['b']; - struct = _Struct(types, 'GhosttyTerminalOptions'); - terminalOptsSize = struct.size; - terminalOptsRows = struct['rows']; - terminalOptsMaxScrollback = struct['max_scrollback']; - struct = _Struct(types, 'GhosttyTerminalScrollbar'); scrollbarSize = struct.size; scrollbarOffset = struct['offset']; diff --git a/packages/libghostty/lib/src/bindings/wasm/wasm.dart b/packages/libghostty/lib/src/bindings/wasm/wasm.dart index 1c6eeb13..cc645af6 100644 --- a/packages/libghostty/lib/src/bindings/wasm/wasm.dart +++ b/packages/libghostty/lib/src/bindings/wasm/wasm.dart @@ -1089,18 +1089,11 @@ class WasmBindings implements GhosttyBindings { } @override - CResult terminalNew(int cols, int rows, int maxScrollback) { + CResult terminalNew(int cols, int rows) { final outPtr = _exports.ghostty_wasm_alloc_opaque(); - final optsPtr = _exports.ghostty_wasm_alloc_u8_array( - _layout.terminalOptsSize, - ); - _mem.writeU16(optsPtr, cols); - _mem.writeU16(optsPtr + _layout.terminalOptsRows, rows); - _mem.writeU32(optsPtr + _layout.terminalOptsMaxScrollback, maxScrollback); - final result = _exports.ghostty_terminal_new(0, outPtr, optsPtr); + final result = _exports.ghostty_terminal_new(0, outPtr, cols, rows); final handle = _mem.readPtr(outPtr); _exports.ghostty_wasm_free_opaque(outPtr); - _exports.ghostty_wasm_free_u8_array(optsPtr, _layout.terminalOptsSize); return (.fromValue(result), handle); } @@ -1498,6 +1491,16 @@ class WasmBindings implements GhosttyBindings { return _terminalGetU64(handle, .kittyImageStorageLimit); } + @override + CResult terminalGetScrollbackMaxBytes(int handle) { + return _terminalGetU64(handle, .scrollbackMaxBytes); + } + + @override + CResult terminalGetScrollbackMaxLines(int handle) { + return _terminalGetU64(handle, .scrollbackMaxLines); + } + @override CResult terminalGetKittyImageMediumFile(int handle) { return _terminalGetBool(handle, .kittyImageMediumFile); @@ -1535,12 +1538,22 @@ class WasmBindings implements GhosttyBindings { @override Result terminalSetApcBufferLimit(int handle, int? bytes) { - return _terminalSetApcSize(handle, .apcMaxBytes, bytes); + return _terminalSetSize(handle, .apcMaxBytes, bytes); } @override Result terminalSetKittyApcBufferLimit(int handle, int? bytes) { - return _terminalSetApcSize(handle, .apcMaxBytesKitty, bytes); + return _terminalSetSize(handle, .apcMaxBytesKitty, bytes); + } + + @override + Result terminalSetScrollbackMaxBytes(int handle, int? bytes) { + return _terminalSetSize(handle, .scrollbackMaxBytes, bytes); + } + + @override + Result terminalSetScrollbackMaxLines(int handle, int? lines) { + return _terminalSetSize(handle, .scrollbackMaxLines, lines); } @override @@ -4410,7 +4423,7 @@ class WasmBindings implements GhosttyBindings { return .fromValue(result); } - Result _terminalSetApcSize(int handle, TerminalOption option, int? value) { + Result _terminalSetSize(int handle, TerminalOption option, int? value) { if (value == null) { return .fromValue(_exports.ghostty_terminal_set(handle, option.value, 0)); } diff --git a/packages/libghostty/lib/src/ffi/libghostty.g.dart b/packages/libghostty/lib/src/ffi/libghostty.g.dart index 3e25a299..6ee86722 100644 --- a/packages/libghostty/lib/src/ffi/libghostty.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty.g.dart @@ -3898,9 +3898,14 @@ Result ghostty_terminal_mode_set( /// Create a new terminal instance. /// +/// The terminal starts with various reasonable defaults e.g. around +/// scrollback limits. Use ghostty_terminal_set() to change any options +/// prior to using the terminal. +/// /// @param allocator Pointer to allocator, or NULL to use the default allocator /// @param terminal Pointer to store the created terminal handle -/// @param options Terminal initialization options +/// @param cols Terminal width in cells (must be greater than zero) +/// @param rows Terminal height in cells (must be greater than zero) /// @return GHOSTTY_SUCCESS on success, or an error code on failure /// /// @ingroup terminal @@ -3908,20 +3913,23 @@ Result ghostty_terminal_mode_set( ffi.Int Function( ffi.Pointer, ffi.Pointer, - TerminalOptions, + ffi.Uint16, + ffi.Uint16, ) >(symbol: 'ghostty_terminal_new', isLeaf: true) external int _ghostty_terminal_new( ffi.Pointer allocator, ffi.Pointer terminal, - TerminalOptions options, + int cols, + int rows, ); Result ghostty_terminal_new( ffi.Pointer allocator, ffi.Pointer terminal, - TerminalOptions options, -) => Result.fromValue(_ghostty_terminal_new(allocator, terminal, options)); + int cols, + int rows, +) => Result.fromValue(_ghostty_terminal_new(allocator, terminal, cols, rows)); /// Convert a grid reference back to a point in the given coordinate system. /// @@ -4568,7 +4576,8 @@ Result ghostty_terminal_selection_ordered( /// write_pty callback and userdata pointer. The value is passed /// directly for pointer types (callbacks, userdata) or as a pointer /// to the value for non-pointer types (e.g. String*). -/// NULL clears the option to its default. +/// The behavior of a NULL value is specific to each option and is +/// documented by the corresponding TerminalOption value. /// /// Callbacks are invoked synchronously during ghostty_terminal_vt_write(). /// Callbacks must not call ghostty_terminal_vt_write() on the same @@ -6274,6 +6283,45 @@ typedef TerminalColorSchemeFn = > >; +/// A request to show a desktop notification. +/// +/// This is a sized struct. The callback must only access fields present in the +/// size reported by `size`. Both strings are borrowed and valid only for the +/// duration of the callback. +/// +/// @ingroup terminal +final class TerminalDesktopNotification extends ffi.Struct { + /// Size of this struct in bytes. + @ffi.Size() + external int size; + + /// Notification title, or an empty string when the protocol omits it. + external String title; + + /// Notification body. + external String body; +} + +/// Callback function type for desktop notifications. +/// +/// Called synchronously when the terminal receives OSC 9 or OSC 777. +/// +/// @param terminal The terminal handle +/// @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA +/// @param notification Borrowed desktop notification request +/// +/// @ingroup terminal +typedef TerminalDesktopNotificationFn = + ffi.Pointer< + ffi.NativeFunction< + ffi.Void Function( + Terminal terminal, + ffi.Pointer userdata, + ffi.Pointer notification, + ) + > + >; + /// Callback function type for device attributes queries (DA1/DA2/DA3). /// /// Called when the terminal receives a device attributes query (CSI c, @@ -6321,23 +6369,49 @@ typedef TerminalEnquiryFn = final class TerminalImpl extends ffi.Opaque {} -/// Terminal initialization options. +/// A progress report emitted by the running program. +/// +/// This is a sized struct. The callback must only access fields present in the +/// size reported by `size`. /// /// @ingroup terminal -final class TerminalOptions extends ffi.Struct { - /// Terminal width in cells. Must be greater than zero. - @ffi.Uint16() - external int cols; +final class TerminalProgressReport extends ffi.Struct { + /// Size of this struct in bytes. + @ffi.Size() + external int size; - /// Terminal height in cells. Must be greater than zero. - @ffi.Uint16() - external int rows; + /// Literal progress state reported by the running program. + @ffi.UnsignedInt() + external int stateAsInt; - /// Maximum number of lines to keep in scrollback history. - @ffi.Size() - external int max_scrollback; + TerminalProgressState get state => + TerminalProgressState.fromValue(stateAsInt); + + /// Progress percentage from 0 through 100, or -1 when omitted. + @ffi.Int8() + external int progress; } +/// Callback function type for progress reports. +/// +/// Called synchronously when the terminal receives OSC 9;4. +/// +/// @param terminal The terminal handle +/// @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA +/// @param report Borrowed progress report +/// +/// @ingroup terminal +typedef TerminalProgressReportFn = + ffi.Pointer< + ffi.NativeFunction< + ffi.Void Function( + Terminal terminal, + ffi.Pointer userdata, + ffi.Pointer report, + ) + > + >; + /// Callback function type for pwd_changed. /// /// Called when the terminal pwd (current working directory) changes via diff --git a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart index 57923cee..f2e4b6de 100644 --- a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart @@ -2774,7 +2774,25 @@ enum TerminalData { /// if there was some error that happened at some point during VT processing. /// /// Output type: bool * - vtProcessingError(33); + vtProcessingError(33), + + /// The configured maximum scrollback allocation in bytes. + /// + /// This always reports the primary screen's configured value, including + /// while an alternate screen is active. Returns GHOSTTY_NO_VALUE when the + /// configured byte limit is unlimited. + /// + /// Output type: size_t * + scrollbackMaxBytes(34), + + /// The configured maximum number of physical scrollback lines. + /// + /// This always reports the primary screen's configured value, including + /// while an alternate screen is active. Returns GHOSTTY_NO_VALUE when the + /// configured line limit is unlimited. + /// + /// Output type: size_t * + scrollbackMaxLines(35); final int value; const TerminalData(this.value); @@ -2814,6 +2832,8 @@ enum TerminalData { 31 => selection, 32 => viewportActive, 33 => vtProcessingError, + 34 => scrollbackMaxBytes, + 35 => scrollbackMaxLines, _ => throw ArgumentError('Unknown value for TerminalData: $value'), }; } @@ -3031,7 +3051,62 @@ enum TerminalOption { /// TerminalClipboardWriteFn. /// /// Input type: TerminalClipboardWriteFn - clipboardWrite(26); + clipboardWrite(26), + + /// Set the maximum scrollback allocation in bytes. + /// + /// This is an estimate. Internally, libghostty only prunes bytes up + /// to a "page"-granularity. A page is the minimum allocated unit of + /// grid space within . A page at the time of writing these docs + /// is about 400KB, so the byte limit will be within this delta. + /// + /// This works alongside the line limit configuration. If both are set, + /// the first-reached limit is used first. Both limits are dependent + /// on external state (byte limit can be reached with less lines if + /// more styles are used for example, line limit can be reached with + /// a narrower terminal viewport). So, they are useful together. + /// + /// Lowering the limit immediately removes eligible complete historical + /// pages. A value of zero disables scrollback and erases retained history. + /// A NULL value pointer removes the byte limit. + /// + /// Input type: size_t* + scrollbackMaxBytes(27), + + /// Set the maximum number of physical lines retained in scrollback. + /// + /// This is an estimate. Internally, libghostty only prunes lines up + /// to a "page"-granularity. A page is the minimum allocated unit of + /// grid space within . As a result, the actual available scrollback + /// lines will almost always be higher than configured. The magnitude + /// of the difference depends on the number of used styles, graphemes, etc. + /// since the row-count in a page is dynamic based on that. In general, + /// it ranges from dozens to a hundred or so lines. + /// + /// This works alongside the line limit configuration. If both are set, + /// the first-reached limit is used first. Both limits are dependent + /// on external state (byte limit can be reached with less lines if + /// more styles are used for example, line limit can be reached with + /// a narrower terminal viewport). So, they are useful together. + /// + /// Lowering the limit immediately removes eligible complete historical + /// pages. A NULL value pointer removes the line limit. + /// + /// Input type: size_t* + scrollbackMaxLines(28), + + /// Callback invoked when the running program requests a desktop + /// notification via OSC 9 or OSC 777. Set to NULL to ignore desktop + /// notification requests. + /// + /// Input type: TerminalDesktopNotificationFn + desktopNotification(29), + + /// Callback invoked when the running program reports progress via OSC 9;4. + /// Set to NULL to ignore progress reports. + /// + /// Input type: TerminalProgressReportFn + progressReport(30); final int value; const TerminalOption(this.value); @@ -3064,10 +3139,46 @@ enum TerminalOption { 24 => glyphProtocol, 25 => pwdChanged, 26 => clipboardWrite, + 27 => scrollbackMaxBytes, + 28 => scrollbackMaxLines, + 29 => desktopNotification, + 30 => progressReport, _ => throw ArgumentError('Unknown value for TerminalOption: $value'), }; } +/// State of a terminal progress report. +/// +/// @ingroup terminal +enum TerminalProgressState { + /// Remove any visible progress indication. + remove(0), + + /// Show determinate progress. + set(1), + + /// Show a failed progress state. + error(2), + + /// Show indeterminate progress. + indeterminate(3), + + /// Show paused progress. + pause(4); + + final int value; + const TerminalProgressState(this.value); + + static TerminalProgressState fromValue(int value) => switch (value) { + 0 => remove, + 1 => set, + 2 => error, + 3 => indeterminate, + 4 => pause, + _ => throw ArgumentError('Unknown value for TerminalProgressState: $value'), + }; +} + /// Terminal screen identifier. /// /// Identifies which screen buffer is active in the terminal. diff --git a/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart b/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart index 1971632b..02d71399 100644 --- a/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart @@ -2502,16 +2502,22 @@ extension type GhosttyExports(JSObject _) implements JSObject { /// Create a new terminal instance. /// + /// The terminal starts with various reasonable defaults e.g. around + /// scrollback limits. Use ghostty_terminal_set() to change any options + /// prior to using the terminal. + /// /// @param allocator Pointer to allocator, or NULL to use the default allocator /// @param terminal Pointer to store the created terminal handle - /// @param options Terminal initialization options + /// @param cols Terminal width in cells (must be greater than zero) + /// @param rows Terminal height in cells (must be greater than zero) /// @return GHOSTTY_SUCCESS on success, or an error code on failure /// /// @ingroup terminal external int ghostty_terminal_new( Pointer allocator, Pointer terminal, - int options, + int cols, + int rows, ); /// Convert a grid reference back to a point in the given coordinate system. @@ -2918,7 +2924,8 @@ extension type GhosttyExports(JSObject _) implements JSObject { /// write_pty callback and userdata pointer. The value is passed /// directly for pointer types (callbacks, userdata) or as a pointer /// to the value for non-pointer types (e.g. GhosttyString*). - /// NULL clears the option to its default. + /// The behavior of a NULL value is specific to each option and is + /// documented by the corresponding GhosttyTerminalOption value. /// /// Callbacks are invoked synchronously during ghostty_terminal_vt_write(). /// Callbacks must not call ghostty_terminal_vt_write() on the same diff --git a/packages/libghostty/test/bindings/bindings_native_test.dart b/packages/libghostty/test/bindings/bindings_native_test.dart index b4609f45..b022787d 100644 --- a/packages/libghostty/test/bindings/bindings_native_test.dart +++ b/packages/libghostty/test/bindings/bindings_native_test.dart @@ -15,7 +15,7 @@ void main() { late int renderState; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; final (_, rs) = bindings.renderStateNew(); renderState = rs; @@ -202,7 +202,7 @@ void main() { }); test('omits viewport coordinates for an offscreen cursor', () { - final scrolledTerminal = check(bindings.terminalNew(5, 2, 100)); + final scrolledTerminal = check(bindings.terminalNew(5, 2)); addTearDown(() => bindings.terminalFree(scrolledTerminal)); final scrolledRenderState = check(bindings.renderStateNew()); addTearDown(() => bindings.renderStateFree(scrolledRenderState)); @@ -302,7 +302,7 @@ void main() { group('terminalCompressionActivity', () { test('changes after terminal activity', () { - final subject = check(bindings.terminalNew(80, 24, 10_000_000)); + final subject = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(subject)); final initial = check(bindings.terminalCompressionActivity(subject)); @@ -437,7 +437,7 @@ void main() { group('terminalGetViewportActive', () { test('returns false after scrollback navigation', () { - final (_, t) = bindings.terminalNew(5, 2, 100); + final (_, t) = bindings.terminalNew(5, 2); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -526,7 +526,7 @@ void main() { }); test('uses back-arrow key mode from terminal options', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); checkCode( bindings.terminalModeSet( @@ -618,7 +618,7 @@ void main() { }); test('uses mouse tracking mode from terminal options', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -740,7 +740,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -765,7 +765,7 @@ void main() { group('gridRefStyle', () { test('reflects bold attribute', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -852,7 +852,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; }); @@ -898,7 +898,7 @@ void main() { group('row iterator data', () { int firstRowIterator(String text) { - final terminal = check(bindings.terminalNew(10, 3, 0)); + final terminal = check(bindings.terminalNew(10, 3)); final renderState = check(bindings.renderStateNew()); final iterator = check(bindings.rowIteratorNew()); addTearDown(() => bindings.rowIteratorFree(iterator)); @@ -1073,7 +1073,7 @@ void main() { group('selection gesture data', () { group('selectionGestureGetState', () { test('returns the initial gesture state', () { - final terminal = check(bindings.terminalNew(80, 24, 0)); + final terminal = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(terminal)); final gesture = check(bindings.selectionGestureNew()); addTearDown(() => bindings.selectionGestureFree(gesture, terminal)); @@ -1093,7 +1093,7 @@ void main() { }); test('rejects an invalid gesture handle', () { - final terminal = check(bindings.terminalNew(80, 24, 0)); + final terminal = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(terminal)); final result = bindings.selectionGestureGetState(0, terminal); @@ -1116,7 +1116,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; }); @@ -1222,7 +1222,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -1247,7 +1247,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -1276,7 +1276,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite( terminal, @@ -1288,7 +1288,7 @@ void main() { group('formatterFormat', () { ({int terminal, String expected}) largeContentFixture() { - final terminal = check(bindings.terminalNew(6000, 1, 0)); + final terminal = check(bindings.terminalNew(6000, 1)); addTearDown(() => bindings.terminalFree(terminal)); final expected = String.fromCharCodes(List.filled(5000, 0x41)); bindings.terminalVtWrite( @@ -1401,7 +1401,7 @@ void main() { }); test('restricts output to selection', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -1451,7 +1451,7 @@ String _firstRowText(int renderState) { } int _firstCellCells(String text) { - final (_, terminal) = bindings.terminalNew(80, 24, 0); + final (_, terminal) = bindings.terminalNew(80, 24); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); final (_, rowCells) = bindings.rowCellsNew(); @@ -1469,7 +1469,7 @@ int _firstCellCells(String text) { } int _selectedRowIterator(int row) { - final (_, terminal) = bindings.terminalNew(10, 3, 0); + final (_, terminal) = bindings.terminalNew(10, 3); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); addTearDown(() => bindings.rowIteratorFree(rowIter)); @@ -1505,7 +1505,7 @@ int _selectedRowIterator(int row) { } int _selectedRowCells(int col) { - final (_, terminal) = bindings.terminalNew(10, 3, 0); + final (_, terminal) = bindings.terminalNew(10, 3); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); final (_, rowCells) = bindings.rowCellsNew(); diff --git a/packages/libghostty/test/impl/terminal/tracked_grid_ref_test.dart b/packages/libghostty/test/impl/terminal/tracked_grid_ref_test.dart index 311240bc..6767717c 100644 --- a/packages/libghostty/test/impl/terminal/tracked_grid_ref_test.dart +++ b/packages/libghostty/test/impl/terminal/tracked_grid_ref_test.dart @@ -150,7 +150,7 @@ void main() { }); test('follows the cell after scrolling', () { - final scrolled = Terminal(cols: 8, rows: 3, maxScrollback: 100); + final scrolled = Terminal(cols: 8, rows: 3); addTearDown(scrolled.dispose); scrolled.write( Uint8List.fromList('alpha\r\nbravo\r\ncharlie'.codeUnits), diff --git a/packages/libghostty/test/wasm/bindings_wasm_test.dart b/packages/libghostty/test/wasm/bindings_wasm_test.dart index b276722c..8558cd2b 100644 --- a/packages/libghostty/test/wasm/bindings_wasm_test.dart +++ b/packages/libghostty/test/wasm/bindings_wasm_test.dart @@ -46,7 +46,7 @@ void main() { late int renderState; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; final (_, rs) = bindings.renderStateNew(); renderState = rs; @@ -79,7 +79,7 @@ void main() { group('terminalCompressionActivity', () { test('changes after terminal activity', () { - final subject = check(bindings.terminalNew(80, 24, 10_000_000)); + final subject = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(subject)); final initial = check(bindings.terminalCompressionActivity(subject)); @@ -339,7 +339,7 @@ void main() { }); test('omits viewport coordinates for an offscreen cursor', () { - final scrolledTerminal = check(bindings.terminalNew(5, 2, 100)); + final scrolledTerminal = check(bindings.terminalNew(5, 2)); addTearDown(() => bindings.terminalFree(scrolledTerminal)); final scrolledRenderState = check(bindings.renderStateNew()); addTearDown(() => bindings.renderStateFree(scrolledRenderState)); @@ -458,7 +458,7 @@ void main() { group('terminalGetViewportActive', () { test('returns false after scrollback navigation', () { - final (_, t) = bindings.terminalNew(5, 2, 100); + final (_, t) = bindings.terminalNew(5, 2); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -550,7 +550,7 @@ void main() { bindings.keyEventFree(event); bindings.keyEncoderFree(encoder); - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); final (_, enc) = bindings.keyEncoderNew(); final (_, ev) = bindings.keyEventNew(); checkCode( @@ -652,7 +652,7 @@ void main() { test('uses mouse tracking mode from terminal options', () { bindings.mouseEncoderFree(encoder); - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); final (_, enc) = bindings.mouseEncoderNew(); bindings.terminalVtWrite( t, @@ -765,7 +765,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -790,7 +790,7 @@ void main() { group('gridRefStyle', () { test('reflects bold attribute', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -877,7 +877,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; }); @@ -923,7 +923,7 @@ void main() { group('row iterator data', () { int firstRowIterator(String text) { - final terminal = check(bindings.terminalNew(10, 3, 0)); + final terminal = check(bindings.terminalNew(10, 3)); final renderState = check(bindings.renderStateNew()); final iterator = check(bindings.rowIteratorNew()); addTearDown(() => bindings.rowIteratorFree(iterator)); @@ -1098,7 +1098,7 @@ void main() { group('selection gesture data', () { group('selectionGestureGetState', () { test('returns the initial gesture state', () { - final terminal = check(bindings.terminalNew(80, 24, 0)); + final terminal = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(terminal)); final gesture = check(bindings.selectionGestureNew()); addTearDown(() => bindings.selectionGestureFree(gesture, terminal)); @@ -1118,7 +1118,7 @@ void main() { }); test('rejects an invalid gesture handle', () { - final terminal = check(bindings.terminalNew(80, 24, 0)); + final terminal = check(bindings.terminalNew(80, 24)); addTearDown(() => bindings.terminalFree(terminal)); final result = bindings.selectionGestureGetState(0, terminal); @@ -1141,7 +1141,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; }); @@ -1243,7 +1243,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -1268,7 +1268,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite(terminal, Uint8List.fromList('Hello'.codeUnits)); }); @@ -1297,7 +1297,7 @@ void main() { late int terminal; setUp(() { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); terminal = t; bindings.terminalVtWrite( terminal, @@ -1309,7 +1309,7 @@ void main() { group('formatterFormat', () { ({int terminal, String expected}) largeContentFixture() { - final terminal = check(bindings.terminalNew(6000, 1, 0)); + final terminal = check(bindings.terminalNew(6000, 1)); addTearDown(() => bindings.terminalFree(terminal)); final expected = String.fromCharCodes(List.filled(5000, 0x41)); bindings.terminalVtWrite( @@ -1422,7 +1422,7 @@ void main() { }); test('restricts output to selection', () { - final (_, t) = bindings.terminalNew(80, 24, 0); + final (_, t) = bindings.terminalNew(80, 24); addTearDown(() => bindings.terminalFree(t)); bindings.terminalVtWrite( t, @@ -1472,7 +1472,7 @@ String _firstRowText(int renderState) { } int _firstCellCells(String text) { - final (_, terminal) = bindings.terminalNew(80, 24, 0); + final (_, terminal) = bindings.terminalNew(80, 24); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); final (_, rowCells) = bindings.rowCellsNew(); @@ -1490,7 +1490,7 @@ int _firstCellCells(String text) { } int _selectedRowIterator(int row) { - final (_, terminal) = bindings.terminalNew(10, 3, 0); + final (_, terminal) = bindings.terminalNew(10, 3); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); addTearDown(() => bindings.rowIteratorFree(rowIter)); @@ -1526,7 +1526,7 @@ int _selectedRowIterator(int row) { } int _selectedRowCells(int col) { - final (_, terminal) = bindings.terminalNew(10, 3, 0); + final (_, terminal) = bindings.terminalNew(10, 3); final (_, renderState) = bindings.renderStateNew(); final (_, rowIter) = bindings.rowIteratorNew(); final (_, rowCells) = bindings.rowCellsNew(); diff --git a/packages/libghostty/test/wasm/impl/terminal/tracked_grid_ref_test.dart b/packages/libghostty/test/wasm/impl/terminal/tracked_grid_ref_test.dart index 0e477bd6..ee860735 100644 --- a/packages/libghostty/test/wasm/impl/terminal/tracked_grid_ref_test.dart +++ b/packages/libghostty/test/wasm/impl/terminal/tracked_grid_ref_test.dart @@ -154,7 +154,7 @@ void main() { }); test('follows the cell after scrolling', () { - final scrolled = Terminal(cols: 8, rows: 3, maxScrollback: 100); + final scrolled = Terminal(cols: 8, rows: 3); addTearDown(scrolled.dispose); scrolled.write( Uint8List.fromList('alpha\r\nbravo\r\ncharlie'.codeUnits), From 1e58f4f4c506d5f90ac0e081dc2e8b151fad3e07 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 31 Jul 2026 16:43:11 +0300 Subject: [PATCH 09/26] feat(libghostty): expose scrollback limits --- .../lib/src/impl/terminal/terminal.dart | 57 ++++++++++++++----- .../test/impl/terminal/terminal_test.dart | 22 +++++++ .../test/wasm/terminal/terminal_test.dart | 22 +++++++ 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/libghostty/lib/src/impl/terminal/terminal.dart b/packages/libghostty/lib/src/impl/terminal/terminal.dart index 5a546d19..ead5bce6 100644 --- a/packages/libghostty/lib/src/impl/terminal/terminal.dart +++ b/packages/libghostty/lib/src/impl/terminal/terminal.dart @@ -91,18 +91,17 @@ final class Terminal with Listenable { final int _handle; bool _disposed; - /// Creates a terminal with the given grid dimensions and scrollback limit. + /// Creates a terminal with the given grid dimensions. /// - /// Both [cols] and [rows] must be greater than zero. [maxScrollback] controls - /// how many lines of history are preserved above the active grid. + /// Both [cols] and [rows] must be greater than zero. /// /// Throws [OutOfMemoryException] if the native allocation fails. /// /// ```dart - /// final terminal = Terminal(cols: 80, rows: 24, maxScrollback: 5000); + /// final terminal = Terminal(cols: 80, rows: 24); /// ``` - Terminal({required int cols, required int rows, int maxScrollback = 10_000}) - : _handle = check(bindings.terminalNew(cols, rows, maxScrollback)), + Terminal({required int cols, required int rows}) + : _handle = check(bindings.terminalNew(cols, rows)), _disposed = false { _finalizer.attach(this, _handle, detach: this); } @@ -120,7 +119,7 @@ final class Terminal with Listenable { /// Returns null if no color is configured (neither a default nor an OSC /// override). RgbColor? get background { - return _optionalColor(bindings.terminalGetColorBackground(_handle)); + return _optionalValue(bindings.terminalGetColorBackground(_handle)); } /// Sets the default background color, or clears it if null. @@ -135,7 +134,7 @@ final class Terminal with Listenable { /// /// Returns null if no default has been configured. RgbColor? get backgroundDefault { - return _optionalColor(bindings.terminalGetColorBackgroundDefault(_handle)); + return _optionalValue(bindings.terminalGetColorBackgroundDefault(_handle)); } /// Opaque token that changes when scrollback compression may have new work. @@ -158,7 +157,7 @@ final class Terminal with Listenable { /// /// Returns null if no color is configured. RgbColor? get cursorColor { - return _optionalColor(bindings.terminalGetColorCursor(_handle)); + return _optionalValue(bindings.terminalGetColorCursor(_handle)); } /// Sets the default cursor color, or clears it if null. @@ -172,7 +171,7 @@ final class Terminal with Listenable { /// /// Returns null if no default has been configured. RgbColor? get cursorColorDefault { - return _optionalColor(bindings.terminalGetColorCursorDefault(_handle)); + return _optionalValue(bindings.terminalGetColorCursorDefault(_handle)); } /// The cursor's current SGR style (applied to newly printed characters). @@ -193,7 +192,7 @@ final class Terminal with Listenable { /// Returns null if no color is configured (neither a default nor an OSC /// override). RgbColor? get foreground { - return _optionalColor(bindings.terminalGetColorForeground(_handle)); + return _optionalValue(bindings.terminalGetColorForeground(_handle)); } /// Sets the default foreground color, or clears it if null. @@ -207,7 +206,7 @@ final class Terminal with Listenable { /// /// Returns null if no default has been configured. RgbColor? get foregroundDefault { - return _optionalColor(bindings.terminalGetColorForegroundDefault(_handle)); + return _optionalValue(bindings.terminalGetColorForegroundDefault(_handle)); } /// Current terminal dimensions in cells and pixels. @@ -439,6 +438,38 @@ final class Terminal with Listenable { /// Sets the working directory, or clears it if null. set pwd(String? value) => checkCode(bindings.terminalSetPwd(_handle, value)); + /// Maximum bytes retained for scrollback, or null when unlimited. + /// + /// This limit and [scrollbackMaxLines] apply together. Ghostty prunes when + /// either limit is reached, at page granularity. + int? get scrollbackMaxBytes { + return _optionalValue(bindings.terminalGetScrollbackMaxBytes(_handle)); + } + + /// Sets the maximum bytes retained for scrollback. + /// + /// Set to null for no byte limit or zero to clear retained history and + /// disable scrollback by bytes. + set scrollbackMaxBytes(int? value) { + checkCode(bindings.terminalSetScrollbackMaxBytes(_handle, value)); + } + + /// Maximum physical lines retained for scrollback, or null when unlimited. + /// + /// This limit and [scrollbackMaxBytes] apply together. Ghostty prunes when + /// either limit is reached, at page granularity. + int? get scrollbackMaxLines { + return _optionalValue(bindings.terminalGetScrollbackMaxLines(_handle)); + } + + /// Sets the maximum physical lines retained for scrollback. + /// + /// Set to null for no line limit or zero to clear retained history and + /// disable scrollback by lines. + set scrollbackMaxLines(int? value) { + checkCode(bindings.terminalSetScrollbackMaxLines(_handle, value)); + } + /// Number of rows in the scrollback buffer (excluding the active grid). int get scrollbackRows => check(bindings.terminalGetScrollbackRows(_handle)); @@ -821,7 +852,7 @@ final class Terminal with Listenable { } } - static RgbColor? _optionalColor(CResult result) { + static T? _optionalValue(CResult result) { return result.$1 == .noValue ? null : check(result); } } diff --git a/packages/libghostty/test/impl/terminal/terminal_test.dart b/packages/libghostty/test/impl/terminal/terminal_test.dart index 71f9972a..d68ef221 100644 --- a/packages/libghostty/test/impl/terminal/terminal_test.dart +++ b/packages/libghostty/test/impl/terminal/terminal_test.dart @@ -58,6 +58,28 @@ void main() { }); }); + group('scrollbackMaxLines', () { + test('gets the value set through the setter', () { + terminal.scrollbackMaxLines = 100; + + expect(terminal.scrollbackMaxLines, 100); + }); + + test('returns null when cleared', () { + terminal.scrollbackMaxLines = null; + + expect(terminal.scrollbackMaxLines, isNull); + }); + }); + + group('scrollbackMaxBytes', () { + test('gets the value set through the setter', () { + terminal.scrollbackMaxBytes = 1024; + + expect(terminal.scrollbackMaxBytes, 1024); + }); + }); + group('write', () { Object captureError(void Function() operation) { try { diff --git a/packages/libghostty/test/wasm/terminal/terminal_test.dart b/packages/libghostty/test/wasm/terminal/terminal_test.dart index 67714485..96deb259 100644 --- a/packages/libghostty/test/wasm/terminal/terminal_test.dart +++ b/packages/libghostty/test/wasm/terminal/terminal_test.dart @@ -38,6 +38,28 @@ void main() { }); }); + group('scrollbackMaxLines', () { + test('gets the value set through the setter', () { + terminal.scrollbackMaxLines = 100; + + expect(terminal.scrollbackMaxLines, 100); + }); + + test('returns null when cleared', () { + terminal.scrollbackMaxLines = null; + + expect(terminal.scrollbackMaxLines, isNull); + }); + }); + + group('scrollbackMaxBytes', () { + test('gets the value set through the setter', () { + terminal.scrollbackMaxBytes = 1024; + + expect(terminal.scrollbackMaxBytes, 1024); + }); + }); + group('dispose', () { test('succeeds after a callback error', () { final error = StateError('bell failed'); From be1570a3c66ecbe62dd2f2a526a2ca4c823d44b5 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 31 Jul 2026 16:58:24 +0300 Subject: [PATCH 10/26] feat(libghostty): expose desktop notifications --- packages/libghostty/lib/libghostty.dart | 2 + .../lib/src/bindings/interface.dart | 4 ++ .../lib/src/bindings/native/native.dart | 44 ++++++++++++++++++ .../lib/src/bindings/types/aliases.dart | 8 ++++ .../lib/src/bindings/wasm/layouts.dart | 10 +++++ .../lib/src/bindings/wasm/wasm.dart | 45 +++++++++++++++++++ .../lib/src/impl/terminal/terminal.dart | 9 ++++ .../test/impl/terminal/terminal_test.dart | 13 ++++++ .../test/wasm/terminal/terminal_test.dart | 13 ++++++ 9 files changed, 148 insertions(+) diff --git a/packages/libghostty/lib/libghostty.dart b/packages/libghostty/lib/libghostty.dart index 7ad89e6e..5c416fa0 100644 --- a/packages/libghostty/lib/libghostty.dart +++ b/packages/libghostty/lib/libghostty.dart @@ -12,6 +12,8 @@ export 'src/bindings/types/aliases.dart' ClipboardWrite, ClipboardWriteCallback, DecodedImage, + DesktopNotification, + DesktopNotificationCallback, PngDecoder, TerminalGeometry, X11ColorName; diff --git a/packages/libghostty/lib/src/bindings/interface.dart b/packages/libghostty/lib/src/bindings/interface.dart index 7fc0336b..28862f9f 100644 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ b/packages/libghostty/lib/src/bindings/interface.dart @@ -194,6 +194,10 @@ abstract interface class GhosttyBindings { int handle, ClipboardWriteCallback? callback, ); + void terminalSetOnDesktopNotification( + int handle, + DesktopNotificationCallback? callback, + ); void terminalSetOnTitleChanged(int handle, VoidCallback? callback); void terminalSetOnPwdChanged(int handle, VoidCallback? callback); void terminalSetOnEnquiry(int handle, ValueGetter? callback); diff --git a/packages/libghostty/lib/src/bindings/native/native.dart b/packages/libghostty/lib/src/bindings/native/native.dart index 2d006be6..eb0fc172 100644 --- a/packages/libghostty/lib/src/bindings/native/native.dart +++ b/packages/libghostty/lib/src/bindings/native/native.dart @@ -3679,6 +3679,50 @@ class NativeBindings implements GhosttyBindings { ); } + @override + void terminalSetOnDesktopNotification( + int handle, + DesktopNotificationCallback? callback, + ) { + final map = _callables.putIfAbsent(handle, () => {}); + const option = TerminalOption.desktopNotification; + map[option]?.close(); + if (callback == null) { + map.remove(option); + ghostty_terminal_set(Pointer.fromAddress(handle), option, nullptr); + return; + } + final callable = + NativeCallable< + Void Function( + Terminal, + Pointer, + Pointer, + ) + >.isolateLocal(( + Terminal terminal, + Pointer userdata, + Pointer notification, + ) { + try { + final value = notification.ref; + if (value.size < sizeOf()) return; + callback(( + title: utf8.decode(value.title.ptr.asTypedList(value.title.len)), + body: utf8.decode(value.body.ptr.asTypedList(value.body.len)), + )); + } on Object catch (error, stackTrace) { + _captureCallbackError(error, stackTrace); + } + }); + map[option] = callable; + ghostty_terminal_set( + Pointer.fromAddress(handle), + option, + callable.nativeFunction.cast(), + ); + } + @override void terminalSetOnTitleChanged(int handle, VoidCallback? callback) { final map = _callables.putIfAbsent(handle, () => {}); diff --git a/packages/libghostty/lib/src/bindings/types/aliases.dart b/packages/libghostty/lib/src/bindings/types/aliases.dart index c5d970ae..dce5d972 100644 --- a/packages/libghostty/lib/src/bindings/types/aliases.dart +++ b/packages/libghostty/lib/src/bindings/types/aliases.dart @@ -56,6 +56,14 @@ typedef ClipboardWrite = ({ typedef ClipboardWriteCallback = ClipboardWriteResult Function(ClipboardWrite write); +/// A desktop notification requested by terminal content. +/// +/// Both strings are owned by Dart and remain valid after the callback returns. +typedef DesktopNotification = ({String title, String body}); + +/// Handles a desktop notification requested by terminal content. +typedef DesktopNotificationCallback = void Function(DesktopNotification value); + /// An untracked grid reference value. /// /// The value follows libghostty's untracked grid-reference lifetime rules and diff --git a/packages/libghostty/lib/src/bindings/wasm/layouts.dart b/packages/libghostty/lib/src/bindings/wasm/layouts.dart index 4847737f..0c3da859 100644 --- a/packages/libghostty/lib/src/bindings/wasm/layouts.dart +++ b/packages/libghostty/lib/src/bindings/wasm/layouts.dart @@ -21,6 +21,11 @@ class Layouts { late final int clipboardWriteContents; late final int clipboardWriteContentsLen; + // GhosttyTerminalDesktopNotification + late final int desktopNotificationSize; + late final int desktopNotificationTitle; + late final int desktopNotificationBody; + // GhosttyColorRgb late final int colorRgbSize; late final int colorRgbG; @@ -238,6 +243,11 @@ class Layouts { clipboardWriteContents = struct['contents']; clipboardWriteContentsLen = struct['contents_len']; + struct = _Struct(types, 'GhosttyTerminalDesktopNotification'); + desktopNotificationSize = struct.size; + desktopNotificationTitle = struct['title']; + desktopNotificationBody = struct['body']; + struct = _Struct(types, 'GhosttyColorRgb'); colorRgbSize = struct.size; colorRgbG = struct['g']; diff --git a/packages/libghostty/lib/src/bindings/wasm/wasm.dart b/packages/libghostty/lib/src/bindings/wasm/wasm.dart index cc645af6..6cf3010f 100644 --- a/packages/libghostty/lib/src/bindings/wasm/wasm.dart +++ b/packages/libghostty/lib/src/bindings/wasm/wasm.dart @@ -1712,6 +1712,45 @@ class WasmBindings implements GhosttyBindings { _exports.ghostty_terminal_set(handle, option.value, index); } + @override + void terminalSetOnDesktopNotification( + int handle, + DesktopNotificationCallback? callback, + ) { + final map = _callbacks.putIfAbsent(handle, () => {}); + const option = TerminalOption.desktopNotification; + if (callback == null) { + final existing = map.remove(option); + if (existing != null) _table.set(existing.$1); + _exports.ghostty_terminal_set(handle, option.value, 0); + return; + } + final reuseIndex = map[option]?.$1; + final index = _registerCallback( + ((int terminal, int userdata, int notificationPtr) { + try { + if (_mem.readU32(notificationPtr) < _layout.desktopNotificationSize) { + return; + } + callback(( + title: _readString( + notificationPtr + _layout.desktopNotificationTitle, + ), + body: _readString( + notificationPtr + _layout.desktopNotificationBody, + ), + )); + } on Object catch (error, stackTrace) { + _captureCallbackError(error, stackTrace); + } + }).toJS, + ['i32', 'i32', 'i32'], + reuseIndex: reuseIndex, + ); + map[option] = (index, callback); + _exports.ghostty_terminal_set(handle, option.value, index); + } + @override void terminalSetOnTitleChanged(int handle, VoidCallback? callback) { final map = _callbacks.putIfAbsent(handle, () => {}); @@ -4380,6 +4419,12 @@ class WasmBindings implements GhosttyBindings { return (.fromValue(result), value); } + String _readString(int pointer) { + final data = _mem.readPtr(pointer); + final length = _mem.readU32(pointer + _layout.stringLen); + return length == 0 ? '' : utf8.decode(_mem.readBytes(data, length)); + } + CResult