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
7 changes: 5 additions & 2 deletions lib/src/binding/scheduler_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -637,11 +637,14 @@ mixin SchedulerBinding on NoctermBinding {
// Execute persistent frame callbacks
// Subclasses (like TerminalBinding) set currentFrameBuildEnd/LayoutEnd/PaintEnd
// during their _drawFrameCallback to provide accurate phase timings.
NoctermTimeline.startSync('Build');
// The 'Build' NoctermTimeline section is opened INSIDE
// TerminalBinding's _drawFrameCallback around the actual
// `super.drawFrame()` call (the buildScope + finalizeTree),
// not here — opening it here would also enclose the layout
// and paint the callback runs, which is misleading.
for (final callback in List<FrameCallback>.of(_persistentCallbacks)) {
_invokeFrameCallback(callback, _currentFrameTimeStamp!);
}
NoctermTimeline.finishSync(); // Build

// Use subclass-provided timing if available, otherwise use now
final buildEnd =
Expand Down
13 changes: 12 additions & 1 deletion lib/src/binding/terminal_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1328,8 +1328,15 @@ class TerminalBinding extends NoctermBinding

t0 = DateTime.now().microsecondsSinceEpoch;

// Build phase - handled by BuildOwner via persistent callback
// Build phase - handled by BuildOwner via persistent callback.
// Wrapped in `NoctermTimeline` here (not in
// `SchedulerBinding.handleDrawFrame`, which surrounds the
// entire persistent-callback loop and so would also count
// the layout and paint time we run later) so the per-section
// report reflects the actual build time, not the whole frame.
NoctermTimeline.startSync('Build');
super.drawFrame();
NoctermTimeline.finishSync(); // Build

t1 = DateTime.now().microsecondsSinceEpoch;
// Report build end time to scheduler for FrameTiming
Expand Down Expand Up @@ -1364,11 +1371,13 @@ class TerminalBinding extends NoctermBinding
}

// Layout phase
NoctermTimeline.startSync('Layout');
renderObject.layout(BoxConstraints.tight(
Size(size.width.toDouble(), size.height.toDouble())));

// Flush layout pipeline
pipelineOwner.flushLayout();
NoctermTimeline.finishSync(); // Layout

t3 = DateTime.now().microsecondsSinceEpoch;
// Report layout end time to scheduler for FrameTiming
Expand All @@ -1378,8 +1387,10 @@ class TerminalBinding extends NoctermBinding
pipelineOwner.flushPaint();

// Paint phase - paint directly to buffer
NoctermTimeline.startSync('Paint');
final canvas = TerminalCanvas(buffer, screenRect);
renderObject.paintWithContext(canvas, Offset.zero);
NoctermTimeline.finishSync(); // Paint
}

t4 = DateTime.now().microsecondsSinceEpoch;
Expand Down
9 changes: 6 additions & 3 deletions lib/src/components/basic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -436,10 +436,13 @@ class RenderConstrainedBox extends RenderObject

@override
void performLayout() {
final enforcedConstraints = _additionalConstraints.enforce(constraints);
if (child != null) {
// Apply additional constraints using enforce method
child!.layout(_additionalConstraints.enforce(constraints),
parentUsesSize: true);
child!.layout(
enforcedConstraints,
parentUsesSize: true,
);

// Position child at origin
final BoxParentData childParentData = child!.parentData as BoxParentData;
Expand All @@ -449,7 +452,7 @@ class RenderConstrainedBox extends RenderObject
size = child!.size;
} else {
// If no child, constrain to smallest size allowed by enforced constraints
size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
size = enforcedConstraints.constrain(Size.zero);
}
}

Expand Down
26 changes: 18 additions & 8 deletions lib/src/components/decorated_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,15 @@ class RenderDecoratedBox extends RenderObject
BoxDecoration get decoration => _decoration;
set decoration(BoxDecoration value) {
if (_decoration != value) {
final oldInset = _borderInsetFor(_decoration);
final newInset = _borderInsetFor(value);
final affectsLayout = oldInset != newInset;
_decoration = value;
markNeedsLayout();
if (affectsLayout) {
markNeedsLayout();
} else {
markNeedsPaint();
}
}
}

Expand All @@ -448,13 +455,7 @@ class RenderDecoratedBox extends RenderObject

@override
void performLayout() {
// Calculate border insets
double borderInset = 0;
if (_decoration.border != null && !_decoration.border!.hasNoBorder) {
// In terminal, we can only draw 1-character wide borders
// So we treat any border as taking up 1 unit of space
borderInset = 1;
}
final borderInset = _borderInsetFor(_decoration);

if (child != null) {
// Deflate constraints by border width
Expand All @@ -480,6 +481,15 @@ class RenderDecoratedBox extends RenderObject
}
}

double _borderInsetFor(BoxDecoration decoration) {
if (decoration.border != null && !decoration.border!.hasNoBorder) {
// In terminal, we can only draw 1-character wide borders, so any
// visible border reserves one cell regardless of color or style.
return 1;
}
return 0;
}

void _paintDecoration(TerminalCanvas canvas, Offset offset) {
// Skip painting if size is non-finite (e.g. from unconstrained scroll views)
if (!size.width.isFinite || !size.height.isFinite) return;
Expand Down
9 changes: 9 additions & 0 deletions lib/src/components/render_paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ class RenderParagraph extends RenderObject with Selectable {
InlineSpan get text => _text;
set text(InlineSpan value) {
if (_text == value) return;
final oldPlainText = _text.toPlainText();
final newPlainText = value.toPlainText();
_text = value;
_cachedSegments = null;

if (_layoutResult != null && oldPlainText == newPlainText) {
_styledLines = _mapSegmentsToLines(_segments, _layoutResult!.lines);
markNeedsPaint();
return;
}

markNeedsLayout();
}

Expand Down
5 changes: 4 additions & 1 deletion lib/src/components/render_stack.dart
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ class RenderStack extends RenderObject

if (!childParentData.isPositioned) {
hasNonPositionedChildren = true;
child.layout(nonPositionedConstraints, parentUsesSize: true);
child.layout(
nonPositionedConstraints,
parentUsesSize: fit != stack_lib.StackFit.expand,
);

final Size childSize = child.size;
width = math.max(width, childSize.width);
Expand Down
35 changes: 26 additions & 9 deletions lib/src/components/render_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,24 @@ class RenderText extends RenderObject with Selectable {
String get text => _text;
set text(String value) {
if (_text == value) return;
final oldSize = hasSize ? size : null;
final oldConstraints = hasSize ? constraints : null;
final hadLayout = _layoutResult != null;
_text = value;

if (hadLayout && oldSize != null && oldConstraints != null) {
final newLayout = _layoutText(oldConstraints);
final newSize = oldConstraints.constrain(Size(
newLayout.actualWidth.toDouble(),
newLayout.actualHeight.toDouble(),
));
if (newSize == oldSize) {
_layoutResult = newLayout;
markNeedsPaint();
return;
}
}

markNeedsLayout();
}

Expand Down Expand Up @@ -81,26 +98,26 @@ class RenderText extends RenderObject with Selectable {
@override
bool hitTestSelf(Offset position) => true;

@override
void performLayout() {
// For text alignment to work properly, we need to use the actual constraint width
// When in a Column without stretch, we get infinite width, so we use intrinsic width
TextLayoutConfig _layoutConfigFor(BoxConstraints constraints) {
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth.toInt()
: double.maxFinite.toInt();

// Debug: print constraint info
// print('RenderText layout: text="$_text", constraints=$constraints, maxWidth=$maxWidth');

final config = TextLayoutConfig(
return TextLayoutConfig(
softWrap: _softWrap,
overflow: _overflow,
textAlign: _textAlign,
maxLines: _maxLines,
maxWidth: maxWidth,
);
}

TextLayoutResult _layoutText(BoxConstraints constraints) =>
TextLayoutEngine.layout(_text, _layoutConfigFor(constraints));

_layoutResult = TextLayoutEngine.layout(_text, config);
@override
void performLayout() {
_layoutResult = _layoutText(constraints);

size = constraints.constrain(Size(
_layoutResult!.actualWidth.toDouble(),
Expand Down
136 changes: 136 additions & 0 deletions lib/src/foundation/layout_profiler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/// Per-render-object layout profiler.
///
/// Accumulates time spent in [RenderObject.layout] (and its recursive
/// descendants) keyed by the runtime type of the render object. When
/// the host's [FrameProfiler] is recording, [setActive] is flipped on
/// and the per-type counts roll up into the next call to [collect].
///
/// Cheap when inactive: a single bool check per layout call.
///
/// The hot-path is in [RenderObject.layout], which the framework
/// calls on every layout pass. We avoid allocating per call by
/// keeping a fixed-size array of "hot type slots" (the most common
/// render object types like RenderConstrainedBox, RenderParagraph,
/// RenderPadding, etc.) and a Map for the long tail. The hot slot
/// cache is bypassed when inactive.
class NoctermLayoutProfiler {
NoctermLayoutProfiler._();

/// Single shared instance. The framework's [RenderObject.layout]
/// reads [isActive] on every call; the chat panel's
/// [FrameProfiler] flips it on when recording starts and back
/// off when recording stops.
static final NoctermLayoutProfiler instance = NoctermLayoutProfiler._();

/// Hot-path gate. When false, [RenderObject.layout] skips the
/// bookkeeping entirely and just runs the layout.
static bool isActive = false;

/// Per-type totals. Keyed by the render object's runtime type
/// string (e.g. `RenderParagraph`, `RenderConstrainedBox`).
/// Cleared by [reset] at the start of every recording.
final Map<String, _TypeStats> _byType = <String, _TypeStats>{};

/// Per-call records, kept so the report can surface the
/// `topSlowLayoutCalls` (the N individual layout calls that
/// took the longest). Capped at 200 to bound memory.
final List<_LayoutCall> _calls = <_LayoutCall>[];
int _nextCallId = 0;

/// Start recording. Idempotent.
void setActive() {
isActive = true;
}

/// Stop recording. The map and call list are kept around so
/// the host can call [collect] after flipping the flag off.
void setInactive() {
isActive = false;
}

/// Wipe per-type and per-call accumulators. Called by the
/// host at the start of a recording.
void reset() {
_byType.clear();
_calls.clear();
_nextCallId = 0;
}

/// Called from [RenderObject.layout] on every invocation while
/// [isActive] is true. [us] is the elapsed microseconds for
/// that one layout() call (which is one render object's
/// performLayout, NOT the cumulative subtree time — see
/// note in [collect]).
void record(String typeName, int us) {
final stats = _byType.putIfAbsent(typeName, () => _TypeStats());
stats.totalUs += us;
stats.count++;
if (stats.maxUs < us) stats.maxUs = us;

// Keep the top slowest individual calls so the report can
// surface "this one Row layout() call took 3.2ms" rather
// than only averages. Capped to keep memory bounded.
if (_calls.length < 200) {
_calls.add(_LayoutCall(_nextCallId++, typeName, us));
} else {
// Find the smallest in the list and replace it if the new
// one is bigger. Simple linear scan — 200 entries is cheap.
var minIdx = 0;
for (var i = 1; i < _calls.length; i++) {
if (_calls[i].us < _calls[minIdx].us) minIdx = i;
}
if (_calls[minIdx].us < us) {
_calls[minIdx] = _LayoutCall(_nextCallId++, typeName, us);
}
}
}

/// Drain accumulators into a JSON-friendly map suitable for
/// embedding in a profile report under `byLayout`.
Map<String, dynamic> collect() {
final byType = <String, dynamic>{};
for (final entry in _byType.entries) {
final s = entry.value;
byType[entry.key] = {
'count': s.count,
'totalUs': s.totalUs,
'avgUs': s.count == 0 ? 0 : s.totalUs ~/ s.count,
'maxUs': s.maxUs,
};
}
// Sort the per-type entries by totalUs descending so the
// biggest offenders surface at the top of the report.
final sorted = byType.entries.toList()
..sort((a, b) =>
(b.value['totalUs'] as int).compareTo(a.value['totalUs'] as int));

// Top-N individual layout calls (capped at 20 in the
// report itself; the underlying list may be longer for
// debugging).
final calls = List<_LayoutCall>.from(_calls)
..sort((a, b) => b.us.compareTo(a.us));
final topCalls = calls
.take(20)
.map((c) => {'id': c.id, 'type': c.type, 'us': c.us})
.toList();

return {
'byType': Map<String, dynamic>.fromEntries(sorted),
'topSlowCalls': topCalls,
'totalCalls': _calls.length < 200 ? _calls.length : 200,
};
}
}

class _TypeStats {
int count = 0;
int totalUs = 0;
int maxUs = 0;
}

class _LayoutCall {
_LayoutCall(this.id, this.type, this.us);
final int id;
final String type;
final int us;
}
1 change: 1 addition & 0 deletions lib/src/framework/framework.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:meta/meta.dart';
import 'package:nocterm/src/components/basic.dart';
import 'package:nocterm/src/foundation/persistent_hash_map.dart';
import 'package:nocterm/src/foundation/nocterm_error.dart';
import 'package:nocterm/src/foundation/layout_profiler.dart';
import 'package:nocterm/src/rectangle.dart';
import 'package:nocterm/src/size.dart';
import 'package:nocterm/src/style.dart';
Expand Down
Loading
Loading