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
6 changes: 6 additions & 0 deletions packages/flterm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
measured grid; assigning it later immediately reports that grid.
Cell-pixel-only changes skip the callback, and in-band output is emitted
first.
- **Accessible viewport**: `TerminalView` exposes a screen-reader semantics
node (`semanticsLabel`/`semanticsHint`) whose value follows the visible,
non-concealed terminal viewport. Snapshots are captured from the render
pipeline after each paint and coalesced through a bounded 100 ms interval so
output updates never announce as a live region. Set `semanticsLabel: null`
to delegate the accessible surface to an embedding application.

### Fixed

Expand Down
25 changes: 25 additions & 0 deletions packages/flterm/lib/src/rendering/frame_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,31 @@ class FrameBuilder {
/// without a new terminal render state.
void refreshCursorGlyph() => _cursorBuilder.refreshGlyph();

/// Emits the visible, non-concealed viewport as plain text.
///
/// Wide cells are widened, SGR 8 invisible cells are omitted, and wrapped
/// rows are joined. Used by [TerminalView] to publish accessible semantics
/// snapshots after terminal state has synchronized for paint.
String semanticsText() {
_rows.reset(_renderState);
final output = StringBuffer();

while (_rows.next()) {
_cells.reset(_rows);
final line = StringBuffer();
while (_cells.next()) {
if (_cells.wide == CellWidth.spacerTail) continue;
final width = _cells.wide == CellWidth.wide ? 2 : 1;
final content = _cells.style.invisible ? '' : _cells.content;
line.write(content.isEmpty ? ' ' * width : content);
}
output.write(line.toString().trimRight());
if (!_rows.wrap) output.writeln();
}

return output.toString().trimRight();
}

/// Syncs terminal state into paint-ready buffers.
void sync(
Terminal terminal, {
Expand Down
2 changes: 2 additions & 0 deletions packages/flterm/lib/src/rendering/render_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ final class RenderPipeline {

void refreshCursorGlyph() => _frameBuilder.refreshCursorGlyph();

String semanticsText() => _frameBuilder.semanticsText();

/// Syncs terminal cells and render-only preedit state into paint buffers.
///
/// [preeditText] does not enter libghostty state. The frame builder overlays
Expand Down
44 changes: 43 additions & 1 deletion packages/flterm/lib/src/rendering/terminal_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ final class TerminalRenderer extends LeafRenderObjectWidget {
/// Requests a terminal viewport row derived from Flutter scroll layout.
final ValueChanged<int> onViewportRowChanged;

/// Monotonically increasing request for an accessible viewport snapshot.
final int semanticsGeneration;

/// Receives accessible text after terminal state has synchronized for paint.
final ValueChanged<String>? onSemanticsText;

const TerminalRenderer({
super.key,
required this.frameSource,
Expand All @@ -112,6 +118,8 @@ final class TerminalRenderer extends LeafRenderObjectWidget {
this.linkSnapshot = .empty,
required this.onGeometryChanged,
required this.onViewportRowChanged,
this.semanticsGeneration = 0,
this.onSemanticsText,
});

@override
Expand All @@ -130,6 +138,8 @@ final class TerminalRenderer extends LeafRenderObjectWidget {
preeditText: preeditText,
linkSnapshot: linkSnapshot,
focused: focused,
semanticsGeneration: semanticsGeneration,
onSemanticsText: onSemanticsText,
);
}

Expand Down Expand Up @@ -169,7 +179,9 @@ final class TerminalRenderer extends LeafRenderObjectWidget {
..focused = focused
..blinkVisible = blinkVisible
..preeditText = preeditText
..linkSnapshot = linkSnapshot;
..linkSnapshot = linkSnapshot
..semanticsGeneration = semanticsGeneration
..onSemanticsText = onSemanticsText;
}
}

Expand Down Expand Up @@ -216,6 +228,9 @@ final class TerminalRenderBox extends RenderBox {
var _preeditText = '';
bool? _primaryStickToBottom;
AtlasPool _atlasPool;
int _semanticsGeneration;
late int _capturedSemanticsGeneration;
ValueChanged<String>? _onSemanticsText;
var _stickToBottom = true;
var _surfacePadding = EdgeInsets.zero;

Expand All @@ -233,8 +248,15 @@ final class TerminalRenderBox extends RenderBox {
this._preeditText = '',
required this._onGeometryChanged,
required this._onViewportRowChanged,
int semanticsGeneration = 0,
ValueChanged<String>? onSemanticsText,
}) : _surfacePadding = surfacePadding,
_lastSurfacePadding = surfacePadding,
_semanticsGeneration = semanticsGeneration,
_capturedSemanticsGeneration = onSemanticsText == null
? semanticsGeneration
: semanticsGeneration - 1,
_onSemanticsText = onSemanticsText,
_paintState = PaintState(theme, metrics)
..blinkVisible = blinkVisible
..cursorFocused = focused {
Expand Down Expand Up @@ -362,6 +384,21 @@ final class TerminalRenderBox extends RenderBox {
set onViewportRowChanged(ValueChanged<int> value) =>
_onViewportRowChanged = value;

set onSemanticsText(ValueChanged<String>? value) {
if (_onSemanticsText == value) return;
_onSemanticsText = value;
if (value != null) {
_capturedSemanticsGeneration = _semanticsGeneration - 1;
markNeedsPaint();
}
}

set semanticsGeneration(int value) {
if (_semanticsGeneration == value) return;
_semanticsGeneration = value;
if (_onSemanticsText != null) markNeedsPaint();
}

bool get focused => _paintState.cursorFocused;

set focused(bool value) {
Expand Down Expand Up @@ -470,6 +507,11 @@ final class TerminalRenderBox extends RenderBox {
@override
void paint(PaintingContext context, Offset offset) {
_syncFrameState();
if (_onSemanticsText != null &&
_capturedSemanticsGeneration != _semanticsGeneration) {
_capturedSemanticsGeneration = _semanticsGeneration;
_onSemanticsText!(_pipeline.semanticsText());
}

final canvas = context.canvas;

Expand Down
126 changes: 125 additions & 1 deletion packages/flterm/lib/src/view/terminal_view.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'dart:async';

import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

Expand Down Expand Up @@ -103,6 +105,15 @@ class TerminalView extends StatefulWidget {
/// corresponding default.
final Map<ShortcutActivator, Intent>? shortcuts;

/// Accessibility label for the terminal semantics node.
///
/// Set to null to omit flterm's semantics node when an embedding application
/// provides an equivalent accessible terminal surface.
final String? semanticsLabel;

/// Accessibility hint for focusing terminal input.
final String? semanticsHint;

/// Raw TTF/OTF font file bytes for exact metric extraction.
///
/// When provided, takes priority over automatic font resolution. The
Expand Down Expand Up @@ -130,6 +141,8 @@ class TerminalView extends StatefulWidget {
this.mouseAutoHide = .onInput,
this.linkSettings = const LinkSettings(),
this.gestureSettings = const TerminalGestureSettings(),
this.semanticsLabel = 'Terminal',
this.semanticsHint = 'Activate to focus terminal input',
});

@override
Expand All @@ -138,10 +151,13 @@ class TerminalView extends StatefulWidget {

final class _TerminalViewState extends State<TerminalView>
with WidgetsBindingObserver {
static const _semanticsUpdateInterval = Duration(milliseconds: 100);

final _rendererKey = GlobalKey();
final _links = LinkInteraction();
final _cursorBlink = CursorBlink();
final _mouseCursorHidden = ValueNotifier(false);
final _semanticsText = ValueNotifier('');
late final _mouseInteraction = Listenable.merge([_links, _mouseCursorHidden]);

late ViewAttachment _attachment;
Expand All @@ -151,6 +167,10 @@ final class _TerminalViewState extends State<TerminalView>
late CellMetrics _metrics;
var _ownsFocusNode = false;
var _ownsScrollController = false;
Timer? _semanticsTimer;
var _semanticsGeneration = 0;
String? _pendingSemanticsText;
var _semanticsNotificationScheduled = false;
Uint8List? _resolvedFontData;
late TerminalScrollController _scrollController;
late TerminalTheme _theme;
Expand Down Expand Up @@ -206,13 +226,25 @@ final class _TerminalViewState extends State<TerminalView>
void didUpdateWidget(TerminalView oldWidget) {
super.didUpdateWidget(oldWidget);

if (widget.semanticsLabel != oldWidget.semanticsLabel) {
if (widget.semanticsLabel == null) {
_semanticsTimer?.cancel();
_semanticsTimer = null;
_pendingSemanticsText = null;
_semanticsText.value = '';
} else {
_scheduleSemanticsUpdate();
}
}

final controllerChanged = widget.controller != oldWidget.controller;
final focusNodeChanged = widget.focusNode != oldWidget.focusNode;
final scrollControllerChanged =
widget.scrollController != oldWidget.scrollController;

if (controllerChanged) {
_attachment.removeListener(_onControllerChanged);
_attachment.terminal.removeListener(_notifySemanticsChanged);
_attachment.dispose();
} else if (focusNodeChanged) {
_attachment.detach();
Expand All @@ -237,9 +269,15 @@ final class _TerminalViewState extends State<TerminalView>
}

if (controllerChanged) {
_semanticsTimer?.cancel();
_semanticsTimer = null;
_pendingSemanticsText = null;
_semanticsText.value = '';
_attachment = ViewAttachment(_controller);
_attachment.addListener(_onControllerChanged);
_attachment.terminal.addListener(_notifySemanticsChanged);
_links.invalidateContent();
_scheduleSemanticsUpdate();
}

if (controllerChanged || focusNodeChanged || scrollControllerChanged) {
Expand Down Expand Up @@ -285,10 +323,16 @@ final class _TerminalViewState extends State<TerminalView>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_semanticsTimer?.cancel();
SemanticsBinding.instance.removeSemanticsEnabledListener(
_handleSemanticsEnabledChanged,
);
_cursorBlink.dispose();
_mouseCursorHidden.dispose();
_semanticsText.dispose();
_links.dispose();
_attachment.removeListener(_onControllerChanged);
_attachment.terminal.removeListener(_notifySemanticsChanged);
_attachment.dispose();
if (_ownsFocusNode) _focusNode.dispose();
_scrollController.removeListener(_onScrollChanged);
Expand All @@ -301,7 +345,12 @@ final class _TerminalViewState extends State<TerminalView>
super.initState();
WidgetsBinding.instance.addObserver(this);

SemanticsBinding.instance.addSemanticsEnabledListener(
_handleSemanticsEnabledChanged,
);

_attachment = ViewAttachment(_controller);
_attachment.terminal.addListener(_notifySemanticsChanged);
_focusNode = widget.focusNode ?? FocusNode();
_ownsFocusNode = widget.focusNode == null;

Expand All @@ -317,10 +366,11 @@ final class _TerminalViewState extends State<TerminalView>
_scrollController.addListener(_onScrollChanged);
_attachment.addListener(_onControllerChanged);
_syncLinkInteraction();
_scheduleSemanticsUpdate();
}

Widget _build(AtlasPool atlasPool) {
return GestureDetector(
final terminal = GestureDetector(
behavior: .translucent,
onTap: _attachment.requestFocus,
child: ColoredBox(
Expand All @@ -344,6 +394,26 @@ final class _TerminalViewState extends State<TerminalView>
),
),
);
final semanticsLabel = widget.semanticsLabel;
if (semanticsLabel == null || !SemanticsBinding.instance.semanticsEnabled) {
return terminal;
}
return ListenableBuilder(
listenable: _semanticsText,
child: terminal,
builder: (context, child) => Semantics(
container: true,
excludeSemantics: true,
label: semanticsLabel,
value: _semanticsText.value,
hint: widget.semanticsHint,
focusable: true,
focused: _focusNode.hasFocus,
onTap: _attachment.requestFocus,
onFocus: _attachment.requestFocus,
child: child,
),
);
}

Widget _buildInteraction(
Expand Down Expand Up @@ -392,6 +462,12 @@ final class _TerminalViewState extends State<TerminalView>
linkSnapshot: _links.snapshot(),
onGeometryChanged: _handleResize,
onViewportRowChanged: _attachment.handleViewportRowChanged,
semanticsGeneration: _semanticsGeneration,
onSemanticsText:
SemanticsBinding.instance.semanticsEnabled &&
widget.semanticsLabel != null
? _handleSemanticsText
: null,
),
),
),
Expand Down Expand Up @@ -487,9 +563,57 @@ final class _TerminalViewState extends State<TerminalView>
void _onScrollChanged() {
_syncBlink();
_links.invalidateContent();
if (_controller.activeScreen == .primary) _scheduleSemanticsUpdate();
_updateTextInputGeometry();
}

void _handleSemanticsEnabledChanged() {
if (!mounted) return;
if (SemanticsBinding.instance.semanticsEnabled) {
_scheduleSemanticsUpdate();
} else {
_semanticsTimer?.cancel();
_semanticsTimer = null;
_pendingSemanticsText = null;
_semanticsText.value = '';
}
setState(() {});
}

void _notifySemanticsChanged() => _scheduleSemanticsUpdate();

void _scheduleSemanticsUpdate() {
if (_semanticsTimer != null ||
widget.semanticsLabel == null ||
!SemanticsBinding.instance.semanticsEnabled) {
return;
}
_semanticsTimer = Timer(_semanticsUpdateInterval, () {
_semanticsTimer = null;
if (!mounted ||
widget.semanticsLabel == null ||
!SemanticsBinding.instance.semanticsEnabled) {
return;
}
setState(() => _semanticsGeneration++);
});
}

void _handleSemanticsText(String text) {
_pendingSemanticsText = text;
if (_semanticsNotificationScheduled) return;
_semanticsNotificationScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_semanticsNotificationScheduled = false;
final pending = _pendingSemanticsText;
_pendingSemanticsText = null;
if (!mounted || pending == null || pending == _semanticsText.value) {
return;
}
_semanticsText.value = pending;
});
}

/// Asynchronously resolves font data and recomputes metrics when found.
Future<void> _resolveFontData(String fontFamily) async {
if (!mounted ||
Expand Down