diff --git a/packages/flterm/lib/src/foundation/terminal_theme.dart b/packages/flterm/lib/src/foundation/terminal_theme.dart index 6eb7a941..f971ddb0 100644 --- a/packages/flterm/lib/src/foundation/terminal_theme.dart +++ b/packages/flterm/lib/src/foundation/terminal_theme.dart @@ -82,16 +82,27 @@ final class CursorTheme { /// Cursor opacity from 0.0 (invisible) to 1.0 (fully opaque). final double opacity; + /// Width of a bar cursor as a fraction of one terminal cell. + /// + /// Must be greater than zero and at most one. The rendered width is never + /// less than one logical pixel. + final double barWidthRatio; + const CursorTheme({ this.shape = CursorShape.block, this.color, this.text, this.blinkInterval = const Duration(milliseconds: 600), this.opacity = 1.0, - }); + this.barWidthRatio = 1 / 6, + }) : assert( + barWidthRatio > 0 && barWidthRatio <= 1, + 'barWidthRatio must be greater than zero and at most one', + ); @override - int get hashCode => Object.hash(shape, color, text, blinkInterval, opacity); + int get hashCode => + Object.hash(shape, color, text, blinkInterval, opacity, barWidthRatio); @override bool operator ==(Object other) => @@ -100,12 +111,14 @@ final class CursorTheme { other.color == color && other.text == text && other.blinkInterval == blinkInterval && - other.opacity == opacity; + other.opacity == opacity && + other.barWidthRatio == barWidthRatio; @override String toString() => 'CursorTheme(shape: $shape, color: $color, text: $text, ' - 'blinkInterval: $blinkInterval, opacity: $opacity)'; + 'blinkInterval: $blinkInterval, opacity: $opacity, ' + 'barWidthRatio: $barWidthRatio)'; /// Linearly interpolates between two cursor themes. /// @@ -125,6 +138,7 @@ final class CursorTheme { )!.round(), ), opacity: lerpDouble(a.opacity, b.opacity, t)!, + barWidthRatio: lerpDouble(a.barWidthRatio, b.barWidthRatio, t)!, ); } } diff --git a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart index bd88bcbb..5d14a94e 100644 --- a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart @@ -110,10 +110,11 @@ class CursorPainter implements TerminalPainter { } case .blockHollow: + const strokeWidth = 1.5; _paint ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawRect(rect, _paint); + ..strokeWidth = strokeWidth; + canvas.drawRect(rect.deflate(strokeWidth / 2), _paint); case .underline: final thickness = metrics.underlineThickness; @@ -124,7 +125,8 @@ class CursorPainter implements TerminalPainter { ); case .bar: - final barWidth = (metrics.cellWidth / 6).clamp(1.0, 3.0); + final barWidth = (metrics.cellWidth * _state.theme.cursor.barWidthRatio) + .clamp(1.0, metrics.cellWidth); canvas.drawRect( .fromLTWH(rect.left, rect.top, barWidth, rect.height), _paint, diff --git a/packages/flterm/lib/src/rendering/terminal_renderer.dart b/packages/flterm/lib/src/rendering/terminal_renderer.dart index 93d08820..cbfd00ad 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -18,9 +18,9 @@ import 'terminal_render_pipeline.dart'; /// terminal resize), frame sync, and a paint stack. /// /// Sizing is determined by the parent constraints and cell metrics: the -/// widget computes how many columns and rows fit, then sizes itself to -/// exactly that grid. When the grid dimensions change, the terminal is -/// resized and [onResize] fires. +/// widget fills the bounded viewport while the terminal grid uses only the +/// whole columns and rows that fit inside it. When the grid dimensions change, +/// the terminal is resized and [onResize] fires. /// /// ```dart /// TerminalRenderer( @@ -433,6 +433,7 @@ class TerminalRenderBox extends RenderBox { canvas.save(); canvas.translate(offset.dx, offset.dy); + canvas.clipRect(Offset.zero & size); _pipeline.paint(canvas); canvas.restore(); } @@ -441,16 +442,20 @@ class TerminalRenderBox extends RenderBox { void performLayout() { _performingLayout = true; - final maxW = constraints.hasBoundedWidth ? constraints.maxWidth : 0.0; - final maxH = constraints.hasBoundedHeight ? constraints.maxHeight : 0.0; - final (newCols, newRows) = _paintState.metrics.gridSize(maxW, maxH); - size = constraints.constrain( Size( - newCols * _paintState.metrics.cellWidth, - newRows * _paintState.metrics.cellHeight, + constraints.hasBoundedWidth + ? constraints.maxWidth + : constraints.minWidth, + constraints.hasBoundedHeight + ? constraints.maxHeight + : constraints.minHeight, ), ); + final (newCols, newRows) = _paintState.metrics.gridSize( + size.width, + size.height, + ); final dpr = _currentDevicePixelRatio; final atlasReconfigured = _acquireAtlasForCurrentConfig(dpr: dpr); diff --git a/packages/flterm/test/foundation/terminal_theme_test.dart b/packages/flterm/test/foundation/terminal_theme_test.dart index 372b699c..85befd62 100644 --- a/packages/flterm/test/foundation/terminal_theme_test.dart +++ b/packages/flterm/test/foundation/terminal_theme_test.dart @@ -13,6 +13,7 @@ void main() { expect(cursor.color, isNull); expect(cursor.blinkInterval, const Duration(milliseconds: 600)); expect(cursor.opacity, 1.0); + expect(cursor.barWidthRatio, 1 / 6); }); test('stores custom values', () { @@ -22,6 +23,7 @@ void main() { text: DynamicColor.cellBackground(), blinkInterval: Duration(milliseconds: 500), opacity: 0.7, + barWidthRatio: 0.5, ); expect(cursor.shape, CursorShape.bar); @@ -29,6 +31,12 @@ void main() { expect(cursor.text, const DynamicColor.cellBackground()); expect(cursor.blinkInterval, const Duration(milliseconds: 500)); expect(cursor.opacity, 0.7); + expect(cursor.barWidthRatio, 0.5); + }); + + test('requires a positive ratio within one cell', () { + expect(() => CursorTheme(barWidthRatio: 0), throwsAssertionError); + expect(() => CursorTheme(barWidthRatio: 1.1), throwsAssertionError); }); }); @@ -41,6 +49,7 @@ void main() { expect(a.hashCode, b.hashCode); expect(a, isNot(equals(const CursorTheme(shape: CursorShape.bar)))); expect(a, isNot(equals(const CursorTheme(opacity: 0.5)))); + expect(a, isNot(equals(const CursorTheme(barWidthRatio: 0.5)))); expect( a, isNot(equals(const CursorTheme(text: DynamicColor.cellBackground()))), @@ -54,23 +63,27 @@ void main() { color: DynamicColor.fixed(Color(0xFF000000)), blinkInterval: Duration(milliseconds: 400), opacity: 0.2, + barWidthRatio: 0.2, ); const b = CursorTheme( shape: CursorShape.bar, color: DynamicColor.fixed(Color(0xFFFFFFFF)), blinkInterval: Duration(milliseconds: 800), opacity: 0.8, + barWidthRatio: 0.6, ); final at0 = CursorTheme.lerp(a, b, 0.0)!; expect(at0.shape, CursorShape.block); expect(at0.blinkInterval, const Duration(milliseconds: 400)); expect(at0.opacity, 0.2); + expect(at0.barWidthRatio, 0.2); final at1 = CursorTheme.lerp(a, b, 1.0)!; expect(at1.shape, CursorShape.bar); expect(at1.blinkInterval, const Duration(milliseconds: 800)); expect(at1.opacity, 0.8); + expect(at1.barWidthRatio, 0.6); }); test('interpolates opacity and snaps shape at midpoint', () { @@ -80,6 +93,7 @@ void main() { final mid = CursorTheme.lerp(a, b, 0.5)!; expect(mid.opacity, 0.5); + expect(mid.barWidthRatio, 1 / 6); expect(CursorTheme.lerp(a, b, 0.49)!.shape, CursorShape.block); expect(CursorTheme.lerp(a, b, 0.5)!.shape, CursorShape.bar); }); diff --git a/packages/flterm/test/rendering/painters/cursor_painter_test.dart b/packages/flterm/test/rendering/painters/cursor_painter_test.dart index 83283d2c..cda67ce2 100644 --- a/packages/flterm/test/rendering/painters/cursor_painter_test.dart +++ b/packages/flterm/test/rendering/painters/cursor_painter_test.dart @@ -4,12 +4,13 @@ library; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:flterm/src/foundation.dart' show CellMetrics, TerminalTheme; +import 'package:flterm/src/foundation.dart' + show CellMetrics, CursorTheme, TerminalTheme; import 'package:flterm/src/rendering/atlas/atlas.dart'; import 'package:flterm/src/rendering/paint_state.dart'; import 'package:flterm/src/rendering/painters/cursor_painter.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' show Cursor; +import 'package:libghostty/libghostty.dart' show Cursor, CursorShape, Position; void main() { group('CursorPainter', () { @@ -27,13 +28,18 @@ void main() { ); } - Future render({bool preeditActive = false}) async { + Future render({ + bool preeditActive = false, + Cursor cursor = const Cursor(), + CursorTheme? cursorTheme, + }) async { final atlas = Atlas(config()); addTearDown(atlas.dispose); - final state = TerminalPaintState(TerminalTheme.dark(), metrics) + final theme = TerminalTheme.dark().copyWith(cursor: cursorTheme); + final state = TerminalPaintState(theme, metrics) ..cols = 2 ..rows = 1 - ..cursor = const Cursor() + ..cursor = cursor ..cursorColorArgb = 0xFF0000FF ..preeditActive = preeditActive; @@ -77,6 +83,28 @@ void main() { expect(cursorPixel, 0xFFFF0000); }); + + test('keeps hollow cursor stroke inside its cell', () async { + final bytes = await render( + cursor: const Cursor( + position: Position(row: 0, col: 1), + shape: CursorShape.blockHollow, + ), + ); + + expect(pixel(bytes, x: 12, y: 0), 0xFF0000FF); + expect(pixel(bytes, x: 15, y: 8), 0xFF0000FF); + }); + + test('uses the configured bar cursor width ratio', () async { + final bytes = await render( + cursor: const Cursor(shape: CursorShape.bar), + cursorTheme: const CursorTheme(barWidthRatio: 0.5), + ); + + expect(pixel(bytes, x: 3, y: 8), 0xFF0000FF); + expect(pixel(bytes, x: 4, y: 8), 0xFFFF0000); + }); }); }); } diff --git a/packages/flterm/test/rendering/terminal_renderer_test.dart b/packages/flterm/test/rendering/terminal_renderer_test.dart index b4df5d07..34ec9dc2 100644 --- a/packages/flterm/test/rendering/terminal_renderer_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_test.dart @@ -81,32 +81,62 @@ void main() { tearDown(() => terminal.dispose()); - testWidgets('snaps width to whole-cell multiples', (tester) async { + testWidgets('fills bounded width while grid uses whole cells', ( + tester, + ) async { + int? reportedCols; await tester.pumpWidget( wrap( terminal, maxWidth: 163.7, maxHeight: defaultRows * defaultMetrics.cellHeight, + onResize: (cols, _) => reportedCols = cols, ), ); final box = tester.renderObject( find.byType(TerminalRenderer), ); - expect(box.size.width, 160.0); + expect(box.size.width, 163.7); + expect(reportedCols, 20); }); - testWidgets('snaps height to whole-cell multiples', (tester) async { + testWidgets('fills bounded height while grid uses whole cells', ( + tester, + ) async { + int? reportedRows; await tester.pumpWidget( wrap( terminal, maxWidth: defaultCols * defaultMetrics.cellWidth, maxHeight: 85.3, + onResize: (_, rows) => reportedRows = rows, ), ); final box = tester.renderObject( find.byType(TerminalRenderer), ); - expect(box.size.height, 80.0); + expect(box.size.height, 85.3); + expect(reportedRows, 5); + }); + + testWidgets('viewport pixels change without redundant grid resize', ( + tester, + ) async { + final reported = <(int, int)>[]; + void recordResize(int cols, int rows) => reported.add((cols, rows)); + + await tester.pumpWidget( + wrap(terminal, maxWidth: 163.7, onResize: recordResize), + ); + await tester.pumpWidget( + wrap(terminal, maxWidth: 167.9, onResize: recordResize), + ); + + final box = tester.renderObject( + find.byType(TerminalRenderer), + ); + expect(box.size.width, 167.9); + expect(reported, [(20, defaultRows)]); }); testWidgets('metrics change triggers layout', (tester) async {