Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions packages/flterm/lib/src/foundation/terminal_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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.
///
Expand All @@ -125,6 +138,7 @@ final class CursorTheme {
)!.round(),
),
opacity: lerpDouble(a.opacity, b.opacity, t)!,
barWidthRatio: lerpDouble(a.barWidthRatio, b.barWidthRatio, t)!,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
23 changes: 14 additions & 9 deletions packages/flterm/lib/src/rendering/terminal_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions packages/flterm/test/foundation/terminal_theme_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -22,13 +23,20 @@ void main() {
text: DynamicColor.cellBackground(),
blinkInterval: Duration(milliseconds: 500),
opacity: 0.7,
barWidthRatio: 0.5,
);

expect(cursor.shape, CursorShape.bar);
expect(cursor.color, const DynamicColor.fixed(Color(0xFFFF0000)));
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);
});
});

Expand All @@ -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()))),
Expand All @@ -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', () {
Expand All @@ -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);
});
Expand Down
38 changes: 33 additions & 5 deletions packages/flterm/test/rendering/painters/cursor_painter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -27,13 +28,18 @@ void main() {
);
}

Future<ByteData> render({bool preeditActive = false}) async {
Future<ByteData> 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;

Expand Down Expand Up @@ -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);
});
});
});
}
38 changes: 34 additions & 4 deletions packages/flterm/test/rendering/terminal_renderer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<TerminalRenderBox>(
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<TerminalRenderBox>(
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<TerminalRenderBox>(
find.byType(TerminalRenderer),
);
expect(box.size.width, 167.9);
expect(reported, [(20, defaultRows)]);
});

testWidgets('metrics change triggers layout', (tester) async {
Expand Down