Skip to content

Commit dd47644

Browse files
authored
fix(ui): keep vertical scrollbar visible for long log lines (#45)
Prevent vertical scrollbar from being pushed outside the viewport when displaying long log entries with text wrapping disabled. Preserve horizontal scrolling while keeping scrollbar anchored to the container boundary. Fixes #23
1 parent e02ff85 commit dd47644

1 file changed

Lines changed: 98 additions & 108 deletions

File tree

lib/features/logs/presentation/components/log_viewer.dart

Lines changed: 98 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -886,124 +886,114 @@ class _LogViewerState extends State<LogViewer> {
886886
final contentWidth = _contentWidth(viewportWidth);
887887
final logViewport = _buildLogViewport(messageWidth);
888888

889-
return Stack(
889+
final innerColumn = Column(
890890
children: [
891-
Scrollbar(
892-
controller: _horizontalScrollController,
893-
thumbVisibility: true,
894-
notificationPredicate: (notification) =>
895-
notification.metrics.axis == Axis.horizontal,
896-
child: SingleChildScrollView(
897-
controller: _horizontalScrollController,
898-
scrollDirection: Axis.horizontal,
899-
child: SizedBox(
900-
width: contentWidth,
901-
child: Column(
902-
children: [
903-
_buildHeader(messageWidth),
904-
const Divider(height: 1, thickness: 1),
905-
Expanded(
906-
child: NotificationListener<ScrollNotification>(
907-
onNotification: (notification) {
908-
if (notification.metrics.axis != Axis.vertical) {
909-
return false;
910-
}
911-
final isUserScroll =
912-
notification is UserScrollNotification ||
913-
notification is ScrollStartNotification &&
914-
notification.dragDetails != null ||
915-
notification is ScrollUpdateNotification &&
916-
notification.dragDetails != null ||
917-
notification is OverscrollNotification &&
918-
notification.dragDetails != null;
919-
if (isUserScroll) {
920-
widget.onUserScroll?.call();
921-
}
922-
return false;
891+
_buildHeader(messageWidth),
892+
const Divider(height: 1, thickness: 1),
893+
Expanded(
894+
child: NotificationListener<ScrollNotification>(
895+
onNotification: (notification) {
896+
if (notification.metrics.axis != Axis.vertical) return false;
897+
final isUserScroll =
898+
notification is UserScrollNotification ||
899+
(notification is ScrollStartNotification &&
900+
notification.dragDetails != null) ||
901+
(notification is ScrollUpdateNotification &&
902+
notification.dragDetails != null) ||
903+
(notification is OverscrollNotification &&
904+
notification.dragDetails != null);
905+
if (isUserScroll) widget.onUserScroll?.call();
906+
return false;
907+
},
908+
child: GestureDetector(
909+
onScaleStart: (details) {
910+
_scaleBaseFontSize = PreferencesService.logFontSize;
911+
},
912+
onScaleUpdate: (details) {
913+
if (details.pointerCount < 2) return;
914+
final base =
915+
_scaleBaseFontSize ?? PreferencesService.logFontSize;
916+
PreferencesService.logFontSize = (base * details.scale)
917+
.roundToDouble();
918+
},
919+
onScaleEnd: (_) => _scaleBaseFontSize = null,
920+
child: Listener(
921+
onPointerDown: widget.rowSelectionMode
922+
? null
923+
: (event) {
924+
if ((event.buttons & kPrimaryButton) == 0) return;
925+
widget.onLogRowTap?.call();
923926
},
924-
child: Scrollbar(
925-
controller: widget.scrollController,
926-
thumbVisibility: true,
927-
child: GestureDetector(
928-
// Support pinch-to-zoom on trackpads / touchpads to change log font size.
929-
onScaleStart: (details) {
930-
// Record the base font size at gesture start.
931-
_scaleBaseFontSize =
932-
PreferencesService.logFontSize;
933-
},
934-
onScaleUpdate: (details) {
935-
// Only react when there are multiple pointers (pinch gesture).
936-
if (details.pointerCount < 2) return;
937-
final base =
938-
_scaleBaseFontSize ??
939-
PreferencesService.logFontSize;
940-
final target = base * details.scale;
941-
// Use integer steps to avoid jitter; PreferencesService
942-
// will clamp and avoid redundant writes.
943-
final rounded = target.roundToDouble();
944-
PreferencesService.logFontSize = rounded;
945-
},
946-
onScaleEnd: (_) {
947-
_scaleBaseFontSize = null;
948-
},
949-
child: Listener(
950-
onPointerDown: widget.rowSelectionMode
951-
? null
952-
: (event) {
953-
if ((event.buttons & kPrimaryButton) ==
954-
0) {
955-
return;
956-
}
957-
widget.onLogRowTap?.call();
958-
},
959-
onPointerUp: (event) =>
960-
_endRowSelectionDrag(event.pointer),
961-
onPointerCancel: (event) =>
962-
_endRowSelectionDrag(event.pointer),
963-
child: Theme(
964-
data: widget.rowSelectionMode
965-
? Theme.of(context).copyWith(
966-
textSelectionTheme:
967-
const TextSelectionThemeData(
968-
selectionColor:
969-
Colors.transparent,
970-
),
971-
)
972-
: Theme.of(context),
973-
child: SelectionArea(
974-
key: const ValueKey(
975-
'log-viewer-selection-area',
976-
),
977-
onSelectionChanged: (selectedContent) {
978-
widget.onSelectedTextChanged?.call(
979-
selectedContent?.plainText,
980-
);
981-
},
982-
contextMenuBuilder:
983-
(ctx, selectableRegionState) =>
984-
_buildSelectionContextMenu(
985-
ctx,
986-
selectableRegionState,
987-
),
988-
child: logViewport,
989-
),
990-
),
927+
onPointerUp: (event) => _endRowSelectionDrag(event.pointer),
928+
onPointerCancel: (event) =>
929+
_endRowSelectionDrag(event.pointer),
930+
child: Theme(
931+
data: widget.rowSelectionMode
932+
? Theme.of(context).copyWith(
933+
textSelectionTheme: const TextSelectionThemeData(
934+
selectionColor: Colors.transparent,
991935
),
936+
)
937+
: Theme.of(context),
938+
child: SelectionArea(
939+
key: const ValueKey('log-viewer-selection-area'),
940+
onSelectionChanged: (selectedContent) {
941+
widget.onSelectedTextChanged?.call(
942+
selectedContent?.plainText,
943+
);
944+
},
945+
contextMenuBuilder: (ctx, selectableRegionState) =>
946+
_buildSelectionContextMenu(
947+
ctx,
948+
selectableRegionState,
992949
),
993-
),
994-
),
950+
child: logViewport,
995951
),
996-
],
952+
),
997953
),
998954
),
999955
),
1000956
),
1001-
// Sticky column-visibility button pinned to the top-right corner.
1002-
// It stays in place regardless of horizontal scroll.
957+
],
958+
);
959+
960+
// Wrap SingleChildScrollView in a ScrollConfiguration that disables
961+
// ALL ambient scrollbars. This prevents Flutter's ScrollBehavior from
962+
// auto-painting a second ghost thumb on desktop/web — our explicit
963+
// Scrollbar widgets above are the only ones that should render.
964+
final scrollableContent = ScrollConfiguration(
965+
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
966+
child: SingleChildScrollView(
967+
controller: _horizontalScrollController,
968+
scrollDirection: Axis.horizontal,
969+
child: SizedBox(width: contentWidth, child: innerColumn),
970+
),
971+
);
972+
973+
return Stack(
974+
children: [
975+
// Vertical scrollbar — pinned to screen right, vertical axis only.
976+
Scrollbar(
977+
controller: widget.scrollController,
978+
thumbVisibility: true,
979+
notificationPredicate: (n) => n.metrics.axis == Axis.vertical,
980+
// Horizontal scrollbar — pinned to screen bottom, shown only when
981+
// content actually overflows (i.e. wrapText == false).
982+
child: widget.wrapText
983+
? scrollableContent
984+
: Scrollbar(
985+
controller: _horizontalScrollController,
986+
thumbVisibility: true,
987+
notificationPredicate: (n) =>
988+
n.metrics.axis == Axis.horizontal,
989+
child: scrollableContent,
990+
),
991+
),
992+
// Column-visibility button — always pinned top-right.
1003993
Positioned(
1004994
top: 0,
1005995
right: 0,
1006-
height: 29, // match header + divider height (28 + 1)
996+
height: 29,
1007997
child: Container(
1008998
padding: const EdgeInsets.symmetric(horizontal: 8),
1009999
color: Theme.of(context).colorScheme.surfaceContainerHighest,
@@ -1016,7 +1006,6 @@ class _LogViewerState extends State<LogViewer> {
10161006
color: Theme.of(context).colorScheme.onSurfaceVariant,
10171007
),
10181008
onTap: () {
1019-
// Show popup anchored to the button's position.
10201009
final renderBox =
10211010
context.findRenderObject() as RenderBox?;
10221011
final offset =
@@ -1073,7 +1062,8 @@ class _LogViewerState extends State<LogViewer> {
10731062
allowSelectionStart: log.isUserSelectable,
10741063
onSelectionPointerDown: (event) => _startRowSelectionDrag(index, event),
10751064
onSelectionPointerMove: (event) => _extendRowSelectionDrag(index, event),
1076-
contentValueForColumn: (col) => log.valueForColumn(col, isIos: widget.isIos),
1065+
contentValueForColumn: (col) =>
1066+
log.valueForColumn(col, isIos: widget.isIos),
10771067
);
10781068
}
10791069

0 commit comments

Comments
 (0)