diff --git a/.gitignore b/.gitignore index 53b0dbf8..09d5081f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ app.*.map.json /android/app/release # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ +# Generated localization +lib/gen/ diff --git a/example/pubspec.yaml b/example/pubspec.yaml index eabb50bf..5d60d0cb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -27,7 +27,7 @@ dev_dependencies: flutter_lints: ^5.0.0 flutter: - + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/l10n/placeholder b/l10n/placeholder new file mode 100644 index 00000000..e69de29b diff --git a/lib/core/auto_fill_engine.dart b/lib/core/auto_fill_engine.dart index 065c25a5..d9ca0cc1 100644 --- a/lib/core/auto_fill_engine.dart +++ b/lib/core/auto_fill_engine.dart @@ -1,11 +1,7 @@ import 'package:sheets/core/cell_properties.dart'; import 'package:sheets/core/sheet_data.dart'; import 'package:sheets/core/sheet_index.dart'; -import 'package:sheets/core/values/patterns/linear_date_pattern.dart'; -import 'package:sheets/core/values/patterns/linear_duration_pattern.dart'; -import 'package:sheets/core/values/patterns/linear_numeric_pattern.dart'; -import 'package:sheets/core/values/patterns/linear_string_pattern.dart'; -import 'package:sheets/core/values/patterns/repeat_value_pattern.dart'; +import 'package:sheets/core/pattern_detector.dart'; import 'package:sheets/core/values/patterns/value_pattern.dart'; import 'package:sheets/utils/direction.dart'; import 'package:sheets/utils/extensions/cell_properties_extensions.dart'; @@ -34,10 +30,13 @@ class AutoFillEngine { ); if (fillDirection.isVertical) { - Map> groupedPatternCells = _patternCells.groupByColumns(); - _fillCells(groupedPatternCells, _cellsToFill.whereColumn, cellMergePattern); + Map> groupedPatternCells = + _patternCells.groupByColumns(); + _fillCells( + groupedPatternCells, _cellsToFill.whereColumn, cellMergePattern); } else { - Map> groupedPatternCells = _patternCells.groupByRows(); + Map> groupedPatternCells = + _patternCells.groupByRows(); _fillCells(groupedPatternCells, _cellsToFill.whereRow, cellMergePattern); } } @@ -49,9 +48,12 @@ class AutoFillEngine { ) { bool reversed = fillDirection.isReversed; - for (MapEntry> entry in groupedPatternCells.entries) { - List patternCells = entry.value.maybeReverse(reversed); - List fillCells = getFillCellsForKey(entry.key).maybeReverse(reversed); + for (MapEntry> entry + in groupedPatternCells.entries) { + List patternCells = + entry.value.maybeReverse(reversed); + List fillCells = + getFillCellsForKey(entry.key).maybeReverse(reversed); patternApplier.apply(patternCells, fillCells); } @@ -75,15 +77,20 @@ class _PatternApplier { final Set completedCells = {}; - void apply(List patternCells, List cellsToFill) { + void apply(List patternCells, + List cellsToFill) { int templateIndex = 0; - List unprocessedFillCells = List.from(cellsToFill); + List unprocessedFillCells = + List.from(cellsToFill); - Map> templateRanges = >{}; - Map> fillRanges = >{}; + Map> templateRanges = + >{}; + Map> fillRanges = + >{}; while (unprocessedFillCells.isNotEmpty) { - IndexedCellProperties baseCell = patternCells[templateIndex % patternCells.length]; + IndexedCellProperties baseCell = + patternCells[templateIndex % patternCells.length]; IndexedCellProperties targetCell = unprocessedFillCells.removeAt(0); CellMergeStatus baseMergeStatus = baseCell.properties.mergeStatus; @@ -92,7 +99,8 @@ class _PatternApplier { } if (baseMergeStatus is! MergedCell) { - _handleUnmergedTemplateCell(baseCell, targetCell, templateRanges, fillRanges); + _handleUnmergedTemplateCell( + baseCell, targetCell, templateRanges, fillRanges); templateIndex++; continue; } @@ -120,8 +128,12 @@ class _PatternApplier { Map> fillRanges, ) { const String unmergedKey = '1x1'; - templateRanges.putIfAbsent(unmergedKey, () => {}).add(baseCell); - fillRanges.putIfAbsent(unmergedKey, () => []).add(targetCell); + templateRanges + .putIfAbsent(unmergedKey, () => {}) + .add(baseCell); + fillRanges + .putIfAbsent(unmergedKey, () => []) + .add(targetCell); } bool _handleMergedTemplateCell( @@ -131,73 +143,88 @@ class _PatternApplier { List unprocessedFillCells, Map> templateRanges, Map> fillRanges, + ) { + MergedCell movedStatus = + _calculateMovedStatus(baseMergeStatus, baseCell, targetCell); + if (_isOverlappingRange(movedStatus)) { + return false; + } + + _markCellsProcessed(unprocessedFillCells, movedStatus.mergedCells); + data.cells.merge(movedStatus.mergedCells); + _addMergeRange(movedStatus, baseCell, templateRanges, fillRanges); + + return true; + } + + MergedCell _calculateMovedStatus( + MergedCell baseMergeStatus, + IndexedCellProperties baseCell, + IndexedCellProperties targetCell, ) { int dxDiff = targetCell.index.column.value - baseCell.index.column.value; int dyDiff = targetCell.index.row.value - baseCell.index.row.value; - MergedCell movedMergeStatus = fillDirection.isHorizontal + return fillDirection.isHorizontal ? baseMergeStatus.moveHorizontal(dx: dxDiff, reverse: reversed) : baseMergeStatus.moveVertical(dy: dyDiff, reverse: reversed); + } - if (movedMergeStatus.contains(rangeStart) || movedMergeStatus.contains(rangeEnd)) { - return false; - } + bool _isOverlappingRange(MergedCell movedStatus) { + return movedStatus.contains(rangeStart) || movedStatus.contains(rangeEnd); + } - for (CellIndex index in movedMergeStatus.mergedCells) { - unprocessedFillCells.removeWhere((IndexedCellProperties cell) => cell.index == index); + void _markCellsProcessed( + List unprocessed, + Iterable mergedCells, + ) { + for (CellIndex index in mergedCells) { + unprocessed + .removeWhere((IndexedCellProperties cell) => cell.index == index); completedCells.add(index); } + } - data.cells.merge(movedMergeStatus.mergedCells); - - String key = movedMergeStatus.id; - templateRanges.putIfAbsent(key, () => {}).add(baseCell); + void _addMergeRange( + MergedCell movedStatus, + IndexedCellProperties baseCell, + Map> templateRanges, + Map> fillRanges, + ) { + String key = movedStatus.id; + templateRanges + .putIfAbsent(key, () => {}) + .add(baseCell); fillRanges.putIfAbsent(key, () => []).add( IndexedCellProperties( - index: movedMergeStatus.start, - properties: data.cells.get(movedMergeStatus.start), + index: movedStatus.start, + properties: data.cells.get(movedStatus.start), ), ); - - return true; } void _applyPatterns( Map> templateRanges, Map> fillRanges, ) { - for (MapEntry> fillRangeEntry in fillRanges.entries) { + for (MapEntry> fillRangeEntry + in fillRanges.entries) { String key = fillRangeEntry.key; List patternCells = templateRanges[key]!.toList(); ValuePattern pattern = _detectPattern(patternCells); - List filledCells = pattern.apply( patternCells, fillRangeEntry.value); + List filledCells = + pattern.apply(patternCells, fillRangeEntry.value); data.cells.setAll(filledCells); } } - ValuePattern _detectPattern(List cells) { + ValuePattern _detectPattern( + List patternCells, + ) { PatternDetector detector = PatternDetector(); - List propertiesToFill = reversed ? cells.reversed.toList() : cells; + List processedCells = + reversed ? patternCells.reversed.toList() : patternCells; - return detector.detectPattern(propertiesToFill); - } -} - -class PatternDetector { - final List matchers = [ - LinearNumericPatternMatcher(), - LinearDatePatternMatcher(), - LinearDurationPatternMatcher(), - LinearStringPatternMatcher(), - ]; - - ValuePattern detectPattern(List patternCells) { - for (ValuePatternMatcher matcher in matchers) { - ValuePattern? pattern = matcher.detect(patternCells); - if (pattern != null) { - return pattern; - } - } - return RepeatValuePattern(); + return detector.detectPattern(processedCells); } } diff --git a/lib/core/config/sheet_constants.dart b/lib/core/config/sheet_constants.dart index 37d40187..49e85fa6 100644 --- a/lib/core/config/sheet_constants.dart +++ b/lib/core/config/sheet_constants.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; double borderWidth = 1; +// Thickness for borders separating pinned rows and columns from +// the scrollable area. Used for both the draggable pin area and +// drawing pinned dividers. +double pinnedBorderWidth = 5; double scrollbarWidth = 14; diff --git a/lib/core/events/sheet_event.dart b/lib/core/events/sheet_event.dart index 450f8f0f..7f74ec59 100644 --- a/lib/core/events/sheet_event.dart +++ b/lib/core/events/sheet_event.dart @@ -163,5 +163,80 @@ class SetViewportSizeAction extends SheetAction { @override void execute() { worksheet.viewport.setViewportRect(event.rect); + worksheet.scroll.setViewportSize( + event.rect.size, + pinnedColumnsWidth: worksheet.data.pinnedColumnsFullWidth, + pinnedRowsHeight: worksheet.data.pinnedRowsFullHeight, + ); + } +} + +// Set Pinned Columns Count +class SetPinnedColumnsEvent extends SheetEvent { + SetPinnedColumnsEvent(this.count); + + final int count; + + @override + SheetAction createAction(Worksheet worksheet) => + SetPinnedColumnsAction(this, worksheet); + + @override + SheetRebuildConfig get rebuildConfig { + return SheetRebuildConfig.all(); + } + + @override + List get props => [count]; +} + +class SetPinnedColumnsAction extends SheetAction { + SetPinnedColumnsAction(super.event, super.worksheet); + + @override + void execute() { + worksheet.data.pinnedColumnCount = event.count; + worksheet.scroll.setContentSize(worksheet.data.scrollableContentSize); + worksheet.scroll.setViewportSize( + worksheet.viewport.rect.size, + pinnedColumnsWidth: worksheet.data.pinnedColumnsFullWidth, + pinnedRowsHeight: worksheet.data.pinnedRowsFullHeight, + ); + worksheet.viewport.rebuild(worksheet.scroll.offset); + } +} + +// Set Pinned Rows Count +class SetPinnedRowsEvent extends SheetEvent { + SetPinnedRowsEvent(this.count); + + final int count; + + @override + SheetAction createAction(Worksheet worksheet) => + SetPinnedRowsAction(this, worksheet); + + @override + SheetRebuildConfig get rebuildConfig { + return SheetRebuildConfig.all(); + } + + @override + List get props => [count]; +} + +class SetPinnedRowsAction extends SheetAction { + SetPinnedRowsAction(super.event, super.worksheet); + + @override + void execute() { + worksheet.data.pinnedRowCount = event.count; + worksheet.scroll.setContentSize(worksheet.data.scrollableContentSize); + worksheet.scroll.setViewportSize( + worksheet.viewport.rect.size, + pinnedColumnsWidth: worksheet.data.pinnedColumnsFullWidth, + pinnedRowsHeight: worksheet.data.pinnedRowsFullHeight, + ); + worksheet.viewport.rebuild(worksheet.scroll.offset); } } diff --git a/lib/core/pattern_detector.dart b/lib/core/pattern_detector.dart new file mode 100644 index 00000000..8e4ee6a7 --- /dev/null +++ b/lib/core/pattern_detector.dart @@ -0,0 +1,28 @@ +import 'package:sheets/core/cell_properties.dart'; +import 'package:sheets/core/values/patterns/linear_date_pattern.dart'; +import 'package:sheets/core/values/patterns/linear_duration_pattern.dart'; +import 'package:sheets/core/values/patterns/linear_numeric_pattern.dart'; +import 'package:sheets/core/values/patterns/linear_string_pattern.dart'; +import 'package:sheets/core/values/patterns/repeat_value_pattern.dart'; +import 'package:sheets/core/values/patterns/value_pattern.dart'; + +class PatternDetector { + final List matchers = [ + LinearNumericPatternMatcher(), + LinearDatePatternMatcher(), + LinearDurationPatternMatcher(), + LinearStringPatternMatcher(), + ]; + + ValuePattern detectPattern( + List patternCells, + ) { + for (ValuePatternMatcher matcher in matchers) { + ValuePattern? pattern = matcher.detect(patternCells); + if (pattern != null) { + return pattern; + } + } + return RepeatValuePattern(); + } +} diff --git a/lib/core/scroll/sheet_scroll_controller.dart b/lib/core/scroll/sheet_scroll_controller.dart index 33dd4bb8..9d7ebd0c 100644 --- a/lib/core/scroll/sheet_scroll_controller.dart +++ b/lib/core/scroll/sheet_scroll_controller.dart @@ -27,10 +27,14 @@ class SheetScrollController { late DirectionalValues position; late DirectionalValues metrics; - void setViewportSize(Size size) { + void setViewportSize(Size size, {double pinnedColumnsWidth = 0, double pinnedRowsHeight = 0}) { metrics = DirectionalValues( - horizontal: metrics.horizontal.copyWith(viewportDimension: size.width - rowHeadersWidth), - vertical: metrics.vertical.copyWith(viewportDimension: size.height - columnHeadersHeight), + horizontal: metrics.horizontal.copyWith( + viewportDimension: size.width - rowHeadersWidth - pinnedColumnsWidth, + ), + vertical: metrics.vertical.copyWith( + viewportDimension: size.height - columnHeadersHeight - pinnedRowsHeight, + ), ); } diff --git a/lib/core/selection/paints/sheet_multi_selection_paint.dart b/lib/core/selection/paints/sheet_multi_selection_paint.dart index 15c2d960..8044028d 100644 --- a/lib/core/selection/paints/sheet_multi_selection_paint.dart +++ b/lib/core/selection/paints/sheet_multi_selection_paint.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:sheets/core/selection/renderers/sheet_multi_selection_renderer.dart'; +import 'package:sheets/core/selection/selection_rect.dart'; import 'package:sheets/core/selection/sheet_selection.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; import 'package:sheets/core/selection/sheet_selection_renderer.dart'; import 'package:sheets/core/viewport/sheet_viewport.dart'; -import 'package:sheets/core/viewport/viewport_item.dart'; class SheetMultiSelectionPaint extends SheetSelectionPaint { SheetMultiSelectionPaint( @@ -24,11 +24,15 @@ class SheetMultiSelectionPaint extends SheetSelectionPaint { renderer.getPaint(mainCellVisible: false, backgroundVisible: true).paint(viewport, canvas, size); } - ViewportCell? selectedCell = renderer.mainCell; - if (selectedCell == null) { + SelectionRect? selectedRect = renderer.mainCellRect; + if (selectedRect == null) { return; } - paintMainCell(canvas, selectedCell.rect); + paintMainCell( + canvas, + selectedRect, + edgeVisibility: selectedRect.edgeVisibility, + ); } } diff --git a/lib/core/selection/paints/sheet_range_selection_paint.dart b/lib/core/selection/paints/sheet_range_selection_paint.dart index aa82eb54..6abc1f49 100644 --- a/lib/core/selection/paints/sheet_range_selection_paint.dart +++ b/lib/core/selection/paints/sheet_range_selection_paint.dart @@ -21,8 +21,12 @@ class SheetRangeSelectionPaint extends SheetSelectionPaint return; } - if (mainCellVisible && renderer.mainCell != null) { - paintMainCell(canvas, renderer.mainCell!.rect); + if (mainCellVisible && renderer.mainCellRect != null) { + paintMainCell( + canvas, + renderer.mainCellRect!, + edgeVisibility: renderer.mainCellRect!.edgeVisibility, + ); } if (backgroundVisible) { paintSelectionBackground(canvas, selectionRect); diff --git a/lib/core/selection/paints/sheet_single_selection_paint.dart b/lib/core/selection/paints/sheet_single_selection_paint.dart index 088ab64a..63a6e37f 100644 --- a/lib/core/selection/paints/sheet_single_selection_paint.dart +++ b/lib/core/selection/paints/sheet_single_selection_paint.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:sheets/core/selection/renderers/sheet_single_selection_renderer.dart'; +import 'package:sheets/core/selection/selection_rect.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; import 'package:sheets/core/viewport/sheet_viewport.dart'; -import 'package:sheets/core/viewport/viewport_item.dart'; -import 'package:sheets/utils/edge_visibility.dart'; class SheetSingleSelectionPaint extends SheetSelectionPaint { SheetSingleSelectionPaint( @@ -16,19 +15,27 @@ class SheetSingleSelectionPaint extends SheetSelectionPaint { @override void paint(SheetViewport viewport, Canvas canvas, Size size) { - ViewportCell? selectedCell = renderer.selectedCell; - if (selectedCell == null) { + SelectionRect? selectedRect = renderer.selectedRect; + if (selectedRect == null) { return; } if (mainCellVisible) { - paintMainCell(canvas, selectedCell.rect); + paintMainCell( + canvas, + selectedRect, + edgeVisibility: selectedRect.edgeVisibility, + ); } else { - paintSelectionBorder(canvas, selectedCell.rect, EdgeVisibility.allVisible()); + paintSelectionBorder( + canvas, + selectedRect, + selectedRect.edgeVisibility, + ); } if (backgroundVisible) { - paintSelectionBackground(canvas, selectedCell.rect); + paintSelectionBackground(canvas, selectedRect); } } } diff --git a/lib/core/selection/renderers/sheet_fill_selection_renderer.dart b/lib/core/selection/renderers/sheet_fill_selection_renderer.dart index c7e35f6a..eb4ab9b2 100644 --- a/lib/core/selection/renderers/sheet_fill_selection_renderer.dart +++ b/lib/core/selection/renderers/sheet_fill_selection_renderer.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:sheets/core/selection/paints/sheet_fill_selection_paint.dart'; import 'package:sheets/core/selection/renderers/sheet_range_selection_renderer.dart'; +import 'package:sheets/core/selection/sheet_selection.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; +import 'package:sheets/core/selection/sheet_selection_renderer.dart'; import 'package:sheets/core/selection/types/sheet_fill_selection.dart'; import 'package:sheets/core/sheet_index.dart'; @@ -15,10 +17,16 @@ class SheetFillSelectionRenderer extends SheetRangeSelectionRenderer SheetFillSelection get selection => super.selection as SheetFillSelection; @override - bool get fillHandleVisible => selection.baseSelection.isCompleted; + bool get fillHandleVisible { + SheetSelectionRenderer renderer = selection.baseSelection.createRenderer(viewport); + return renderer.fillHandleVisible && selection.baseSelection.isCompleted; + } @override - Offset? get fillHandleOffset => selection.baseSelection.createRenderer(viewport).fillHandleOffset; + Offset? get fillHandleOffset { + SheetSelectionRenderer renderer = selection.baseSelection.createRenderer(viewport); + return renderer.fillHandleVisible ? renderer.fillHandleOffset : null; + } @override SheetSelectionPaint getPaint({bool? mainCellVisible, bool? backgroundVisible}) { diff --git a/lib/core/selection/renderers/sheet_multi_selection_renderer.dart b/lib/core/selection/renderers/sheet_multi_selection_renderer.dart index b850e063..d5a79c0f 100644 --- a/lib/core/selection/renderers/sheet_multi_selection_renderer.dart +++ b/lib/core/selection/renderers/sheet_multi_selection_renderer.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:sheets/core/selection/paints/sheet_multi_selection_paint.dart'; +import 'package:sheets/core/selection/selection_rect.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; import 'package:sheets/core/selection/sheet_selection_renderer.dart'; import 'package:sheets/core/selection/types/sheet_multi_selection.dart'; import 'package:sheets/core/viewport/viewport_item.dart'; +import 'package:sheets/utils/direction.dart'; class SheetMultiSelectionRenderer extends SheetSelectionRenderer { SheetMultiSelectionRenderer({ @@ -22,5 +24,32 @@ class SheetMultiSelectionRenderer extends SheetSelectionRenderer viewport.visibleContent.findCell(selection.mainCell); + SelectionRect? get mainCellRect { + BorderRect cellRect = cellRectFor(selection.mainCell); + Rect visibleArea = visibleAreaFor(selection.mainCell); + + if (!cellRect.overlaps(visibleArea)) { + return null; + } + + Rect clipped = cellRect.intersect(visibleArea); + List hiddenBorders = []; + if (clipped.left > cellRect.left) { + hiddenBorders.add(Direction.left); + } + if (clipped.top > cellRect.top) { + hiddenBorders.add(Direction.top); + } + if (clipped.right < cellRect.right) { + hiddenBorders.add(Direction.right); + } + if (clipped.bottom < cellRect.bottom) { + hiddenBorders.add(Direction.bottom); + } + + return SelectionRect.fromLTRB( + rect: clipped, + hiddenBorders: hiddenBorders, + ); + } } diff --git a/lib/core/selection/renderers/sheet_range_selection_renderer.dart b/lib/core/selection/renderers/sheet_range_selection_renderer.dart index 43a7123a..b3d4ccc3 100644 --- a/lib/core/selection/renderers/sheet_range_selection_renderer.dart +++ b/lib/core/selection/renderers/sheet_range_selection_renderer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:sheets/core/selection/paints/sheet_range_selection_paint.dart'; import 'package:sheets/core/selection/selection_rect.dart'; @@ -6,44 +8,160 @@ import 'package:sheets/core/selection/sheet_selection_renderer.dart'; import 'package:sheets/core/selection/types/sheet_range_selection.dart'; import 'package:sheets/core/sheet_index.dart'; import 'package:sheets/core/viewport/viewport_item.dart'; -import 'package:sheets/utils/cached_value.dart'; -import 'package:sheets/utils/closest_visible.dart'; import 'package:sheets/utils/direction.dart'; class SheetRangeSelectionRenderer extends SheetSelectionRenderer> { SheetRangeSelectionRenderer({ required super.selection, required super.viewport, - }) { - _selectionRect = CachedValue(_calculateSelectionBounds); - } - - late final CachedValue _selectionRect; + }); @override - bool get fillHandleVisible => selection.isCompleted; + bool get fillHandleVisible { + SelectionRect? rect = selectionRect; + return selection.isCompleted && + rect != null && + rect.isBottomBorderVisible && + rect.isRightBorderVisible; + } @override - Offset? get fillHandleOffset => selectionRect?.bottomRight; + Offset? get fillHandleOffset => + fillHandleVisible ? selectionRect!.bottomRight : null; @override SheetSelectionPaint getPaint({bool? mainCellVisible, bool? backgroundVisible}) { return SheetRangeSelectionPaint(this, mainCellVisible, backgroundVisible); } - SelectionRect? get selectionRect => _selectionRect.value; + SelectionRect? get selectionRect => _calculateSelectionBounds(); + + SelectionRect? get mainCellRect { + BorderRect cellRect = cellRectFor(selection.mainCell); + Rect visibleArea = visibleAreaFor(selection.mainCell); + + if (!cellRect.overlaps(visibleArea)) { + return null; + } + + Rect clipped = cellRect.intersect(visibleArea); + List hiddenBorders = []; + if (clipped.left > cellRect.left) { + hiddenBorders.add(Direction.left); + } + if (clipped.top > cellRect.top) { + hiddenBorders.add(Direction.top); + } + if (clipped.right < cellRect.right) { + hiddenBorders.add(Direction.right); + } + if (clipped.bottom < cellRect.bottom) { + hiddenBorders.add(Direction.bottom); + } - ViewportCell? get mainCell => viewport.visibleContent.findCell(selection.mainCell); + return SelectionRect.fromLTRB( + rect: clipped, + hiddenBorders: hiddenBorders, + ); + } SelectionRect? _calculateSelectionBounds() { - if (isSelectionVisible) { - ClosestVisible startCell = viewport.visibleContent.findCellOrClosest(selection.start.cell); - ClosestVisible endCell = viewport.visibleContent.findCellOrClosest(selection.end.cell); + int rowStart = math.min(selection.start.row.value, selection.end.row.value); + int rowEnd = math.max(selection.start.row.value, selection.end.row.value); + int columnStart = + math.min(selection.start.column.value, selection.end.column.value); + int columnEnd = + math.max(selection.start.column.value, selection.end.column.value); - List hiddenBorders = [...startCell.hiddenBorders, ...endCell.hiddenBorders]; - return SelectionRect(startCell.value.rect, endCell.value.rect, selection.direction, hiddenBorders: hiddenBorders); - } else { + List visibleRows = viewport.visibleContent.rows + .where((ViewportRow row) => + row.index.value >= rowStart && row.index.value <= rowEnd) + .toList(); + List visibleColumns = viewport.visibleContent.columns + .where((ViewportColumn column) => + column.index.value >= columnStart && + column.index.value <= columnEnd) + .toList(); + + if (visibleRows.isEmpty || visibleColumns.isEmpty) { return null; } + + CellIndex firstVisible = CellIndex( + column: visibleColumns.first.index, + row: visibleRows.first.index, + ); + CellIndex lastVisible = CellIndex( + column: visibleColumns.last.index, + row: visibleRows.last.index, + ); + + _ClippedCell start = _clipCell(firstVisible)!; + _ClippedCell end = _clipCell(lastVisible)!; + + Rect bounds = Rect.fromLTRB( + math.min(start.rect.left, end.rect.left), + math.min(start.rect.top, end.rect.top), + math.max(start.rect.right, end.rect.right), + math.max(start.rect.bottom, end.rect.bottom), + ); + + bool hideLeft = + visibleColumns.first.index.value > columnStart || + start.hiddenBorders.contains(Direction.left); + bool hideRight = + visibleColumns.last.index.value < columnEnd || + end.hiddenBorders.contains(Direction.right); + bool hideTop = + visibleRows.first.index.value > rowStart || + start.hiddenBorders.contains(Direction.top); + bool hideBottom = + visibleRows.last.index.value < rowEnd || + end.hiddenBorders.contains(Direction.bottom); + + Set hiddenBorders = { + if (hideTop) Direction.top, + if (hideBottom) Direction.bottom, + if (hideLeft) Direction.left, + if (hideRight) Direction.right, + }; + + return SelectionRect.fromLTRB( + rect: bounds, + hiddenBorders: hiddenBorders.toList(), + ); } + + _ClippedCell? _clipCell(CellIndex index) { + BorderRect rect = cellRectFor(index); + Rect visible = visibleAreaFor(index); + + if (!rect.overlaps(visible)) { + return null; + } + + Rect clipped = rect.intersect(visible); + List hiddenBorders = []; + if (clipped.left > rect.left) { + hiddenBorders.add(Direction.left); + } + if (clipped.top > rect.top) { + hiddenBorders.add(Direction.top); + } + if (clipped.right < rect.right) { + hiddenBorders.add(Direction.right); + } + if (clipped.bottom < rect.bottom) { + hiddenBorders.add(Direction.bottom); + } + + return _ClippedCell(clipped, hiddenBorders); + } +} + +class _ClippedCell { + _ClippedCell(this.rect, this.hiddenBorders); + + final Rect rect; + final List hiddenBorders; } diff --git a/lib/core/selection/renderers/sheet_single_selection_renderer.dart b/lib/core/selection/renderers/sheet_single_selection_renderer.dart index 12c8849b..d9ad004a 100644 --- a/lib/core/selection/renderers/sheet_single_selection_renderer.dart +++ b/lib/core/selection/renderers/sheet_single_selection_renderer.dart @@ -1,31 +1,64 @@ import 'package:flutter/material.dart'; import 'package:sheets/core/selection/paints/sheet_single_selection_paint.dart'; +import 'package:sheets/core/selection/selection_rect.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; import 'package:sheets/core/selection/sheet_selection_renderer.dart'; import 'package:sheets/core/selection/types/sheet_single_selection.dart'; import 'package:sheets/core/viewport/viewport_item.dart'; -import 'package:sheets/utils/cached_value.dart'; +import 'package:sheets/utils/direction.dart'; class SheetSingleSelectionRenderer extends SheetSelectionRenderer { SheetSingleSelectionRenderer({ required super.selection, required super.viewport, - }) { - _selectedCell = CachedValue(() => viewport.visibleContent.findCell(selection.start.cell)); - } - - late final CachedValue _selectedCell; + }); @override - bool get fillHandleVisible => selection.fillHandleVisible && selection.isCompleted; + bool get fillHandleVisible { + SelectionRect? rect = selectedRect; + return selection.fillHandleVisible && + selection.isCompleted && + rect != null && + rect.isBottomBorderVisible && + rect.isRightBorderVisible; + } @override - Offset? get fillHandleOffset => selectedCell?.rect.bottomRight; + Offset? get fillHandleOffset => + fillHandleVisible ? selectedRect!.bottomRight : null; @override SheetSelectionPaint getPaint({bool? mainCellVisible, bool? backgroundVisible}) { return SheetSingleSelectionPaint(this, mainCellVisible, backgroundVisible); } - ViewportCell? get selectedCell => _selectedCell.value; + SelectionRect? get selectedRect { + BorderRect cellRect = cellRectFor(selection.start.cell); + Rect visibleArea = visibleAreaFor(selection.start.cell); + + if (!cellRect.overlaps(visibleArea)) { + return null; + } + + Rect clipped = cellRect.intersect(visibleArea); + + List hiddenBorders = []; + if (clipped.left > cellRect.left) { + hiddenBorders.add(Direction.left); + } + if (clipped.top > cellRect.top) { + hiddenBorders.add(Direction.top); + } + if (clipped.right < cellRect.right) { + hiddenBorders.add(Direction.right); + } + if (clipped.bottom < cellRect.bottom) { + hiddenBorders.add(Direction.bottom); + } + + return SelectionRect.fromLTRB( + rect: clipped, + hiddenBorders: hiddenBorders, + ); + } } diff --git a/lib/core/selection/sheet_selection_paint.dart b/lib/core/selection/sheet_selection_paint.dart index 7c59e0c7..728500a3 100644 --- a/lib/core/selection/sheet_selection_paint.dart +++ b/lib/core/selection/sheet_selection_paint.dart @@ -17,11 +17,12 @@ abstract class SheetSelectionPaint { void paint(SheetViewport viewport, Canvas canvas, Size size); - void paintMainCell(Canvas canvas, BorderRect rect) { + void paintMainCell(Canvas canvas, BorderRect rect, + {EdgeVisibility edgeVisibility = const EdgeVisibility.allVisible()}) { SharedPaints.paintBorder( canvas: canvas, rect: rect, - edgeVisibility: EdgeVisibility.allVisible(), + edgeVisibility: edgeVisibility, border: Border.all( color: const Color(0xff3572e3), width: 2, diff --git a/lib/core/selection/sheet_selection_renderer.dart b/lib/core/selection/sheet_selection_renderer.dart index 3f3bf2e8..964bec07 100644 --- a/lib/core/selection/sheet_selection_renderer.dart +++ b/lib/core/selection/sheet_selection_renderer.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:sheets/core/cell_properties.dart'; +import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/selection/sheet_selection.dart'; import 'package:sheets/core/selection/sheet_selection_paint.dart'; +import 'package:sheets/core/sheet_data.dart'; +import 'package:sheets/core/sheet_index.dart'; import 'package:sheets/core/viewport/sheet_viewport.dart'; import 'package:sheets/core/viewport/viewport_item.dart'; @@ -13,6 +17,127 @@ abstract class SheetSelectionRenderer { final SheetViewport viewport; final T selection; + WorksheetData get _data => viewport.visibleContent.data; + + double get _horizontalScrollOffset { + ViewportColumn? unpinned; + for (ViewportColumn c in viewport.visibleContent.columns) { + if (c.index.value >= _data.pinnedColumnCount) { + unpinned = c; + break; + } + } + if (unpinned == null) { + return 0; + } + + double sheetX = 0; + for (int i = 0; i < unpinned.index.value; i++) { + sheetX += _data.columns.getWidth(ColumnIndex(i)) + borderWidth; + } + + double localX = unpinned.rect.left - rowHeadersWidth - borderWidth; + return sheetX - localX; + } + + double get _verticalScrollOffset { + ViewportRow? unpinned; + for (ViewportRow r in viewport.visibleContent.rows) { + if (r.index.value >= _data.pinnedRowCount) { + unpinned = r; + break; + } + } + if (unpinned == null) { + return 0; + } + + double sheetY = 0; + for (int i = 0; i < unpinned.index.value; i++) { + sheetY += _data.rows.getHeight(RowIndex(i)) + borderWidth; + } + + double localY = unpinned.rect.top - columnHeadersHeight - borderWidth; + return sheetY - localY; + } + + BorderRect _simpleCellRect(CellIndex index) { + double x = rowHeadersWidth + borderWidth; + for (int i = 0; i < index.column.value; i++) { + x += _data.columns.getWidth(ColumnIndex(i)) + borderWidth; + } + + double y = columnHeadersHeight + borderWidth; + for (int i = 0; i < index.row.value; i++) { + y += _data.rows.getHeight(RowIndex(i)) + borderWidth; + } + + bool columnPinned = index.column.value < _data.pinnedColumnCount; + bool rowPinned = index.row.value < _data.pinnedRowCount; + + if (!columnPinned) { + x -= _horizontalScrollOffset; + } + if (!rowPinned) { + y -= _verticalScrollOffset; + } + + double width = _data.columns.getWidth(index.column); + double height = _data.rows.getHeight(index.row); + return BorderRect.fromLTWH(x, y, width, height); + } + + BorderRect _mergedCellRect(MergedCell merge) { + double left = rowHeadersWidth + borderWidth; + for (int i = 0; i < merge.start.column.value; i++) { + left += _data.columns.getWidth(ColumnIndex(i)) + borderWidth; + } + + double right = rowHeadersWidth + borderWidth; + for (int i = 0; i <= merge.end.column.value; i++) { + right += _data.columns.getWidth(ColumnIndex(i)); + if (i != merge.end.column.value) { + right += borderWidth; + } + } + + double top = columnHeadersHeight + borderWidth; + for (int i = 0; i < merge.start.row.value; i++) { + top += _data.rows.getHeight(RowIndex(i)) + borderWidth; + } + + double bottom = columnHeadersHeight + borderWidth; + for (int i = 0; i <= merge.end.row.value; i++) { + bottom += _data.rows.getHeight(RowIndex(i)); + if (i != merge.end.row.value) { + bottom += borderWidth; + } + } + + bool columnPinned = merge.start.column.value < _data.pinnedColumnCount; + bool rowPinned = merge.start.row.value < _data.pinnedRowCount; + + if (!columnPinned) { + left -= _horizontalScrollOffset; + right -= _horizontalScrollOffset; + } + if (!rowPinned) { + top -= _verticalScrollOffset; + bottom -= _verticalScrollOffset; + } + + return BorderRect.fromLTRB(left, top, right, bottom); + } + + BorderRect cellRectFor(CellIndex index) { + CellProperties props = _data.cells.get(index); + CellMergeStatus merge = props.mergeStatus; + if (merge is MergedCell) { + return _mergedCellRect(merge); + } + return _simpleCellRect(index); + } + bool get fillHandleVisible; Offset? get fillHandleOffset; @@ -25,4 +150,22 @@ abstract class SheetSelectionRenderer { return rowVisible && columnVisible; } + + Rect visibleAreaFor(CellIndex index) { + WorksheetData data = viewport.visibleContent.data; + + bool columnPinned = index.column.value < data.pinnedColumnCount; + bool rowPinned = index.row.value < data.pinnedRowCount; + + double left = rowHeadersWidth + + (columnPinned ? borderWidth : data.pinnedColumnsFullWidth); + double top = columnHeadersHeight + + (rowPinned ? borderWidth : data.pinnedRowsFullHeight); + double right = + columnPinned ? rowHeadersWidth + data.pinnedColumnsFullWidth : viewport.rect.width; + double bottom = + rowPinned ? columnHeadersHeight + data.pinnedRowsFullHeight : viewport.rect.height; + + return Rect.fromLTRB(left, top, right, bottom); + } } diff --git a/lib/core/sheet_data.dart b/lib/core/sheet_data.dart index 01e491ca..e9b00ca5 100644 --- a/lib/core/sheet_data.dart +++ b/lib/core/sheet_data.dart @@ -190,6 +190,8 @@ class WorksheetData { RowsProperties? rows, ColumnsProperties? columns, CellsProperties? cells, + this.pinnedColumnCount = 0, + this.pinnedRowCount = 0, }) { this.rows = rows ?? RowsProperties(); this.columns = columns ?? ColumnsProperties(); @@ -207,6 +209,9 @@ class WorksheetData { int columnCount; int rowCount; + int pinnedColumnCount; + int pinnedRowCount; + double _contentWidth = 0; double _contentHeight = 0; @@ -364,4 +369,29 @@ class WorksheetData { double get contentHeight => _contentHeight; Size get contentSize => Size(contentWidth, contentHeight); + + double get pinnedColumnsWidth { + double width = 0; + for (int i = 0; i < pinnedColumnCount && i < columnCount; i++) { + width += columns.getWidth(ColumnIndex(i)) + borderWidth; + } + return width; + } + + double get pinnedColumnsFullWidth => + pinnedColumnCount > 0 ? pinnedColumnsWidth + pinnedBorderWidth : pinnedColumnsWidth; + + double get pinnedRowsHeight { + double height = 0; + for (int i = 0; i < pinnedRowCount && i < rowCount; i++) { + height += rows.getHeight(RowIndex(i)) + borderWidth; + } + return height; + } + + double get pinnedRowsFullHeight => + pinnedRowCount > 0 ? pinnedRowsHeight + pinnedBorderWidth : pinnedRowsHeight; + + Size get scrollableContentSize => + Size(contentWidth - pinnedColumnsWidth, contentHeight - pinnedRowsHeight); } diff --git a/lib/core/viewport/calculators/closest_visible_column_index_calculator.dart b/lib/core/viewport/calculators/closest_visible_column_index_calculator.dart index a686c9c7..7cf539e5 100644 --- a/lib/core/viewport/calculators/closest_visible_column_index_calculator.dart +++ b/lib/core/viewport/calculators/closest_visible_column_index_calculator.dart @@ -9,22 +9,56 @@ class ClosestVisibleColumnIndexCalculator { final List visibleColumns; ClosestVisible findFor(CellIndex cellIndex) { - ColumnIndex firstVisibleColumn = visibleColumns.first.index; - ColumnIndex lastVisibleColumn = visibleColumns.last.index; ColumnIndex cellColumn = cellIndex.column; + List indices = + visibleColumns.map((ViewportColumn column) => column.index).toList(); - if (cellColumn >= firstVisibleColumn && cellColumn <= lastVisibleColumn) { + if (indices.contains(cellColumn)) { return ClosestVisible.fullyVisible(cellColumn); - } else if (cellColumn < firstVisibleColumn) { + } + + ColumnIndex firstVisibleColumn = indices.first; + ColumnIndex lastVisibleColumn = indices.last; + + if (cellColumn < firstVisibleColumn) { return ClosestVisible.partiallyVisible( hiddenBorders: [Direction.left], value: firstVisibleColumn, ); - } else { + } + + if (cellColumn > lastVisibleColumn) { return ClosestVisible.partiallyVisible( hiddenBorders: [Direction.right], value: lastVisibleColumn, ); } + + // Column lies between first and last but is not visible (gap). + ColumnIndex lower = indices.first; + ColumnIndex upper = indices.last; + for (ColumnIndex index in indices) { + if (index.value <= cellColumn.value) { + lower = index; + } + if (index.value >= cellColumn.value) { + upper = index; + break; + } + } + + int distanceLeft = cellColumn.value - lower.value; + int distanceRight = upper.value - cellColumn.value; + if (distanceLeft <= distanceRight) { + return ClosestVisible.partiallyVisible( + hiddenBorders: [Direction.right], + value: lower, + ); + } else { + return ClosestVisible.partiallyVisible( + hiddenBorders: [Direction.left], + value: upper, + ); + } } } diff --git a/lib/core/viewport/calculators/closest_visible_row_index_calculator.dart b/lib/core/viewport/calculators/closest_visible_row_index_calculator.dart index facc4059..c1133932 100644 --- a/lib/core/viewport/calculators/closest_visible_row_index_calculator.dart +++ b/lib/core/viewport/calculators/closest_visible_row_index_calculator.dart @@ -9,25 +9,57 @@ class ClosestVisibleRowIndexCalculator { final List visibleRows; ClosestVisible findFor(CellIndex cellIndex) { - RowIndex firstVisibleRow = visibleRows.first.index; - RowIndex lastVisibleRow = visibleRows.last.index; RowIndex cellRow = cellIndex.row; + List indices = + visibleRows.map((ViewportRow row) => row.index).toList(); - bool visible = cellRow >= firstVisibleRow && cellRow <= lastVisibleRow; - bool missingTop = cellRow < firstVisibleRow; - - if (visible) { + if (indices.contains(cellRow)) { return ClosestVisible.fullyVisible(cellRow); - } else if (missingTop) { + } + + RowIndex firstVisibleRow = indices.first; + RowIndex lastVisibleRow = indices.last; + + if (cellRow < firstVisibleRow) { return ClosestVisible.partiallyVisible( hiddenBorders: [Direction.top], value: firstVisibleRow, ); - } else { + } + + if (cellRow > lastVisibleRow) { return ClosestVisible.partiallyVisible( hiddenBorders: [Direction.bottom], value: lastVisibleRow, ); } + + // The row lies between first and last but is not visible (gap caused by + // pinned regions). Find the closest visible row. + RowIndex lower = indices.first; + RowIndex upper = indices.last; + for (RowIndex index in indices) { + if (index.value <= cellRow.value) { + lower = index; + } + if (index.value >= cellRow.value) { + upper = index; + break; + } + } + + int distanceTop = cellRow.value - lower.value; + int distanceBottom = upper.value - cellRow.value; + if (distanceTop <= distanceBottom) { + return ClosestVisible.partiallyVisible( + hiddenBorders: [Direction.bottom], + value: lower, + ); + } else { + return ClosestVisible.partiallyVisible( + hiddenBorders: [Direction.top], + value: upper, + ); + } } } diff --git a/lib/core/viewport/renderers/visible_cells_renderer.dart b/lib/core/viewport/renderers/visible_cells_renderer.dart index 4666ce5f..3abd9350 100644 --- a/lib/core/viewport/renderers/visible_cells_renderer.dart +++ b/lib/core/viewport/renderers/visible_cells_renderer.dart @@ -32,7 +32,10 @@ class VisibleCellsRenderer { MergedCell mergedCell = cellProperties.mergeStatus as MergedCell; CellProperties startCellProperties = data.cells.get(mergedCell.start); - if (mergedCell.isMainCell(cellIndex) || (y == 0 || x == 0) && !resolvedMergedCells.contains(mergedCell)) { + bool edgeRow = y == data.pinnedRowCount; + bool edgeColumn = x == data.pinnedColumnCount; + + if (mergedCell.isMainCell(cellIndex) || (edgeRow || edgeColumn) && !resolvedMergedCells.contains(mergedCell)) { CellIndex mergeEnd = mergedCell.end; ViewportRow rowEnd = visibleRows.findByIndex(mergeEnd.row); diff --git a/lib/core/viewport/renderers/visible_columns_renderer.dart b/lib/core/viewport/renderers/visible_columns_renderer.dart index 0ba743c1..ce4050cb 100644 --- a/lib/core/viewport/renderers/visible_columns_renderer.dart +++ b/lib/core/viewport/renderers/visible_columns_renderer.dart @@ -1,4 +1,3 @@ -import 'package:equatable/equatable.dart'; import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/sheet_data.dart'; import 'package:sheets/core/sheet_index.dart'; @@ -18,74 +17,42 @@ class VisibleColumnsRenderer { final double scrollOffset; List build() { - double firstVisibleCoordinate = scrollOffset; - _FirstVisibleColumnInfo firstVisibleColumnInfo = _findColumnByX(firstVisibleCoordinate); - - double maxContentWidth = viewportRect.width - rowHeadersWidth; - double currentContentWidth = -firstVisibleColumnInfo.hiddenWidth; - + double currentWidth = 0; + double viewportWidth = viewportRect.width - rowHeadersWidth; + double pinnedWidth = data.pinnedColumnsFullWidth; + double offset = data.pinnedColumnCount > 0 ? pinnedBorderWidth : 0; List visibleColumns = []; - int index = firstVisibleColumnInfo.index.value; - - while (currentContentWidth < maxContentWidth && index < data.columnCount) { - ColumnIndex columnIndex = ColumnIndex(index); - ColumnStyle columnStyle = data.columns.get(columnIndex); - - ViewportColumn viewportColumn = ViewportColumn( - index: columnIndex, - style: columnStyle, - rect: BorderRect.fromLTWH(currentContentWidth + rowHeadersWidth + borderWidth, 0, columnStyle.width, columnHeadersHeight), - ); - visibleColumns.add(viewportColumn); - currentContentWidth += viewportColumn.style.width + borderWidth; - - index++; - } - - return visibleColumns; - } - _FirstVisibleColumnInfo _findColumnByX(double x) { - int actualColumnIndex = 0; - double currentWidthStart = 0; - - _FirstVisibleColumnInfo? firstVisibleColumnInfo; - - while (firstVisibleColumnInfo == null) { - ColumnIndex columnIndex = ColumnIndex(actualColumnIndex); + for (int i = 0; i < data.columnCount; i++) { + ColumnIndex columnIndex = ColumnIndex(i); ColumnStyle columnStyle = data.columns.get(columnIndex); - double columnWidthEnd = currentWidthStart + columnStyle.width + borderWidth; - - if (x >= currentWidthStart && x < columnWidthEnd) { - firstVisibleColumnInfo = _FirstVisibleColumnInfo( - index: columnIndex, - startCoordinate: currentWidthStart - borderWidth, - visibleWidth: columnWidthEnd - x, - hiddenWidth: x - currentWidthStart, + bool pinned = i < data.pinnedColumnCount; + + double adjustedStart = currentWidth - (pinned ? 0 : scrollOffset) + (pinned ? 0 : offset); + double end = adjustedStart + columnStyle.width + borderWidth; + + bool visible = pinned + ? end > 0 && adjustedStart < viewportWidth + : end > pinnedWidth && adjustedStart < viewportWidth; + + if (visible) { + visibleColumns.add( + ViewportColumn( + index: columnIndex, + style: columnStyle, + rect: BorderRect.fromLTWH( + rowHeadersWidth + borderWidth + adjustedStart, + 0, + columnStyle.width, + columnHeadersHeight, + ), + ), ); - } else { - actualColumnIndex++; - currentWidthStart = columnWidthEnd; } + + currentWidth += columnStyle.width + borderWidth; } - return firstVisibleColumnInfo; + return visibleColumns; } } - -class _FirstVisibleColumnInfo with EquatableMixin { - const _FirstVisibleColumnInfo({ - required this.index, - required this.startCoordinate, - required this.visibleWidth, - required this.hiddenWidth, - }); - - final ColumnIndex index; - final double startCoordinate; - final double visibleWidth; - final double hiddenWidth; - - @override - List get props => [index, startCoordinate, visibleWidth, hiddenWidth]; -} diff --git a/lib/core/viewport/renderers/visible_rows_renderer.dart b/lib/core/viewport/renderers/visible_rows_renderer.dart index 47fb72fe..7e335b19 100644 --- a/lib/core/viewport/renderers/visible_rows_renderer.dart +++ b/lib/core/viewport/renderers/visible_rows_renderer.dart @@ -1,4 +1,3 @@ -import 'package:equatable/equatable.dart'; import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/sheet_data.dart'; import 'package:sheets/core/sheet_index.dart'; @@ -20,74 +19,42 @@ class VisibleRowsRenderer { final double scrollOffset; List build() { - double firstVisibleCoordinate = scrollOffset; - _FirstVisibleRowInfo firstVisibleRowInfo = _findRowByY(firstVisibleCoordinate); - - double maxContentHeight = viewportRect.height - columnHeadersHeight; - double currentContentHeight = -firstVisibleRowInfo.hiddenHeight; - + double currentHeight = 0; + double viewportHeight = viewportRect.height - columnHeadersHeight; + double pinnedHeight = data.pinnedRowsFullHeight; + double offset = data.pinnedRowCount > 0 ? pinnedBorderWidth : 0; List visibleRows = []; - int index = firstVisibleRowInfo.index.value; - - while (currentContentHeight < maxContentHeight && index < data.rowCount) { - RowIndex rowIndex = RowIndex(index); - RowStyle rowStyle = data.rows.get(rowIndex); - - ViewportRow viewportRow = ViewportRow( - index: rowIndex, - style: rowStyle, - rect: BorderRect.fromLTWH(0, currentContentHeight + columnHeadersHeight + borderWidth, rowHeadersWidth, rowStyle.height), - ); - visibleRows.add(viewportRow); - currentContentHeight += viewportRow.style.height + borderWidth; - - index++; - } - - return visibleRows; - } - _FirstVisibleRowInfo _findRowByY(double y) { - int actualRowIndex = 0; - double currentHeightStart = 0; - - _FirstVisibleRowInfo? firstVisibleRowInfo; - - while (firstVisibleRowInfo == null) { - RowIndex rowIndex = RowIndex(actualRowIndex); + for (int i = 0; i < data.rowCount; i++) { + RowIndex rowIndex = RowIndex(i); RowStyle rowStyle = data.rows.get(rowIndex); - double rowHeightEnd = currentHeightStart + rowStyle.height + borderWidth; - - if (y >= currentHeightStart && y < rowHeightEnd) { - firstVisibleRowInfo = _FirstVisibleRowInfo( - index: rowIndex, - startCoordinate: currentHeightStart, - visibleHeight: rowHeightEnd - y, - hiddenHeight: y - currentHeightStart, + bool pinned = i < data.pinnedRowCount; + + double adjustedStart = currentHeight - (pinned ? 0 : scrollOffset) + (pinned ? 0 : offset); + double end = adjustedStart + rowStyle.height + borderWidth; + + bool visible = pinned + ? end > 0 && adjustedStart < viewportHeight + : end > pinnedHeight && adjustedStart < viewportHeight; + + if (visible) { + visibleRows.add( + ViewportRow( + index: rowIndex, + style: rowStyle, + rect: BorderRect.fromLTWH( + 0, + columnHeadersHeight + borderWidth + adjustedStart, + rowHeadersWidth, + rowStyle.height, + ), + ), ); - } else { - actualRowIndex++; - currentHeightStart = rowHeightEnd; } + + currentHeight += rowStyle.height + borderWidth; } - return firstVisibleRowInfo; + return visibleRows; } } - -class _FirstVisibleRowInfo with EquatableMixin { - const _FirstVisibleRowInfo({ - required this.index, - required this.startCoordinate, - required this.visibleHeight, - required this.hiddenHeight, - }); - - final RowIndex index; - final double startCoordinate; - final double visibleHeight; - final double hiddenHeight; - - @override - List get props => [index, startCoordinate, visibleHeight, hiddenHeight]; -} diff --git a/lib/core/viewport/sheet_viewport_content_data.dart b/lib/core/viewport/sheet_viewport_content_data.dart index 4f5f0ad8..01888699 100644 --- a/lib/core/viewport/sheet_viewport_content_data.dart +++ b/lib/core/viewport/sheet_viewport_content_data.dart @@ -50,7 +50,8 @@ class SheetViewportContentData { ViewportItem? findAnyByOffset(Offset mousePosition) { try { - return all.firstWhere((ViewportItem element) { + Iterable prioritized = [...cells, ...columns, ...rows]; + return prioritized.firstWhere((ViewportItem element) { Rect itemRect = element.rect.expand(borderWidth / 2); return itemRect.within(mousePosition); }); diff --git a/lib/core/viewport/sheet_viewport_content_manager.dart b/lib/core/viewport/sheet_viewport_content_manager.dart index 33197a29..917659a6 100644 --- a/lib/core/viewport/sheet_viewport_content_manager.dart +++ b/lib/core/viewport/sheet_viewport_content_manager.dart @@ -15,6 +15,8 @@ class SheetViewportContentManager { final SheetViewportContentData _contentData; final WorksheetData _data; + WorksheetData get data => _data; + void rebuild(SheetViewportRect viewportRect, Offset scrollOffset) { List rows = _calculateRows(viewportRect, scrollOffset.dy); List columns = _calculateColumns(viewportRect, scrollOffset.dx); diff --git a/lib/core/worksheet.dart b/lib/core/worksheet.dart index cb1be72a..dd4dd085 100644 --- a/lib/core/worksheet.dart +++ b/lib/core/worksheet.dart @@ -37,7 +37,7 @@ class Worksheet extends SheetRebuildNotifier { viewport = SheetViewport(data); selection = SelectionState.defaultSelection(); - scroll.setContentSize(data.contentSize); + scroll.setContentSize(data.scrollableContentSize); } final FocusNode sheetFocusNode = FocusNode()..requestFocus(); @@ -48,33 +48,41 @@ class Worksheet extends SheetRebuildNotifier { late SelectionState selection; - List eventsQueue = []; + final List _eventsQueue = []; void resolve(SheetEvent event) { - bool mainEvent = eventsQueue.isEmpty; - SheetAction? action = event.createAction(this); - if (action == null) { - return; - } - eventsQueue.add(event); - unawaited(_executeAction(mainEvent, action)); + final bool isMainEvent = _eventsQueue.isEmpty; + final SheetAction? action = event.createAction(this); + if (action == null) return; + + _eventsQueue.add(event); + unawaited(_executeAction(isMainEvent, action)); } - Future _executeAction(bool mainAction, SheetAction action) async { + Future _executeAction( + bool isMainAction, + SheetAction action, + ) async { await action.execute(); + if (!isMainAction) return; - if (mainAction) { - SheetRebuildConfig rebuildProperties = eventsQueue.fold( - SheetRebuildConfig(), - (SheetRebuildConfig previousValue, SheetEvent element) => previousValue.combine(element.rebuildConfig), - ); + final SheetRebuildConfig config = _collectRebuildConfig(); + _rebuildViewportIfNeeded(config); + notify(config); + _eventsQueue.clear(); + } - if (rebuildProperties.rebuildViewport || rebuildProperties.rebuildCellsLayer) { - viewport.rebuild(scroll.offset); - } + SheetRebuildConfig _collectRebuildConfig() { + return _eventsQueue.fold( + SheetRebuildConfig(), + (SheetRebuildConfig value, SheetEvent event) => + value.combine(event.rebuildConfig), + ); + } - notify(rebuildProperties); - eventsQueue.clear(); + void _rebuildViewportIfNeeded(SheetRebuildConfig config) { + if (config.rebuildViewport || config.rebuildCellsLayer) { + viewport.rebuild(scroll.offset); } } @@ -102,7 +110,8 @@ class Worksheet extends SheetRebuildNotifier { return CellSelectionStyle(cellProperties: cellProperties); } - SheetTextEditingController textEditingController = editableCellNotifier.value!.controller; + SheetTextEditingController textEditingController = + editableCellNotifier.value!.controller; if (textEditingController.selection.isCollapsed) { return CursorSelectionStyle( cellProperties: cellProperties, diff --git a/lib/layers/pin_area/sheet_pin_area_layer.dart b/lib/layers/pin_area/sheet_pin_area_layer.dart new file mode 100644 index 00000000..a464772f --- /dev/null +++ b/lib/layers/pin_area/sheet_pin_area_layer.dart @@ -0,0 +1,316 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:sheets/core/config/sheet_constants.dart'; +import 'package:sheets/core/events/sheet_event.dart'; +import 'package:sheets/core/selection/sheet_selection_factory.dart'; +import 'package:sheets/core/sheet_data.dart'; +import 'package:sheets/core/sheet_index.dart'; +import 'package:sheets/core/worksheet.dart'; +import 'package:sheets/widgets/sheet_mouse_region.dart'; + +/// Layer allowing users to pin rows and columns by dragging the top left corner +/// cross similarly to Google Sheets. +class SheetPinAreaLayer extends StatefulWidget { + const SheetPinAreaLayer({ + required this.worksheet, + super.key, + }); + + final Worksheet worksheet; + + @override + State createState() => _SheetPinAreaLayerState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('worksheet', worksheet)); + } +} + +class _SheetPinAreaLayerState extends State { + bool _isDraggingColumns = false; + bool _isDraggingRows = false; + double? _cursorLeft; + double? _cursorTop; + int _targetColumnCount = 0; + int _targetRowCount = 0; + + WorksheetData get _data => widget.worksheet.data; + + // Colors used for the pin area guidelines. + static const Color _headerGuideColor = Color(0xff1e40af); // dark blue + static const Color _dynamicGuideColor = Color(0xff9fa8da); // light blue + static const Color _pinnedGuideColor = Color(0xffc7c7c7); // grey + + Widget _buildGuideLine({ + required Axis axis, + required double offset, + required Color color, + }) { + if (axis == Axis.vertical) { + return Positioned( + top: 0, + bottom: 0, + left: offset, + width: pinnedBorderWidth, + child: Column( + children: [ + Container(height: columnHeadersHeight, color: _headerGuideColor), + Expanded(child: Container(color: color)), + ], + ), + ); + } + + return Positioned( + left: 0, + right: 0, + top: offset, + height: pinnedBorderWidth, + child: Row( + children: [ + Container(width: rowHeadersWidth, color: _headerGuideColor), + Expanded(child: Container(color: color)), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + widget.worksheet.addListener(_handleWorksheetChanged); + } + + @override + void dispose() { + widget.worksheet.removeListener(_handleWorksheetChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + top: 0, + left: 0, + child: SheetMouseRegion( + cursor: SystemMouseCursors.click, + onDragStart: (_) => + widget.worksheet.selection.update(SheetSelectionFactory.all()), + child: Container( + width: rowHeadersWidth, + height: columnHeadersHeight, + color: const Color(0xfff8f9fa), + ), + ), + ), + // Vertical drag handle for columns + Positioned( + top: 0, + left: rowHeadersWidth + _data.pinnedColumnsWidth - (_data.pinnedColumnCount == 0 ? pinnedBorderWidth : 0), + width: pinnedBorderWidth, + height: columnHeadersHeight, + child: SheetMouseRegion( + cursor: _isDraggingColumns + ? SystemMouseCursors.grabbing + : SystemMouseCursors.grab, + onDragStart: _handleColumnDragStart, + onDragUpdate: _handleColumnDragUpdate, + onDragEnd: _handleColumnDragEnd, + child: const ColoredBox( + color: Color(0xffb7b7b7), + ), + ), + ), + // Horizontal drag handle for rows + Positioned( + top: columnHeadersHeight + _data.pinnedRowsHeight - (_data.pinnedRowCount == 0 ? pinnedBorderWidth : 0), + left: 0, + width: rowHeadersWidth, + height: pinnedBorderWidth, + child: SheetMouseRegion( + cursor: _isDraggingRows + ? SystemMouseCursors.grabbing + : SystemMouseCursors.grab, + onDragStart: _handleRowDragStart, + onDragUpdate: _handleRowDragUpdate, + onDragEnd: _handleRowDragEnd, + child: const ColoredBox( + color: Color(0xffb7b7b7), + ), + ), + ), + if (_isDraggingColumns) ...[ + _buildPinnedColumnLine(), + if (_cursorLeft != null) _buildDynamicColumnLine(), + ], + if (_isDraggingRows) ...[ + _buildPinnedRowLine(), + if (_cursorTop != null) _buildDynamicRowLine(), + ], + ], + ); + } + + Widget _buildPinnedColumnLine() { + double pinnedWidth = _isDraggingColumns + ? _calculateColumnsWidth(_targetColumnCount) + : _data.pinnedColumnsWidth; + double x = rowHeadersWidth + pinnedWidth; + return _buildGuideLine( + axis: Axis.vertical, + offset: x, + color: _pinnedGuideColor, + ); + } + + Widget _buildDynamicColumnLine() { + return _buildGuideLine( + axis: Axis.vertical, + offset: _cursorLeft!, + color: _dynamicGuideColor, + ); + } + + Widget _buildPinnedRowLine() { + double pinnedHeight = + _isDraggingRows ? _calculateRowsHeight(_targetRowCount) : _data.pinnedRowsHeight; + double y = columnHeadersHeight + pinnedHeight; + return _buildGuideLine( + axis: Axis.horizontal, + offset: y, + color: _pinnedGuideColor, + ); + } + + Widget _buildDynamicRowLine() { + return _buildGuideLine( + axis: Axis.horizontal, + offset: _cursorTop!, + color: _dynamicGuideColor, + ); + } + + void _handleColumnDragStart(PointerDownEvent event) { + setState(() { + _isDraggingColumns = true; + _cursorLeft = rowHeadersWidth + _data.pinnedColumnsWidth; + _targetColumnCount = _data.pinnedColumnCount; + }); + } + + void _handleColumnDragUpdate(PointerMoveEvent event) { + Offset local = widget.worksheet.viewport.globalOffsetToLocal(event.position); + setState(() { + _cursorLeft = local.dx.clamp(rowHeadersWidth, double.infinity); + _targetColumnCount = _calculatePinnedColumns(local.dx); + }); + } + + void _handleColumnDragEnd() { + widget.worksheet.resolve(SetPinnedColumnsEvent(_targetColumnCount)); + setState(() { + _isDraggingColumns = false; + _cursorLeft = null; + }); + } + + int _calculatePinnedColumns(double localX) { + return _calculatePinnedCount( + position: localX, + headerOffset: rowHeadersWidth, + itemCount: _data.columnCount, + itemSize: (int i) => _data.columns.getWidth(ColumnIndex(i)), + ); + } + + void _handleRowDragStart(PointerDownEvent event) { + setState(() { + _isDraggingRows = true; + _cursorTop = columnHeadersHeight + _data.pinnedRowsHeight; + _targetRowCount = _data.pinnedRowCount; + }); + } + + void _handleRowDragUpdate(PointerMoveEvent event) { + Offset local = widget.worksheet.viewport.globalOffsetToLocal(event.position); + setState(() { + _cursorTop = local.dy.clamp(columnHeadersHeight, double.infinity); + _targetRowCount = _calculatePinnedRows(local.dy); + }); + } + + void _handleRowDragEnd() { + widget.worksheet.resolve(SetPinnedRowsEvent(_targetRowCount)); + setState(() { + _isDraggingRows = false; + _cursorTop = null; + }); + } + + int _calculatePinnedRows(double localY) { + return _calculatePinnedCount( + position: localY, + headerOffset: columnHeadersHeight, + itemCount: _data.rowCount, + itemSize: (int i) => _data.rows.getHeight(RowIndex(i)), + ); + } + + double _calculateColumnsWidth(int count) { + return _calculateSize( + count: count, + totalCount: _data.columnCount, + itemSize: (int i) => _data.columns.getWidth(ColumnIndex(i)), + ); + } + + double _calculateRowsHeight(int count) { + return _calculateSize( + count: count, + totalCount: _data.rowCount, + itemSize: (int i) => _data.rows.getHeight(RowIndex(i)), + ); + } + + int _calculatePinnedCount({ + required double position, + required double headerOffset, + required int itemCount, + required double Function(int index) itemSize, + }) { + double offset = position - headerOffset; + if (offset <= 0) { + return 0; + } + + double length = 0; + for (int i = 0; i < itemCount; i++) { + length += itemSize(i) + borderWidth; + if (offset < length) { + return i + 1; + } + } + return itemCount; + } + + double _calculateSize({ + required int count, + required int totalCount, + required double Function(int index) itemSize, + }) { + double size = 0; + int maxCount = count < totalCount ? count : totalCount; + for (int i = 0; i < maxCount; i++) { + size += itemSize(i) + borderWidth; + } + return size; + } + + void _handleWorksheetChanged() { + setState(() {}); + } +} diff --git a/lib/layers/shared_paints.dart b/lib/layers/shared_paints.dart index 042f489c..ebd2f170 100644 --- a/lib/layers/shared_paints.dart +++ b/lib/layers/shared_paints.dart @@ -52,12 +52,24 @@ class SharedPaints { canvas.drawLine(rightBorderLine.start, rightBorderLine.end, rightBorderPaint); } - if (bottomBorderSide.width > 0 && bottomBorderSide != BorderSide.none) { - canvas.drawLine(bottomBorderLine.start, bottomBorderLine.end, bottomBorderPaint); + if (bottomBorderSide.width > 0 && + bottomBorderSide != BorderSide.none && + edgeVisibility.bottom) { + canvas.drawLine( + bottomBorderLine.start, + bottomBorderLine.end, + bottomBorderPaint, + ); } - if (leftBorderSide.width > 0 && leftBorderSide != BorderSide.none) { - canvas.drawLine(leftBorderLine.start, leftBorderLine.end, leftBorderPaint); + if (leftBorderSide.width > 0 && + leftBorderSide != BorderSide.none && + edgeVisibility.left) { + canvas.drawLine( + leftBorderLine.start, + leftBorderLine.end, + leftBorderPaint, + ); } } diff --git a/lib/layers/sheet/helpers/background_color_painter.dart b/lib/layers/sheet/helpers/background_color_painter.dart new file mode 100644 index 00000000..462ce11e --- /dev/null +++ b/lib/layers/sheet/helpers/background_color_painter.dart @@ -0,0 +1,38 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:sheets/core/viewport/viewport_item.dart'; + +class BackgroundColorPainter { + BackgroundColorPainter( + {required this.color, required Iterable shapes}) + : _corners = [], + _colors = [], + _indices = [] { + shapes.forEach(_fillRect); + } + + static const int _cornersCount = 4; + + final Color color; + final List _corners; + final List _colors; + final List _indices; + + void layout(Canvas canvas) { + canvas.drawVertices( + Vertices(VertexMode.triangles, _corners, + colors: _colors, indices: _indices), + BlendMode.srcOver, + Paint(), + ); + } + + void _fillRect(BorderRect rect) { + int offset = _corners.length ~/ _cornersCount; + List cornerPoints = rect.asOffsets; + _corners.addAll(cornerPoints); + _colors.addAll(List.filled(cornerPoints.length, color)); + _indices.addAll([0, 1, 2, 1, 2, 3] + .map((int index) => index + offset * _cornersCount)); + } +} diff --git a/lib/layers/sheet/helpers/cell_text_painter.dart b/lib/layers/sheet/helpers/cell_text_painter.dart new file mode 100644 index 00000000..f613109d --- /dev/null +++ b/lib/layers/sheet/helpers/cell_text_painter.dart @@ -0,0 +1,99 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:sheets/core/cell_properties.dart'; +import 'package:sheets/core/sheet_style.dart'; +import 'package:sheets/core/values/sheet_text_span.dart'; +import 'package:sheets/core/viewport/viewport_item.dart'; +import 'package:sheets/utils/extensions/text_span_extensions.dart'; +import 'package:sheets/utils/text_rotation.dart'; + +class CellTextPainter { + const CellTextPainter(this.padding); + + final EdgeInsets padding; + + void paint(Canvas canvas, ViewportCell cell) { + final SheetRichText richText = cell.properties.visibleRichText; + if (richText.isEmpty) return; + + final CellStyle style = cell.properties.style; + final TextPainter painter = _createPainter(richText, cell.properties); + final Offset offset = _calculateOffset(painter, cell, style); + + _drawText(canvas, painter, cell.rect.topLeft + offset, style.rotation); + } + + TextPainter _createPainter(SheetRichText text, CellProperties props) { + TextSpan span = text.toTextSpan(); + if (props.style.rotation == TextRotation.vertical) { + span = span.applyDivider('\n'); + } + + return TextPainter( + text: span, + textAlign: props.visibleTextAlign, + textDirection: TextDirection.ltr, + )..layout(); + } + + Offset _calculateOffset(TextPainter painter, ViewportCell cell, CellStyle style) { + final double width = cell.rect.width - padding.horizontal; + final double height = cell.rect.height - padding.vertical; + + final Size rotated = _rotatedSize(painter, style.rotation); + + double dx = padding.left; + switch (cell.properties.visibleTextAlign) { + case TextAlign.center: + dx += (width - rotated.width) / 2; + break; + case TextAlign.right: + case TextAlign.end: + dx += width - rotated.width; + break; + case TextAlign.left: + case TextAlign.start: + case TextAlign.justify: + break; + } + + double dy = padding.top; + final TextAlignVertical vertical = style.verticalAlign; + if (vertical == TextAlignVertical.center) { + dy += (height - rotated.height) / 2; + } else if (vertical == TextAlignVertical.bottom) { + dy += height - rotated.height; + } + + return Offset(dx, dy); + } + + Size _rotatedSize(TextPainter painter, TextRotation rotation) { + final double angle = rotation.angle * pi / 180; + final double c = cos(angle).abs(); + final double s = sin(angle).abs(); + return Size( + painter.width * c + painter.height * s, + painter.width * s + painter.height * c, + ); + } + + void _drawText( + Canvas canvas, + TextPainter painter, + Offset position, + TextRotation rotation, + ) { + canvas.save(); + if (rotation != TextRotation.none && rotation != TextRotation.vertical) { + final double angle = rotation.angle * pi / 180; + canvas.translate(position.dx, position.dy); + canvas.rotate(angle); + painter.paint(canvas, Offset.zero); + } else { + painter.paint(canvas, position); + } + canvas.restore(); + } +} diff --git a/lib/layers/sheet/helpers/mesh.dart b/lib/layers/sheet/helpers/mesh.dart new file mode 100644 index 00000000..da938c97 --- /dev/null +++ b/lib/layers/sheet/helpers/mesh.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class Mesh { + Mesh({ + required this.verticalPoints, + required this.horizontalPoints, + this.maxVertical = 0, + this.maxHorizontal = 0, + }); + + final List verticalPoints; + final List horizontalPoints; + final double maxVertical; + final double maxHorizontal; + + final Map> customVertical = + >{}; + final Map> customHorizontal = + >{}; + + bool hasVertical(double x, MeshLine line) { + return customVertical[x]?.any((StyledLine s) => s.line == line) ?? false; + } + + bool hasHorizontal(double y, MeshLine line) { + return customHorizontal[y]?.any((StyledLine s) => s.line == line) ?? false; + } + + void addVertical(double x, MeshLine line, BorderSide style) { + customVertical + .putIfAbsent(x, () => {}) + .add(StyledLine(line, style)); + } + + void addHorizontal(double y, MeshLine line, BorderSide style) { + customHorizontal + .putIfAbsent(y, () => {}) + .add(StyledLine(line, style)); + } + + Map> get lines { + final Map> result = + >{}; + for (final MapEntry> entry in customVertical.entries) { + for (final StyledLine styledLine in entry.value) { + result + .putIfAbsent(styledLine.style, () => {}) + .add(styledLine.line); + } + } + for (final MapEntry> entry in + customHorizontal.entries) { + for (final StyledLine styledLine in entry.value) { + result + .putIfAbsent(styledLine.style, () => {}) + .add(styledLine.line); + } + } + return result.map((BorderSide key, Set value) => + MapEntry>(key, value.toList())); + } +} + +class StyledLine with EquatableMixin { + StyledLine(this.line, this.style); + + final MeshLine line; + final BorderSide style; + + @override + List get props => [line, style]; +} + +class MeshLine with EquatableMixin { + MeshLine(this.start, this.end); + + final Offset start; + final Offset end; + + @override + List get props => [start, end]; + + @override + String toString() => 'Line{start: $start, end: $end}'; +} diff --git a/lib/layers/sheet/helpers/mesh_painter.dart b/lib/layers/sheet/helpers/mesh_painter.dart new file mode 100644 index 00000000..d5e51377 --- /dev/null +++ b/lib/layers/sheet/helpers/mesh_painter.dart @@ -0,0 +1,162 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:sheets/widgets/material/material_sheet_theme.dart'; +import 'package:sheets/layers/sheet/helpers/mesh.dart'; +import 'package:sheets/core/viewport/viewport_item.dart'; +import 'package:sheets/core/config/sheet_constants.dart'; + +class MeshPainter { + const MeshPainter(); + + void paint(Canvas canvas, Iterable cells, Rect clipRect) { + if (cells.isEmpty) return; + final Mesh mesh = _buildMesh(cells); + canvas.save(); + canvas.clipRect(clipRect); + _drawMesh(canvas, mesh); + canvas.restore(); + } + + Mesh _buildMesh(Iterable cells) { + final Set vertical = {}; + final Set horizontal = {}; + + for (final ViewportCell cell in cells) { + vertical + ..add(cell.rect.left) + ..add(cell.rect.right); + horizontal + ..add(cell.rect.top) + ..add(cell.rect.bottom); + } + + final List vPoints = vertical.toList()..sort(); + final List hPoints = horizontal.toList()..sort(); + + final Mesh mesh = Mesh( + verticalPoints: vPoints, + horizontalPoints: hPoints, + maxHorizontal: vPoints.isNotEmpty ? vPoints.last : 0, + maxVertical: hPoints.isNotEmpty ? hPoints.last : 0, + ); + + final BorderSide defaultBorder = MaterialSheetTheme.defaultBorderSide; + + for (final ViewportCell cell in cells) { + _addCustomLines( + mesh, + cell.rect, + cell.properties.style.border, + defaultBorder, + ); + } + for (final ViewportCell cell in cells) { + _addDefaultLines(mesh, cell.rect, defaultBorder); + } + + return mesh; + } + + void _addDefaultLines(Mesh mesh, Rect rect, BorderSide style) { + const double shift = borderWidth / 2; + + final MeshLine top = MeshLine( + rect.topLeft.translate(0, -shift), + rect.topRight.translate(0, -shift), + ); + final MeshLine right = MeshLine( + rect.topRight.translate(shift, 0), + rect.bottomRight.translate(shift, 0), + ); + final MeshLine bottom = MeshLine( + rect.bottomLeft.translate(0, shift), + rect.bottomRight.translate(0, shift), + ); + final MeshLine left = MeshLine( + rect.topLeft.translate(-shift, 0), + rect.bottomLeft.translate(-shift, 0), + ); + + if (!mesh.hasHorizontal(rect.top - shift, top)) { + mesh.addHorizontal(rect.top - shift, top, style); + } + if (!mesh.hasHorizontal(rect.bottom + shift, bottom)) { + mesh.addHorizontal(rect.bottom + shift, bottom, style); + } + if (!mesh.hasVertical(rect.right + shift, right)) { + mesh.addVertical(rect.right + shift, right, style); + } + if (!mesh.hasVertical(rect.left - shift, left)) { + mesh.addVertical(rect.left - shift, left, style); + } + } + + void _addCustomLines( + Mesh mesh, + Rect rect, + Border? border, + BorderSide defaultStyle, + ) { + const double shift = borderWidth / 2; + + final MeshLine top = MeshLine( + rect.topLeft.translate(0, -shift), + rect.topRight.translate(0, -shift), + ); + final MeshLine right = MeshLine( + rect.topRight.translate(shift, 0), + rect.bottomRight.translate(shift, 0), + ); + final MeshLine bottom = MeshLine( + rect.bottomLeft.translate(0, shift), + rect.bottomRight.translate(0, shift), + ); + final MeshLine left = MeshLine( + rect.topLeft.translate(-shift, 0), + rect.bottomLeft.translate(-shift, 0), + ); + + final BorderSide topSide = border?.top ?? defaultStyle; + final BorderSide rightSide = border?.right ?? defaultStyle; + final BorderSide bottomSide = border?.bottom ?? defaultStyle; + final BorderSide leftSide = border?.left ?? defaultStyle; + + if (topSide != defaultStyle) { + mesh.addHorizontal(rect.top - shift, top, topSide); + } + if (rightSide != defaultStyle) { + mesh.addVertical(rect.right + shift, right, rightSide); + } + if (bottomSide != defaultStyle) { + mesh.addHorizontal(rect.bottom + shift, bottom, bottomSide); + } + if (leftSide != defaultStyle) { + mesh.addVertical(rect.left - shift, left, leftSide); + } + } + + void _drawMesh(Canvas canvas, Mesh mesh) { + final Map> linesByStyle = mesh.lines; + + for (final MapEntry> entry + in linesByStyle.entries) { + final BorderSide side = entry.key; + final List lines = entry.value; + final Paint paint = Paint() + ..color = side.color + ..strokeWidth = side.width + ..isAntiAlias = false + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.square; + + canvas.drawPoints( + PointMode.lines, + lines + .expand((MeshLine line) => [line.start, line.end]) + .toList(), + paint, + ); + } + } +} diff --git a/lib/layers/sheet/helpers/pinned_border_painter.dart b/lib/layers/sheet/helpers/pinned_border_painter.dart new file mode 100644 index 00000000..f6e96fb7 --- /dev/null +++ b/lib/layers/sheet/helpers/pinned_border_painter.dart @@ -0,0 +1,41 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:sheets/core/config/sheet_constants.dart'; +import 'package:sheets/core/sheet_data.dart'; + +class PinnedBorderPainter { + const PinnedBorderPainter(this.data); + + final WorksheetData data; + + void paint(Canvas canvas, Size size) { + final Paint borderPaint = Paint() + ..color = const Color(0xffb7b7b7) + ..style = PaintingStyle.fill; + + if (data.pinnedColumnsWidth > 0) { + canvas.drawRect( + Rect.fromLTWH( + rowHeadersWidth + data.pinnedColumnsWidth, + 0, + pinnedBorderWidth, + size.height, + ), + borderPaint, + ); + } + + if (data.pinnedRowsHeight > 0) { + canvas.drawRect( + Rect.fromLTWH( + 0, + columnHeadersHeight + data.pinnedRowsHeight, + size.width, + pinnedBorderWidth, + ), + borderPaint, + ); + } + } +} diff --git a/lib/layers/sheet/helpers/pinned_cell_groups.dart b/lib/layers/sheet/helpers/pinned_cell_groups.dart new file mode 100644 index 00000000..80b06f04 --- /dev/null +++ b/lib/layers/sheet/helpers/pinned_cell_groups.dart @@ -0,0 +1,48 @@ +import 'package:sheets/core/sheet_data.dart'; +import 'package:sheets/core/viewport/viewport_item.dart'; + +class PinnedCellGroups { + PinnedCellGroups({ + required this.normal, + required this.rows, + required this.columns, + required this.both, + }); + + final List normal; + final List rows; + final List columns; + final List both; +} + +PinnedCellGroups groupPinnedCells( + Iterable cells, + WorksheetData data, +) { + final List both = []; + final List rows = []; + final List columns = []; + final List normal = []; + + for (final ViewportCell cell in cells) { + final bool rowPinned = cell.index.row.value < data.pinnedRowCount; + final bool columnPinned = cell.index.column.value < data.pinnedColumnCount; + + if (rowPinned && columnPinned) { + both.add(cell); + } else if (rowPinned) { + rows.add(cell); + } else if (columnPinned) { + columns.add(cell); + } else { + normal.add(cell); + } + } + + return PinnedCellGroups( + normal: normal, + rows: rows, + columns: columns, + both: both, + ); +} diff --git a/lib/layers/sheet/helpers/pinned_header_groups.dart b/lib/layers/sheet/helpers/pinned_header_groups.dart new file mode 100644 index 00000000..731d077b --- /dev/null +++ b/lib/layers/sheet/helpers/pinned_header_groups.dart @@ -0,0 +1,25 @@ +class PinnedHeaderGroups { + const PinnedHeaderGroups({required this.pinned, required this.normal}); + + final List pinned; + final List normal; +} + +PinnedHeaderGroups groupPinnedHeaders( + Iterable headers, + int pinnedCount, + int Function(T) indexGetter, +) { + final List pinned = []; + final List normal = []; + + for (final T header in headers) { + if (indexGetter(header) < pinnedCount) { + pinned.add(header); + } else { + normal.add(header); + } + } + + return PinnedHeaderGroups(pinned: pinned, normal: normal); +} diff --git a/lib/layers/sheet/helpers/style_based_painter_builder.dart b/lib/layers/sheet/helpers/style_based_painter_builder.dart new file mode 100644 index 00000000..f09146ea --- /dev/null +++ b/lib/layers/sheet/helpers/style_based_painter_builder.dart @@ -0,0 +1,24 @@ +import 'package:sheets/core/sheet_style.dart'; +import 'package:sheets/core/viewport/viewport_item.dart'; + +class StyleBasedPainterBuilder { + StyleBasedPainterBuilder({required this.cells, required this.builder}); + + final List cells; + final void Function(CellStyle style, List cells) builder; + + void build() { + final Map> cellsByStyle = + >{}; + + for (final ViewportCell cell in cells) { + final CellStyle style = cell.properties.style; + cellsByStyle.putIfAbsent(style, () => []).add(cell); + } + + for (final MapEntry> entry + in cellsByStyle.entries) { + builder(entry.key, entry.value); + } + } +} diff --git a/lib/layers/sheet/sheet_cells_layer_painter.dart b/lib/layers/sheet/sheet_cells_layer_painter.dart index 14eaa3e8..c8af02fc 100644 --- a/lib/layers/sheet/sheet_cells_layer_painter.dart +++ b/lib/layers/sheet/sheet_cells_layer_painter.dart @@ -1,19 +1,22 @@ -import 'dart:math'; -import 'dart:ui'; - -import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:sheets/core/cell_properties.dart'; import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/sheet_style.dart'; -import 'package:sheets/core/values/sheet_text_span.dart'; import 'package:sheets/core/viewport/sheet_viewport_content_manager.dart'; -import 'package:sheets/core/viewport/viewport_item.dart'; +import 'package:sheets/core/viewport/viewport_item.dart' show ViewportCell; import 'package:sheets/core/worksheet.dart'; -import 'package:sheets/utils/extensions/offset_extensions.dart'; -import 'package:sheets/utils/extensions/text_span_extensions.dart'; -import 'package:sheets/utils/text_rotation.dart'; -import 'package:sheets/widgets/material/material_sheet_theme.dart'; +import 'package:sheets/layers/sheet/helpers/background_color_painter.dart'; +import 'package:sheets/layers/sheet/helpers/cell_text_painter.dart'; +import 'package:sheets/layers/sheet/helpers/pinned_cell_groups.dart'; +import 'package:sheets/layers/sheet/helpers/mesh_painter.dart'; +import 'package:sheets/layers/sheet/helpers/pinned_border_painter.dart'; +import 'package:sheets/layers/sheet/helpers/style_based_painter_builder.dart'; + +class _Region { + _Region({required this.cells, required this.clip}); + + final List cells; + final Rect clip; +} class CellsDrawingController extends ChangeNotifier { void rebuild() { @@ -26,386 +29,132 @@ class SheetCellsLayerPainter extends CustomPainter { required this.worksheet, required CellsDrawingController drawingController, EdgeInsets? padding, - }) : _padding = padding ?? const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + }) : _padding = + padding ?? const EdgeInsets.symmetric(horizontal: 3, vertical: 2), super(repaint: drawingController); final Worksheet worksheet; final EdgeInsets _padding; - SheetViewportContentManager get _visibleContent => worksheet.viewport.visibleContent; + SheetViewportContentManager get _visibleContent => + worksheet.viewport.visibleContent; @override void paint(Canvas canvas, Size size) { _clipCellsLayerBox(canvas, size); - - _StyleBasedPainterBuilder( - cells: _visibleContent.cells, - builder: (CellStyle style, List cells) { - _BackgroundColorPainter( - color: style.backgroundColor, - shapes: cells.map((ViewportCell cell) => cell.rect), - ).layout(canvas); - }, - ).build(); - - - for (ViewportCell cell in _visibleContent.cells) { - _paintCellText(canvas, cell); + final PinnedCellGroups groups = + groupPinnedCells(_visibleContent.cells, worksheet.data); + final List<_Region> regions = _buildRegions(size, groups); + + for (final _Region region in regions) { + if (region.cells.isEmpty) continue; + canvas.save(); + canvas.clipRect(region.clip); + _paintCells(canvas, region.cells); + canvas.restore(); } - _paintMesh(canvas, size); + _paintMesh(canvas, regions); + + _paintPinnedBorders(canvas, size); } @override bool shouldRepaint(covariant SheetCellsLayerPainter oldDelegate) { - return oldDelegate._visibleContent != _visibleContent || oldDelegate._padding != _padding; + return oldDelegate._visibleContent != _visibleContent || + oldDelegate._padding != _padding; } void _clipCellsLayerBox(Canvas canvas, Size size) { - canvas.clipRect(Rect.fromLTWH(rowHeadersWidth + borderWidth, columnHeadersHeight + borderWidth, size.width, size.height)); + canvas.clipRect(Rect.fromLTWH(rowHeadersWidth + borderWidth, + columnHeadersHeight + borderWidth, size.width, size.height)); } - void _paintMesh(Canvas canvas, Size size) { - canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - List visibleColumns = _visibleContent.columns; - List visibleRows = _visibleContent.rows; - List visibleCells = _visibleContent.cells; - - if (visibleColumns.isEmpty || visibleRows.isEmpty) { - return; - } - - Mesh mesh = Mesh( - verticalPoints: visibleColumns.map((ViewportColumn column) => column.rect.left).toList(), - horizontalPoints: visibleRows.map((ViewportRow row) => row.rect.top).toList(), - maxHorizontal: visibleColumns.last.rect.right, - maxVertical: visibleRows.last.rect.bottom, - ); - - for (ViewportCell cell in visibleCells) { - Rect cellRect = cell.rect; - BorderSide defaultBorder = MaterialSheetTheme.defaultBorderSide; - - Line topBorderLine = Line( - cellRect.topLeft.moveY(-borderWidth), - cellRect.topRight.moveY(-borderWidth).expandEndX(borderWidth), - ); - - Line rightBorderLine = Line( - cellRect.topRight.moveX(borderWidth).expandEndY(-1), - cellRect.bottomRight.moveX(borderWidth), - ); - - Line bottomBorderLine = Line( - cellRect.bottomLeft, - cellRect.bottomRight.expandEndX(borderWidth), - ); - - Line leftBorderLine = Line( - cellRect.topLeft.expandEndY(-borderWidth), - cellRect.bottomLeft, - ); - - mesh.addHorizontal(cellRect.top, topBorderLine, defaultBorder); - mesh.addHorizontal(cellRect.bottom, bottomBorderLine, defaultBorder); - mesh.addVertical(cellRect.right, rightBorderLine, defaultBorder); - mesh.addVertical(cellRect.left, leftBorderLine, defaultBorder); - } - - for (ViewportCell cell in visibleCells) { - Rect cellRect = cell.rect; - Border? border = cell.properties.style.border; - - BorderSide defaultBorder = MaterialSheetTheme.defaultBorderSide; - - BorderSide topBorderSide = border?.top ?? defaultBorder; - Line topBorderLine = Line( - cellRect.topLeft.moveY(-borderWidth), - cellRect.topRight.moveY(-borderWidth).expandEndX(borderWidth), - ); - - BorderSide rightBorderSide = border?.right ?? defaultBorder; - Line rightBorderLine = Line( - cellRect.topRight.moveX(borderWidth).expandEndY(-1), - cellRect.bottomRight.moveX(borderWidth), - ); - - BorderSide bottomBorderSide = border?.bottom ?? defaultBorder; - Line bottomBorderLine = Line( - cellRect.bottomLeft, - cellRect.bottomRight.expandEndX(borderWidth), - ); - - BorderSide leftBorderSide = border?.left ?? defaultBorder; - Line leftBorderLine = Line( - cellRect.topLeft.expandEndY(-borderWidth), - cellRect.bottomLeft, - ); - - if (topBorderSide != defaultBorder) { - mesh.addHorizontal(cellRect.top, topBorderLine, topBorderSide); - } - if (rightBorderSide != defaultBorder) { - mesh.addVertical(cellRect.right, rightBorderLine, rightBorderSide); - } - if (bottomBorderSide != defaultBorder) { - mesh.addHorizontal(cellRect.bottom, bottomBorderLine, bottomBorderSide); - } - if (leftBorderSide != defaultBorder) { - mesh.addVertical(cellRect.left, leftBorderLine, leftBorderSide); - } - } - - Map> linesByStyle = mesh.lines; - - for (MapEntry> entry in linesByStyle.entries) { - BorderSide side = entry.key; - List lines = entry.value; - Paint paint = Paint() - ..color = side.color - ..strokeWidth = side.width - ..isAntiAlias = false - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.square; - - canvas.drawPoints( - PointMode.lines, - lines.expand((Line line) => [line.start, line.end]).toList(), - paint, - ); - } + List<_Region> _buildRegions(Size size, PinnedCellGroups groups) { + final double pinnedColumnsWidth = worksheet.data.pinnedColumnsWidth; + final double pinnedRowsHeight = worksheet.data.pinnedRowsHeight; + final double pinnedColumnsFullWidth = worksheet.data.pinnedColumnsFullWidth; + final double pinnedRowsFullHeight = worksheet.data.pinnedRowsFullHeight; + + return <_Region>[ + _Region( + cells: groups.normal, + clip: Rect.fromLTWH( + rowHeadersWidth + pinnedColumnsFullWidth, + columnHeadersHeight + pinnedRowsFullHeight, + size.width - pinnedColumnsFullWidth, + size.height - pinnedRowsFullHeight, + ), + ), + _Region( + cells: groups.rows, + clip: Rect.fromLTWH( + rowHeadersWidth + pinnedColumnsFullWidth, + columnHeadersHeight, + size.width - pinnedColumnsFullWidth, + pinnedRowsHeight, + ), + ), + _Region( + cells: groups.columns, + clip: Rect.fromLTWH( + rowHeadersWidth, + columnHeadersHeight + pinnedRowsFullHeight, + pinnedColumnsWidth, + size.height - pinnedRowsFullHeight, + ), + ), + _Region( + cells: groups.both, + clip: Rect.fromLTWH( + rowHeadersWidth, + columnHeadersHeight, + pinnedColumnsWidth, + pinnedRowsHeight, + ), + ), + ]; } - void _paintCellText(Canvas canvas, ViewportCell cell) { - CellProperties properties = cell.properties; - CellStyle cellStyle = properties.style; - - SheetRichText richText = properties.visibleRichText; - if (richText.isEmpty) { + void _paintCells(Canvas canvas, List cells) { + if (cells.isEmpty) { return; } - TextAlign textAlign = properties.visibleTextAlign; - TextRotation textRotation = cellStyle.rotation; - TextSpan textSpan = richText.toTextSpan(); - if (textRotation == TextRotation.vertical) { - textSpan = textSpan.applyDivider('\n'); - } - - TextPainter textPainter = TextPainter( - text: textSpan, - textDirection: TextDirection.ltr, - textAlign: textAlign, - ); - - // Use cell's width minus padding for text layout - double cellWidth = cell.rect.width - _padding.horizontal; - double cellHeight = cell.rect.height - _padding.vertical; - textPainter.layout(); - - // Calculate the size of the rotated text's bounding box - double angle = textRotation.angle; - double angleRad = angle * pi / 180; - double cosTheta = cos(angleRad).abs(); - double sinTheta = sin(angleRad).abs(); - double rotatedWidth = textPainter.width * cosTheta + textPainter.height * sinTheta; - double rotatedHeight = textPainter.width * sinTheta + textPainter.height * cosTheta; - - // Adjust xOffset and yOffset based on alignment - // Horizontal Alignment - double xOffset; - switch (textAlign) { - case TextAlign.justify: - case TextAlign.left: - case TextAlign.start: - xOffset = _padding.left; - case TextAlign.center: - xOffset = _padding.left + (cellWidth - rotatedWidth) / 2; - case TextAlign.right: - case TextAlign.end: - xOffset = _padding.left + (cellWidth - rotatedWidth); - } - - // Vertical Alignment - double yOffset; - TextAlignVertical verticalAlign = cellStyle.verticalAlign; - if (verticalAlign == TextAlignVertical.top) { - yOffset = _padding.top; - } else if (verticalAlign == TextAlignVertical.center) { - yOffset = _padding.top + (cellHeight - rotatedHeight) / 2; - } else if (verticalAlign == TextAlignVertical.bottom) { - yOffset = _padding.top + (cellHeight - rotatedHeight); - } else { - yOffset = _padding.top; - } - - Offset textPosition = cell.rect.topLeft + Offset(xOffset, yOffset); - textPainter.paint(canvas, textPosition); - - // // Position where the text should be painted - // Offset textPosition = cell.rect.topLeft + Offset(xOffset, yOffset); - // - // // Save the canvas state - // canvas.save(); - // - // // Clip the canvas to the cell rectangle - // canvas.clipRect(cell.rect); - // - // if (textRotation == TextRotation.vertical) { - // // Paint the text at the calculated position - // textPainter.paint(canvas, textPosition); - // } else if (angle != 0) { - // // Translate to the center of the rotated bounding box - // canvas.translate( - // textPosition.dx + rotatedWidth / 2, - // textPosition.dy + rotatedHeight / 2, - // ); - // - // // Rotate the canvas - // canvas.rotate(angleRad); - // - // // Translate back to the top-left of the text - // canvas.translate(-textPainter.width / 2, -textPainter.height / 2); - // - // // Paint the text at (0,0) - // textPainter.paint(canvas, Offset.zero); - // } else { - // // Paint the text at the calculated position - // textPainter.paint(canvas, textPosition); - // } - // - // // Restore the canvas state - // canvas.restore(); - } -} - -class _StyleBasedPainterBuilder { - _StyleBasedPainterBuilder({required this.cells, required this.builder}); - - final List cells; - final void Function(CellStyle style, List cells) builder; - - - void build() { - Map> cellsByStyle = >{}; + StyleBasedPainterBuilder( + cells: cells, + builder: (CellStyle style, List cells) { + BackgroundColorPainter( + color: style.backgroundColor, + shapes: cells.map((ViewportCell cell) => cell.rect), + ).layout(canvas); + }, + ).build(); for (ViewportCell cell in cells) { - CellStyle style = cell.properties.style; - cellsByStyle.putIfAbsent(style, () => []).add(cell); - } - - for (MapEntry> entry in cellsByStyle.entries) { - builder(entry.key, entry.value); + _paintCellText(canvas, cell); } } -} - -class _BackgroundColorPainter { - _BackgroundColorPainter({ - required this.color, - required Iterable shapes, - }) : _corners = [], - _colors = [], - _indices = [] { - shapes.forEach(_fillRect); - } - - static const int _cornersCount = 4; - - final Color color; - final List _corners; - final List _colors; - final List _indices; - - void layout(Canvas canvas) { - canvas.drawVertices( - Vertices(VertexMode.triangles, _corners, colors: _colors, indices: _indices), - BlendMode.srcOver, - Paint(), - ); - } - - void _fillRect(BorderRect rect) { - int offset = _corners.length ~/ _cornersCount; - List cornerPoints = rect.asOffsets; - - _corners.addAll(cornerPoints); - _colors.addAll(List.filled(cornerPoints.length, color)); - _indices.addAll([0, 1, 2, 1, 2, 3].map((int index) => index + offset * _cornersCount)); - } -} - -class StyledLine { - StyledLine(this.line, this.style); - - final Line line; - final BorderSide style; -} - -class Mesh { - Mesh({ - required this.verticalPoints, - required this.horizontalPoints, - this.maxVertical = 0, - this.maxHorizontal = 0, - }); - - final List verticalPoints; - final List horizontalPoints; - final double maxVertical; - final double maxHorizontal; - Map> customVertical = >{}; - Map> customHorizontal = >{}; - - void addVertical(double x, Line line, BorderSide style) { - customVertical.putIfAbsent(x, () => []).add(StyledLine(line, style)); + void _paintMesh(Canvas canvas, List<_Region> regions) { + for (final _Region region in regions) { + _paintMeshForCells(canvas, region.cells, region.clip); + } } - void addHorizontal(double y, Line line, BorderSide style) { - customHorizontal.putIfAbsent(y, () => []).add(StyledLine(line, style)); + void _paintMeshForCells( + Canvas canvas, + List cells, + Rect clipRect, + ) { + MeshPainter().paint(canvas, cells, clipRect); } - Map> get lines { - Map> result = >{}; - - for (MapEntry> entry in customVertical.entries) { - List lines = entry.value; - - for (StyledLine styledLine in lines) { - BorderSide style = styledLine.style; - Line line = styledLine.line; - - result.putIfAbsent(style, () => []).add(line); - } - } - - for (MapEntry> entry in customHorizontal.entries) { - List lines = entry.value; - - for (StyledLine styledLine in lines) { - BorderSide style = styledLine.style; - Line line = styledLine.line; - - result.putIfAbsent(style, () => []).add(line); - } - } - - return result; + void _paintPinnedBorders(Canvas canvas, Size size) { + PinnedBorderPainter(worksheet.data).paint(canvas, size); } -} -class Line with EquatableMixin { - Line(this.start, this.end); - - final Offset start; - final Offset end; - - @override - List get props => [start, end]; - - @override - String toString() { - return 'Line{start: $start, end: $end}'; + void _paintCellText(Canvas canvas, ViewportCell cell) { + CellTextPainter(_padding).paint(canvas, cell); } } diff --git a/lib/layers/sheet/sheet_headers_painter.dart b/lib/layers/sheet/sheet_headers_painter.dart index 80257f94..b8e7d059 100644 --- a/lib/layers/sheet/sheet_headers_painter.dart +++ b/lib/layers/sheet/sheet_headers_painter.dart @@ -5,9 +5,12 @@ import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/selection/selection_status.dart'; import 'package:sheets/core/selection/sheet_selection.dart'; import 'package:sheets/core/viewport/viewport_item.dart'; +import 'package:sheets/layers/sheet/helpers/pinned_header_groups.dart'; -abstract class SheetHeadersPainter extends ChangeNotifier implements CustomPainter { - void paintHeadersBackground(Canvas canvas, Rect rect, SelectionStatus selectionStatus) { +abstract class SheetHeadersPainter extends ChangeNotifier + implements CustomPainter { + void paintHeadersBackground( + Canvas canvas, Rect rect, SelectionStatus selectionStatus) { Color backgroundColor = selectionStatus.selectValue( fullySelected: const Color(0xff2456cb), selected: const Color(0xffd6e2fb), @@ -22,7 +25,8 @@ abstract class SheetHeadersPainter extends ChangeNotifier implements CustomPaint canvas.drawRect(rect, backgroundPaint); } - void paintRowLabel(Canvas canvas, Rect rect, String value, SelectionStatus selectionStatus) { + void paintRowLabel( + Canvas canvas, Rect rect, String value, SelectionStatus selectionStatus) { TextStyle textStyle = selectionStatus.selectValue( fullySelected: defaultHeaderTextStyleSelectedAll.copyWith(height: 1.2), selected: defaultHeaderTextStyleSelected.copyWith(height: 1.2), @@ -36,10 +40,12 @@ abstract class SheetHeadersPainter extends ChangeNotifier implements CustomPaint ); textPainter.layout(minWidth: rect.width, maxWidth: rect.width); - textPainter.paint(canvas, rect.center - Offset(textPainter.width / 2, textPainter.height / 2)); + textPainter.paint(canvas, + rect.center - Offset(textPainter.width / 2, textPainter.height / 2)); } - void paintColumnLabel(Canvas canvas, Rect rect, String value, SelectionStatus selectionStatus) { + void paintColumnLabel( + Canvas canvas, Rect rect, String value, SelectionStatus selectionStatus) { TextStyle textStyle = selectionStatus.selectValue( fullySelected: defaultHeaderTextStyleSelectedAll.copyWith(height: 1), selected: defaultHeaderTextStyleSelected.copyWith(height: 1), @@ -53,7 +59,8 @@ abstract class SheetHeadersPainter extends ChangeNotifier implements CustomPaint ); textPainter.layout(minWidth: rect.width, maxWidth: rect.width); - textPainter.paint(canvas, rect.center - Offset(textPainter.width / 2, textPainter.height / 2)); + textPainter.paint(canvas, + rect.center - Offset(textPainter.width / 2, textPainter.height / 2)); } @override @@ -69,13 +76,19 @@ abstract class SheetHeadersPainter extends ChangeNotifier implements CustomPaint class SheetColumnHeadersPainter extends SheetHeadersPainter { late List _visibleColumns; late SheetSelection _selection; + late double _pinnedWidth; + late int _pinnedCount; void rebuild({ required List visibleColumns, required SheetSelection selection, + required int pinnedCount, + required double pinnedWidth, }) { _visibleColumns = visibleColumns; _selection = selection; + _pinnedCount = pinnedCount; + _pinnedWidth = pinnedWidth; notifyListeners(); } @@ -89,35 +102,84 @@ class SheetColumnHeadersPainter extends SheetHeadersPainter { ..isAntiAlias = false ..color = const Color(0xffc4c7c5); - double width = min(size.width, _visibleColumns.last.rect.right - rowHeadersWidth + borderWidth); + double width = min(size.width, + _visibleColumns.last.rect.right - rowHeadersWidth + borderWidth); Rect visibleRect = Rect.fromLTWH(rowHeadersWidth, 0, width, size.height); canvas.clipRect(visibleRect); canvas.drawRect(visibleRect, backgroundPaint); - for (ViewportColumn column in _visibleColumns) { - SelectionStatus selectionStatus = _selection.isColumnSelected(column.index); + final PinnedHeaderGroups groups = groupPinnedHeaders( + _visibleColumns, + _pinnedCount, + (ViewportColumn column) => column.index.value, + ); + + canvas.save(); + canvas.clipRect(Rect.fromLTWH( + rowHeadersWidth + _pinnedWidth, + 0, + size.width - _pinnedWidth, + size.height, + )); + _paintColumns(canvas, groups.normal); + canvas.restore(); + + canvas.save(); + canvas + .clipRect(Rect.fromLTWH(rowHeadersWidth, 0, _pinnedWidth, size.height)); + _paintColumns(canvas, groups.pinned); + canvas.restore(); + + Paint borderPaint = Paint() + ..color = const Color(0xffb7b7b7) + ..style = PaintingStyle.fill; - paintHeadersBackground(canvas, column.rect, selectionStatus); - paintColumnLabel(canvas, column.rect, column.value, selectionStatus); + if (_pinnedWidth > 0) { + canvas.drawRect( + Rect.fromLTWH( + rowHeadersWidth + _pinnedWidth - pinnedBorderWidth, + 0, + pinnedBorderWidth, + size.height, + ), + borderPaint, + ); } } @override bool shouldRepaint(covariant SheetColumnHeadersPainter oldDelegate) { - return oldDelegate._visibleColumns != _visibleColumns || oldDelegate._selection != _selection; + return oldDelegate._visibleColumns != _visibleColumns || + oldDelegate._selection != _selection || + oldDelegate._pinnedWidth != _pinnedWidth || + oldDelegate._pinnedCount != _pinnedCount; + } + + void _paintColumns(Canvas canvas, List columns) { + for (final ViewportColumn column in columns) { + final SelectionStatus status = _selection.isColumnSelected(column.index); + paintHeadersBackground(canvas, column.rect, status); + paintColumnLabel(canvas, column.rect, column.value, status); + } } } class SheetRowHeadersPainter extends SheetHeadersPainter { late List _visibleRows; late SheetSelection _selection; + late double _pinnedHeight; + late int _pinnedCount; void rebuild({ required List visibleRows, required SheetSelection selection, + required int pinnedCount, + required double pinnedHeight, }) { _visibleRows = visibleRows; _selection = selection; + _pinnedCount = pinnedCount; + _pinnedHeight = pinnedHeight; notifyListeners(); } @@ -132,21 +194,65 @@ class SheetRowHeadersPainter extends SheetHeadersPainter { ..isAntiAlias = false ..color = const Color(0xffc4c7c5); - double height = min(size.height, _visibleRows.last.rect.bottom - columnHeadersHeight + borderWidth); - Rect visibleRect = Rect.fromLTWH(0, columnHeadersHeight, size.width, height); + double height = min(size.height, + _visibleRows.last.rect.bottom - columnHeadersHeight + borderWidth); + Rect visibleRect = + Rect.fromLTWH(0, columnHeadersHeight, size.width, height); canvas.clipRect(visibleRect); canvas.drawRect(visibleRect, backgroundPaint); - for (ViewportRow row in _visibleRows) { - SelectionStatus selectionStatus = _selection.isRowSelected(row.index); + final PinnedHeaderGroups groups = groupPinnedHeaders( + _visibleRows, + _pinnedCount, + (ViewportRow row) => row.index.value, + ); + + canvas.save(); + canvas.clipRect(Rect.fromLTWH( + 0, + columnHeadersHeight + _pinnedHeight, + size.width, + size.height - _pinnedHeight, + )); + _paintRows(canvas, groups.normal); + canvas.restore(); + + canvas.save(); + canvas.clipRect( + Rect.fromLTWH(0, columnHeadersHeight, size.width, _pinnedHeight)); + _paintRows(canvas, groups.pinned); + canvas.restore(); + + Paint borderPaint = Paint() + ..color = const Color(0xffb7b7b7) + ..style = PaintingStyle.fill; - paintHeadersBackground(canvas, row.rect, selectionStatus); - paintRowLabel(canvas, row.rect, row.value, selectionStatus); + if (_pinnedHeight > 0) { + canvas.drawRect( + Rect.fromLTWH( + 0, + columnHeadersHeight + _pinnedHeight - pinnedBorderWidth, + size.width, + pinnedBorderWidth, + ), + borderPaint, + ); } } @override bool shouldRepaint(covariant SheetRowHeadersPainter oldDelegate) { - return oldDelegate._visibleRows != _visibleRows || oldDelegate._selection != _selection; + return oldDelegate._visibleRows != _visibleRows || + oldDelegate._selection != _selection || + oldDelegate._pinnedHeight != _pinnedHeight || + oldDelegate._pinnedCount != _pinnedCount; + } + + void _paintRows(Canvas canvas, List rows) { + for (final ViewportRow row in rows) { + final SelectionStatus status = _selection.isRowSelected(row.index); + paintHeadersBackground(canvas, row.rect, status); + paintRowLabel(canvas, row.rect, row.value, status); + } } } diff --git a/lib/layers/sheet/sheet_layer.dart b/lib/layers/sheet/sheet_layer.dart index ea944076..d2ba7dde 100644 --- a/lib/layers/sheet/sheet_layer.dart +++ b/lib/layers/sheet/sheet_layer.dart @@ -196,6 +196,8 @@ class _SheetLayerState extends State { _columnHeadersPainter.rebuild( visibleColumns: _visibleContent.columns, selection: _selection, + pinnedCount: widget.worksheet.data.pinnedColumnCount, + pinnedWidth: widget.worksheet.data.pinnedColumnsFullWidth, ); } @@ -203,6 +205,8 @@ class _SheetLayerState extends State { _rowHeadersPainter.rebuild( visibleRows: _visibleContent.rows, selection: _selection, + pinnedCount: widget.worksheet.data.pinnedRowCount, + pinnedHeight: widget.worksheet.data.pinnedRowsFullHeight, ); } @@ -210,6 +214,8 @@ class _SheetLayerState extends State { _selectionPainter.rebuild( selection: _selection, viewport: _viewport, + pinnedColumnsWidth: widget.worksheet.data.pinnedColumnsFullWidth, + pinnedRowsHeight: widget.worksheet.data.pinnedRowsFullHeight, ); } diff --git a/lib/layers/sheet/sheet_selection_layer_painter.dart b/lib/layers/sheet/sheet_selection_layer_painter.dart index 0ee8d81e..656f25da 100644 --- a/lib/layers/sheet/sheet_selection_layer_painter.dart +++ b/lib/layers/sheet/sheet_selection_layer_painter.dart @@ -8,29 +8,68 @@ import 'package:sheets/core/viewport/sheet_viewport.dart'; class SheetSelectionLayerPainter extends ChangeNotifier implements CustomPainter { late SheetSelection _selection; late SheetViewport _viewport; + late double _pinnedColumnsWidth; + late double _pinnedRowsHeight; void rebuild({ required SheetSelection selection, required SheetViewport viewport, + required double pinnedColumnsWidth, + required double pinnedRowsHeight, }) { _selection = selection; _viewport = viewport; + _pinnedColumnsWidth = pinnedColumnsWidth; + _pinnedRowsHeight = pinnedRowsHeight; notifyListeners(); } @override void paint(Canvas canvas, Size size) { - canvas.clipRect(Rect.fromLTWH(rowHeadersWidth - borderWidth, columnHeadersHeight - borderWidth, size.width, size.height)); + List clipRegions = [ + Rect.fromLTWH( + rowHeadersWidth + _pinnedColumnsWidth, + columnHeadersHeight + _pinnedRowsHeight, + size.width - _pinnedColumnsWidth, + size.height - _pinnedRowsHeight, + ), + Rect.fromLTWH( + rowHeadersWidth + _pinnedColumnsWidth, + columnHeadersHeight, + size.width - _pinnedColumnsWidth, + _pinnedRowsHeight, + ), + Rect.fromLTWH( + rowHeadersWidth, + columnHeadersHeight + _pinnedRowsHeight, + _pinnedColumnsWidth, + size.height - _pinnedRowsHeight, + ), + Rect.fromLTWH( + rowHeadersWidth, + columnHeadersHeight, + _pinnedColumnsWidth, + _pinnedRowsHeight, + ), + ]; SheetSelectionRenderer selectionRenderer = _selection.createRenderer(_viewport); - SheetSelectionPaint selectionPaint = selectionRenderer.getPaint(); - selectionPaint.paint(_viewport, canvas, size); + + for (Rect rect in clipRegions) { + canvas.save(); + canvas.clipRect(rect); + selectionPaint.paint(_viewport, canvas, size); + canvas.restore(); + } } @override bool shouldRepaint(covariant SheetSelectionLayerPainter oldDelegate) { - return _selection != oldDelegate._selection || _viewport != oldDelegate._viewport; + return _selection != oldDelegate._selection || + _viewport != oldDelegate._viewport || + _pinnedColumnsWidth != oldDelegate._pinnedColumnsWidth || + _pinnedRowsHeight != oldDelegate._pinnedRowsHeight; } @override diff --git a/lib/sheet.dart b/lib/sheet.dart index 705fab7f..46c22ca7 100644 --- a/lib/sheet.dart +++ b/lib/sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/events/sheet_clipboard_events.dart'; import 'package:sheets/core/events/sheet_event.dart'; import 'package:sheets/core/events/sheet_formatting_events.dart'; @@ -11,6 +10,7 @@ import 'package:sheets/core/viewport/viewport_item.dart'; import 'package:sheets/core/worksheet.dart'; import 'package:sheets/layers/fill_handle/sheet_fill_handle_layer.dart'; import 'package:sheets/layers/headers_resizer/sheet_headers_resizer_layer.dart'; +import 'package:sheets/layers/pin_area/sheet_pin_area_layer.dart'; import 'package:sheets/layers/sheet/sheet_layer.dart'; import 'package:sheets/layers/textfield/sheet_textfield_layer.dart'; import 'package:sheets/utils/formatters/style/text_style_format.dart'; @@ -224,22 +224,8 @@ class SheetGrid extends StatelessWidget { worksheet.resolve(EnableEditingEvent(cell: viewportItem.index.toCellIndex())), ), ), - Positioned( - top: 0, - left: 0, - child: Container( - width: rowHeadersWidth + borderWidth, - height: columnHeadersHeight + borderWidth, - decoration: const BoxDecoration( - color: Color(0xfff8f9fa), - border: Border( - right: BorderSide(color: Color(0xffc7c7c7), width: 5), - bottom: BorderSide(color: Color(0xffc7c7c7), width: 5), - ), - ), - ), - ), Positioned.fill(child: HeadersResizerLayer(worksheet: worksheet)), + Positioned.fill(child: SheetPinAreaLayer(worksheet: worksheet)), Positioned.fill(child: SheetFillHandleLayer(worksheet: worksheet)), Positioned.fill(child: SheetTextfieldLayer(worksheet: worksheet)), ], diff --git a/lib/utils/edge_visibility.dart b/lib/utils/edge_visibility.dart index e30e5e5d..8084d25f 100644 --- a/lib/utils/edge_visibility.dart +++ b/lib/utils/edge_visibility.dart @@ -8,7 +8,7 @@ class EdgeVisibility with EquatableMixin { this.left = true, }); - EdgeVisibility.allVisible() + const EdgeVisibility.allVisible() : top = true, right = true, bottom = true, diff --git a/lib/widgets/scrollbar/sheet_scrollbar.dart b/lib/widgets/scrollbar/sheet_scrollbar.dart new file mode 100644 index 00000000..1259ec17 --- /dev/null +++ b/lib/widgets/scrollbar/sheet_scrollbar.dart @@ -0,0 +1,116 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sheets/widgets/sheet_mouse_region.dart'; +import 'package:sheets/widgets/widget_state_builder.dart'; +import 'sheet_scrollbar_painter.dart'; + +class SheetScrollbar extends StatefulWidget { + const SheetScrollbar({ + required this.painter, + required this.onScroll, + required this.deltaModifier, + super.key, + }); + + final SheetScrollbarPainter painter; + final ValueChanged onScroll; + final double Function(Offset offset) deltaModifier; + + @override + State createState() => _SheetScrollbarState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('painter', painter)); + properties.add( + ObjectFlagProperty>.has('onScroll', onScroll)); + properties.add(ObjectFlagProperty.has( + 'deltaModifier', deltaModifier)); + } +} + +class _SheetScrollbarState extends State { + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: SheetMouseRegion( + key: UniqueKey(), + cursor: SystemMouseCursors.basic, + onEnter: _onHover, + onExit: _onExit, + onDragUpdate: _onScroll, + child: CustomPaint(painter: widget.painter), + ), + ); + } + + void _onScroll(PointerMoveEvent event) { + final double delta = widget.deltaModifier(event.delta); + final double updatedDelta = widget.painter.parseDeltaToRealScroll(delta); + widget.onScroll(updatedDelta); + } + + void _onHover() => widget.painter.hovered = true; + void _onExit() => widget.painter.hovered = false; +} + +class ScrollbarButton extends StatelessWidget { + const ScrollbarButton({ + required this.icon, + required this.onPressed, + required this.size, + super.key, + }); + + final IconData icon; + final VoidCallback onPressed; + final double size; + + @override + Widget build(BuildContext context) { + return WidgetStateBuilder( + onTap: onPressed, + cursor: SystemMouseCursors.basic, + builder: (Set states) { + return Container( + width: size, + height: size, + color: _backgroundColor(states), + child: Center(child: Icon(icon, size: 12, color: _iconColor(states))), + ); + }, + ); + } + + Color _backgroundColor(Set states) { + if (states.contains(WidgetState.pressed)) { + return const Color(0xff919191); + } else if (states.contains(WidgetState.hovered)) { + return const Color(0xffc1c1c1); + } else { + return const Color(0xfff8f8f8); + } + } + + Color _iconColor(Set states) { + if (states.contains(WidgetState.pressed)) { + return Colors.white; + } else if (states.contains(WidgetState.hovered)) { + return const Color(0xff767676); + } else { + return const Color(0xff989898); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('icon', icon)); + properties + .add(ObjectFlagProperty.has('onPressed', onPressed)); + properties.add(DoubleProperty('size', size)); + } +} diff --git a/lib/widgets/scrollbar/sheet_scrollbar_painter.dart b/lib/widgets/scrollbar/sheet_scrollbar_painter.dart new file mode 100644 index 00000000..482c4f3b --- /dev/null +++ b/lib/widgets/scrollbar/sheet_scrollbar_painter.dart @@ -0,0 +1,139 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:sheets/core/scroll/sheet_axis_direction.dart'; +import 'package:sheets/core/scroll/sheet_scroll_metrics.dart'; +import 'package:sheets/core/scroll/sheet_scroll_position.dart'; + +class SheetScrollbarPainter extends ChangeNotifier implements CustomPainter { + SheetScrollbarPainter({required SheetAxisDirection axisDirection}) + : _axisDirection = axisDirection, + _metrics = SheetScrollMetrics.zero(axisDirection), + _position = SheetScrollPosition(); + + final SheetAxisDirection _axisDirection; + + late SheetScrollMetrics _metrics; + set scrollMetrics(SheetScrollMetrics scrollMetrics) { + if (_metrics == scrollMetrics) return; + _metrics = scrollMetrics; + notifyListeners(); + } + + late SheetScrollPosition _position; + set scrollPosition(SheetScrollPosition scrollPosition) { + if (_position == scrollPosition) return; + _position = scrollPosition; + notifyListeners(); + } + + bool _hovered = false; + set hovered(bool hovered) { + if (_hovered == hovered) return; + _hovered = hovered; + notifyListeners(); + } + + double _scrollToThumbRatio = 0; + + @override + bool? hitTest(Offset position) => false; + + @override + void paint(Canvas canvas, Size size) { + canvas.clipRect(Offset.zero & size); + _paintScrollbar(canvas, size); + } + + void _paintScrollbar(Canvas canvas, Size size) { + final double thumbMargin = _hovered ? 0 : 2; + const double thumbMinSize = 48; + + final Size trackSize = size; + late Size thumbSize; + late Offset thumbOffset; + + switch (_axisDirection) { + case SheetAxisDirection.vertical: + final double trackHeight = trackSize.height; + final double availableTrackHeight = trackHeight - (2 * thumbMargin); + + final double ratio = _viewportDimension / _contentSize; + final double offsetRatio = + _scrollOffset / (_contentSize - _viewportDimension); + + final double thumbHeight = + max(availableTrackHeight * ratio, thumbMinSize); + final double thumbPosition = + offsetRatio * (availableTrackHeight - thumbHeight) + thumbMargin; + + thumbSize = Size(trackSize.width - (thumbMargin * 2), thumbHeight); + thumbOffset = Offset(thumbMargin, thumbPosition); + + final double maxScrollPosition = _contentSize - _viewportDimension; + final double maxThumbOffset = trackHeight - thumbHeight; + _scrollToThumbRatio = maxScrollPosition / maxThumbOffset; + break; + case SheetAxisDirection.horizontal: + final double trackWidth = trackSize.width; + final double availableTrackWidth = trackWidth - (2 * thumbMargin); + + final double ratio = _viewportDimension / _contentSize; + final double offsetRatio = + _scrollOffset / (_contentSize - _viewportDimension); + + final double thumbWidth = + max(availableTrackWidth * ratio, thumbMinSize); + final double thumbPosition = + offsetRatio * (availableTrackWidth - thumbWidth) + thumbMargin; + + thumbSize = Size(thumbWidth, trackSize.height - (thumbMargin * 2)); + thumbOffset = Offset(thumbPosition, thumbMargin); + + final double maxScrollPosition = _contentSize - _viewportDimension; + final double maxThumbOffset = trackWidth - thumbWidth; + _scrollToThumbRatio = maxScrollPosition / maxThumbOffset; + break; + } + + _paintTrack(canvas, trackSize); + _paintThumb(canvas, thumbSize, thumbOffset); + } + + double get _viewportDimension => _metrics.viewportDimension; + double get _contentSize => _metrics.contentSize; + double get _scrollOffset => _position.offset; + + double parseDeltaToRealScroll(double delta) => delta * _scrollToThumbRatio; + + void _paintTrack(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + canvas.drawRect(Offset.zero & size, paint); + } + + void _paintThumb(Canvas canvas, Size size, Offset offset) { + final Paint paint = Paint() + ..color = _hovered ? const Color(0xffbdc1c6) : const Color(0xffdadce0) + ..style = PaintingStyle.fill; + + canvas.drawRRect( + RRect.fromRectAndRadius(offset & size, const Radius.circular(16)), + paint, + ); + } + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + bool shouldRebuildSemantics(covariant SheetScrollbarPainter oldDelegate) => + false; + + @override + bool shouldRepaint(covariant SheetScrollbarPainter oldDelegate) { + return oldDelegate._metrics != _metrics || + oldDelegate._position != _position; + } +} diff --git a/lib/widgets/sheet_scrollable.dart b/lib/widgets/sheet_scrollable.dart index a2fe7495..57a742ac 100644 --- a/lib/widgets/sheet_scrollable.dart +++ b/lib/widgets/sheet_scrollable.dart @@ -1,16 +1,12 @@ -import 'dart:math'; - import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:sheets/core/config/sheet_constants.dart'; import 'package:sheets/core/events/sheet_scroll_events.dart'; import 'package:sheets/core/scroll/sheet_axis_direction.dart'; -import 'package:sheets/core/scroll/sheet_scroll_metrics.dart'; -import 'package:sheets/core/scroll/sheet_scroll_position.dart'; import 'package:sheets/core/worksheet.dart'; -import 'package:sheets/widgets/sheet_mouse_region.dart'; -import 'package:sheets/widgets/widget_state_builder.dart'; +import 'package:sheets/widgets/scrollbar/sheet_scrollbar.dart'; +import 'package:sheets/widgets/scrollbar/sheet_scrollbar_painter.dart'; class SheetScrollable extends StatefulWidget { const SheetScrollable({ @@ -33,8 +29,10 @@ class SheetScrollable extends StatefulWidget { } class _SheetScrollableState extends State { - final SheetScrollbarPainter verticalScrollbarPainter = SheetScrollbarPainter(axisDirection: SheetAxisDirection.vertical); - final SheetScrollbarPainter horizontalScrollbarPainter = SheetScrollbarPainter(axisDirection: SheetAxisDirection.horizontal); + final SheetScrollbarPainter verticalScrollbarPainter = + SheetScrollbarPainter(axisDirection: SheetAxisDirection.vertical); + final SheetScrollbarPainter horizontalScrollbarPainter = + SheetScrollbarPainter(axisDirection: SheetAxisDirection.horizontal); @override void initState() { @@ -51,74 +49,12 @@ class _SheetScrollableState extends State { @override Widget build(BuildContext context) { - double scrollbarWeight = scrollbarWidth - borderWidth * 2; + final double scrollbarWeight = scrollbarWidth - borderWidth * 2; return _ScrollbarLayout( scrollbarWeight: scrollbarWeight, - verticalScrollbar: Column( - children: [ - Container(height: columnHeadersHeight), - Divider(height: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - Expanded( - child: SheetScrollbar( - painter: verticalScrollbarPainter, - deltaModifier: (Offset offset) => offset.dy, - onScroll: (double offset) { - widget.worksheet.resolve(ScrollByEvent(Offset(0, offset))); - }, - ), - ), - Divider(height: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - _ScrollbarButton( - size: scrollbarWeight, - icon: Icons.arrow_drop_up, - onPressed: () { - widget.worksheet.resolve(ScrollByEvent(const Offset(0, -20))); - }, - ), - Divider(height: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - _ScrollbarButton( - size: scrollbarWeight, - icon: Icons.arrow_drop_down, - onPressed: () { - widget.worksheet.resolve(ScrollByEvent(const Offset(0, 20))); - }, - ), - ], - ), - horizontalScrollbar: Row( - children: [ - Container(width: rowHeadersWidth), - VerticalDivider(width: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - Expanded( - child: SheetScrollbar( - painter: horizontalScrollbarPainter, - deltaModifier: (Offset offset) => offset.dx, - onScroll: (double offset) { - widget.worksheet.resolve(ScrollByEvent(Offset(offset, 0))); - }, - ), - ), - VerticalDivider(width: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - _ScrollbarButton( - size: scrollbarWeight, - icon: Icons.arrow_left, - onPressed: () { - widget.worksheet.resolve(ScrollByEvent(const Offset(-20, 0))); - }, - ), - VerticalDivider(width: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - _ScrollbarButton( - size: scrollbarWeight, - icon: Icons.arrow_right, - onPressed: () { - widget.worksheet.resolve(ScrollByEvent(const Offset(20, 0))); - }, - ), - VerticalDivider(width: borderWidth, thickness: borderWidth, color: const Color(0xffd9d9d9)), - SizedBox(width: scrollbarWeight, height: scrollbarWeight), - ], - ), + verticalScrollbar: _buildVerticalScrollbar(scrollbarWeight), + horizontalScrollbar: _buildHorizontalScrollbar(scrollbarWeight), child: Listener( onPointerSignal: (PointerSignalEvent event) { if (event is PointerScrollEvent) { @@ -130,6 +66,97 @@ class _SheetScrollableState extends State { ); } + Widget _buildVerticalScrollbar(double scrollbarWeight) { + return Column( + children: [ + Container(height: columnHeadersHeight), + Divider( + height: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + Expanded( + child: SheetScrollbar( + painter: verticalScrollbarPainter, + deltaModifier: (Offset offset) => offset.dy, + onScroll: (double offset) { + widget.worksheet.resolve(ScrollByEvent(Offset(0, offset))); + }, + ), + ), + Divider( + height: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + ScrollbarButton( + size: scrollbarWeight, + icon: Icons.arrow_drop_up, + onPressed: () { + widget.worksheet.resolve(ScrollByEvent(const Offset(0, -20))); + }, + ), + Divider( + height: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + ScrollbarButton( + size: scrollbarWeight, + icon: Icons.arrow_drop_down, + onPressed: () { + widget.worksheet.resolve(ScrollByEvent(const Offset(0, 20))); + }, + ), + ], + ); + } + + Widget _buildHorizontalScrollbar(double scrollbarWeight) { + return Row( + children: [ + Container(width: rowHeadersWidth), + VerticalDivider( + width: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + Expanded( + child: SheetScrollbar( + painter: horizontalScrollbarPainter, + deltaModifier: (Offset offset) => offset.dx, + onScroll: (double offset) { + widget.worksheet.resolve(ScrollByEvent(Offset(offset, 0))); + }, + ), + ), + VerticalDivider( + width: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + ScrollbarButton( + size: scrollbarWeight, + icon: Icons.arrow_left, + onPressed: () { + widget.worksheet.resolve(ScrollByEvent(const Offset(-20, 0))); + }, + ), + VerticalDivider( + width: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + ScrollbarButton( + size: scrollbarWeight, + icon: Icons.arrow_right, + onPressed: () { + widget.worksheet.resolve(ScrollByEvent(const Offset(20, 0))); + }, + ), + VerticalDivider( + width: borderWidth, + thickness: borderWidth, + color: Color(0xffd9d9d9)), + SizedBox(width: scrollbarWeight, height: scrollbarWeight), + ], + ); + } + void _rebuild() { _updateVerticalPosition(); _updateHorizontalPosition(); @@ -137,77 +164,29 @@ class _SheetScrollableState extends State { } void _updateVerticalPosition() { - verticalScrollbarPainter.scrollPosition = widget.worksheet.scroll.position.vertical; + verticalScrollbarPainter.scrollPosition = + widget.worksheet.scroll.position.vertical; } void _updateHorizontalPosition() { - horizontalScrollbarPainter.scrollPosition = widget.worksheet.scroll.position.horizontal; + horizontalScrollbarPainter.scrollPosition = + widget.worksheet.scroll.position.horizontal; } void _updateMetrics() { - verticalScrollbarPainter.scrollMetrics = widget.worksheet.scroll.metrics.vertical; - horizontalScrollbarPainter.scrollMetrics = widget.worksheet.scroll.metrics.horizontal; + verticalScrollbarPainter.scrollMetrics = + widget.worksheet.scroll.metrics.vertical; + horizontalScrollbarPainter.scrollMetrics = + widget.worksheet.scroll.metrics.horizontal; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('verticalScrollbarPainter', verticalScrollbarPainter)); - properties.add(DiagnosticsProperty('horizontalScrollbarPainter', horizontalScrollbarPainter)); - } -} - -class SheetScrollbar extends StatefulWidget { - const SheetScrollbar({ - required this.painter, - required this.onScroll, - required this.deltaModifier, - super.key, - }); - - final SheetScrollbarPainter painter; - final ValueChanged onScroll; - final double Function(Offset offset) deltaModifier; - - @override - State createState() => _SheetScrollbarState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('painter', painter)); - properties.add(ObjectFlagProperty>.has('onScroll', onScroll)); - properties.add(ObjectFlagProperty.has('deltaModifier', deltaModifier)); - } -} - -class _SheetScrollbarState extends State { - @override - Widget build(BuildContext context) { - return SizedBox.expand( - child: SheetMouseRegion( - key: UniqueKey(), - cursor: SystemMouseCursors.basic, - onEnter: _onHover, - onExit: _onExit, - onDragUpdate: _onScroll, - child: CustomPaint(painter: widget.painter), - ), - ); - } - - void _onScroll(PointerMoveEvent event) { - double delta = widget.deltaModifier(event.delta); - double updatedDelta = widget.painter.parseDeltaToRealScroll(delta); - widget.onScroll(updatedDelta); - } - - void _onHover() { - widget.painter.hovered = true; - } - - void _onExit() { - widget.painter.hovered = false; + properties.add(DiagnosticsProperty( + 'verticalScrollbarPainter', verticalScrollbarPainter)); + properties.add(DiagnosticsProperty( + 'horizontalScrollbarPainter', horizontalScrollbarPainter)); } } @@ -238,8 +217,10 @@ class _ScrollbarLayout extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xfff8f8f8), border: Border( - left: BorderSide(width: borderWidth, color: const Color(0xffe1e3e1)), - right: BorderSide(width: borderWidth, color: const Color(0xffe1e3e1)), + left: BorderSide( + width: borderWidth, color: const Color(0xffe1e3e1)), + right: BorderSide( + width: borderWidth, color: const Color(0xffe1e3e1)), ), ), child: verticalScrollbar, @@ -252,7 +233,9 @@ class _ScrollbarLayout extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( color: const Color(0xfff8f8f8), - border: Border(top: BorderSide(width: borderWidth, color: const Color(0xffe1e3e1))), + border: Border( + top: BorderSide( + width: borderWidth, color: const Color(0xffe1e3e1))), ), child: horizontalScrollbar, ), @@ -266,200 +249,3 @@ class _ScrollbarLayout extends StatelessWidget { properties.add(DoubleProperty('scrollbarWeight', scrollbarWeight)); } } - -class SheetScrollbarPainter extends ChangeNotifier implements CustomPainter { - SheetScrollbarPainter({ - required SheetAxisDirection axisDirection, - }) : _axisDirection = axisDirection, - _metrics = SheetScrollMetrics.zero(axisDirection), - _position = SheetScrollPosition(); - final SheetAxisDirection _axisDirection; - - late SheetScrollMetrics _metrics; - - set scrollMetrics(SheetScrollMetrics scrollMetrics) { - if (_metrics == scrollMetrics) { - return; - } - _metrics = scrollMetrics; - notifyListeners(); - } - - late SheetScrollPosition _position; - - set scrollPosition(SheetScrollPosition scrollPosition) { - if (_position == scrollPosition) { - return; - } - _position = scrollPosition; - notifyListeners(); - } - - bool _hovered = false; - - set hovered(bool hovered) { - if (_hovered == hovered) { - return; - } - _hovered = hovered; - notifyListeners(); - } - - double _scrollToThumbRatio = 0; - - @override - bool? hitTest(Offset position) { - return false; - } - - @override - void paint(Canvas canvas, Size size) { - canvas.clipRect(Offset.zero & size); - _paintScrollbar(canvas, size); - } - - void _paintScrollbar(Canvas canvas, Size size) { - double thumbMargin = _hovered ? 0 : 2; - double thumbMinSize = 48; - - Size trackSize = size; - - Size thumbSize; - Offset thumbOffset; - - switch (_axisDirection) { - case SheetAxisDirection.vertical: - double trackHeight = trackSize.height; - double availableTrackHeight = trackHeight - (2 * thumbMargin); - - double ratio = _viewportDimension / _contentSize; - double offsetRatio = _scrollOffset / (_contentSize - _viewportDimension); - - double thumbHeight = max(availableTrackHeight * ratio, thumbMinSize); - double thumbPosition = offsetRatio * (availableTrackHeight - thumbHeight) + thumbMargin; - - thumbSize = Size(trackSize.width - (thumbMargin * 2), thumbHeight); - thumbOffset = Offset(thumbMargin, thumbPosition); - - double maxScrollPosition = _contentSize - _viewportDimension; - double maxThumbOffset = trackHeight - thumbHeight; - _scrollToThumbRatio = maxScrollPosition / maxThumbOffset; - case SheetAxisDirection.horizontal: - double trackWidth = trackSize.width; - double availableTrackWidth = trackWidth - (2 * thumbMargin); - - double ratio = _viewportDimension / _contentSize; - double offsetRatio = _scrollOffset / (_contentSize - _viewportDimension); - - double thumbWidth = max(availableTrackWidth * ratio, thumbMinSize); - double thumbPosition = offsetRatio * (availableTrackWidth - thumbWidth) + thumbMargin; - - thumbSize = Size(thumbWidth, trackSize.height - (thumbMargin * 2)); - thumbOffset = Offset(thumbPosition, thumbMargin); - - double maxScrollPosition = _contentSize - _viewportDimension; - double maxThumbOffset = trackWidth - thumbWidth; - _scrollToThumbRatio = maxScrollPosition / maxThumbOffset; - } - - _paintTrack(canvas, trackSize); - _paintThumb(canvas, thumbSize, thumbOffset); - } - - double get _viewportDimension => _metrics.viewportDimension; - - double get _contentSize => _metrics.contentSize; - - double get _scrollOffset => _position.offset; - - double parseDeltaToRealScroll(double delta) { - return delta * _scrollToThumbRatio; - } - - void _paintTrack(Canvas canvas, Size size) { - Paint paint = Paint() - ..color = Colors.white - ..style = PaintingStyle.fill; - - canvas.drawRect(Offset.zero & size, paint); - } - - void _paintThumb(Canvas canvas, Size size, Offset offset) { - Paint paint = Paint() - ..color = _hovered ? const Color(0xffbdc1c6) : const Color(0xffdadce0) - ..style = PaintingStyle.fill; - - canvas.drawRRect(RRect.fromRectAndRadius(offset & size, const Radius.circular(16)), paint); - } - - @override - SemanticsBuilderCallback? get semanticsBuilder => null; - - @override - bool shouldRebuildSemantics(covariant SheetScrollbarPainter oldDelegate) { - return false; - } - - @override - bool shouldRepaint(covariant SheetScrollbarPainter oldDelegate) { - return oldDelegate._metrics != _metrics || oldDelegate._position != _position; - } -} - -class _ScrollbarButton extends StatelessWidget { - const _ScrollbarButton({ - required this.icon, - required this.onPressed, - required this.size, - }); - - final IconData icon; - final VoidCallback onPressed; - final double size; - - @override - Widget build(BuildContext context) { - return WidgetStateBuilder( - onTap: onPressed, - cursor: SystemMouseCursors.basic, - builder: (Set states) { - return Container( - width: size, - height: size, - color: getBackgroundColor(states), - child: Center( - child: Icon(icon, size: 12, color: getIconColor(states)), - ), - ); - }, - ); - } - - Color getBackgroundColor(Set states) { - if (states.contains(WidgetState.pressed)) { - return const Color(0xff919191); - } else if (states.contains(WidgetState.hovered)) { - return const Color(0xffc1c1c1); - } else { - return const Color(0xfff8f8f8); - } - } - - Color getIconColor(Set states) { - if (states.contains(WidgetState.pressed)) { - return Colors.white; - } else if (states.contains(WidgetState.hovered)) { - return const Color(0xff767676); - } else { - return const Color(0xff989898); - } - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('icon', icon)); - properties.add(ObjectFlagProperty.has('onPressed', onPressed)); - properties.add(DoubleProperty('size', size)); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 6947acee..f587bd48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: slang_build_runner: ^4.4.0 flutter: + generate: true uses-material-design: true fonts: diff --git a/test/unit/core/auto_fill_engine_test.dart b/test/unit/core/auto_fill_engine_test.dart index e0870936..6d624b4e 100644 --- a/test/unit/core/auto_fill_engine_test.dart +++ b/test/unit/core/auto_fill_engine_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sheets/core/auto_fill_engine.dart'; +import 'package:sheets/core/pattern_detector.dart'; import 'package:sheets/core/cell_properties.dart'; import 'package:sheets/core/sheet_index.dart'; import 'package:sheets/core/sheet_style.dart'; @@ -10,7 +11,9 @@ import 'package:sheets/core/values/sheet_text_span.dart'; void main() { group('Tests of PatternDetector', () { - test('Should [detect LinearNumericPattern] when [numeric values are provided]', () { + test( + 'Should [detect LinearNumericPattern] when [numeric values are provided]', + () { // Arrange PatternDetector detector = PatternDetector(); List baseCells = [ @@ -31,13 +34,16 @@ void main() { ]; // Act - ValuePattern pattern = detector.detectPattern(baseCells); + ValuePattern pattern = + detector.detectPattern(baseCells); // Assert expect(pattern, isA()); }); - test('Should [detect RepeatValuePattern] when [no specific pattern is matched]', () { + test( + 'Should [detect RepeatValuePattern] when [no specific pattern is matched]', + () { // Arrange PatternDetector detector = PatternDetector(); List baseCells = [ @@ -51,7 +57,8 @@ void main() { ]; // Act - ValuePattern pattern = detector.detectPattern(baseCells); + ValuePattern pattern = + detector.detectPattern(baseCells); // Assert expect(pattern, isA());