diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9ee5504 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,83 @@ +# Publishes the package to pub.dev when a release is created. +# +# Creating a GitHub release from main tags it, and the tag is what starts this: +# `v3.1.0` publishes version 3.1.0. Nothing is published unless the tag is on +# main, matches the version in pubspec.yaml and has its CHANGELOG entry. +# +# One-off setup on pub.dev (package Admin tab > Automated publishing): +# - enable publishing from GitHub Actions for CtrlAltDevelop/ohlcv_chart +# - tag pattern: v{{version}} +# - require the GitHub Actions environment: pub.dev +# and create an environment named `pub.dev` in the repository settings, with +# a required reviewer if every publish should be approved by hand. + +name: Publish + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +permissions: {} + +jobs: + verify: + name: Verify the release + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: The tag is on main + run: | + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME is not on main; release from main." + exit 1 + fi + + - name: The tag matches the pubspec version + run: | + version=$(sed -n 's/^version: *//p' pubspec.yaml | tr -d '\r') + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pubspec version $version." + exit 1 + fi + + - name: The CHANGELOG has an entry for it + run: | + version="${GITHUB_REF_NAME#v}" + if ! grep -q "^## $version\b" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## $version' entry." + exit 1 + fi + + publish: + name: Publish to pub.dev + needs: verify + runs-on: ubuntu-latest + environment: pub.dev + permissions: + contents: read + # Lets pub.dev trust this run without a stored secret. + id-token: write + steps: + - uses: actions/checkout@v7 + + # Sets up the credentials pub.dev issues to this run; the Flutter + # setup below brings the SDK `flutter pub publish` needs. + - uses: dart-lang/setup-dart@v1 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - run: flutter pub get + + - name: Dry run + run: flutter pub publish --dry-run + + - name: Publish + run: flutter pub publish --force diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f1e8d..50858f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +## 3.1.0 - 2026-10-06 + +### Added + +- **Sizing the panes from your code.** `KChartWidget.paneHeights` sets the + indicator panes' heights and `volumeHeight` the volume pane's. Heights are + used as given — past `ChartStyle.maxPaneHeight` if asked — and cut back only + when a pane would squeeze the candles out of the box. Null leaves the heights + to the chart, as before. While a list is given the host owns the heights: a + drag or a controller call is reported through `onPaneHeightsChanged`, and the + pane moves when the list is passed back. +- **Maximizing a pane.** `KChartController.maximizePane`, `maximizeVolume`, + `restorePanes` and `toggleMaximizePane` stretch one pane over the chart, the + candles keeping a strip above it, for a "maximize this indicator" button. The + stretch follows the chart's size and its own indicator through a reorder. + `setPaneHeight`, `resetPaneHeights`, `paneHeights`, `maximizedPane` and + `isVolumeMaximized` complete the set. +- **`KChartWidget.paneSizeMode`** chooses how the chart's parts are sized. + `PaneSizeMode.heights` is the default and the behaviour so far. + `PaneSizeMode.ratios` splits the height by `paneRatios` — the candles, the + volume pane, then each indicator pane, so `[3, 1, 2]` is half, a sixth and a + third — and holds the split as the chart is resized. `PaneSizeMode.custom` + lets the user lay the chart out: `KChartController.editPanes` shows a line + between each two parts to drag between the smallest and largest height each + may take, the layout is kept as proportions, and `onPaneRatiosChanged` + reports it for saving. `finishEditingPanes`, `toggleEditPanes` and + `isEditingPanes` complete the set. Both modes need a bounded height and no + `mBaseHeight`. +- **`ChartStyle.gridColumnMode`** chooses where the grid's vertical lines go. + `GridColumnMode.dateTicks` (the default, and the behaviour so far) rules a line + wherever a candle crosses a time bucket; with only a few candles in view that + can leave the lines bunched together. `GridColumnMode.evenlySpaced` divides the + chart width into `gridColumns` equal bands instead, so the grid spans the whole + chart however sparse the data. The date labels keep their round times. +- **`ChartStyle.showGridRows` and `showGridColumns`** switch the horizontal and + vertical grid lines on and off independently. `hideGrid` still hides both, and + the pane borders stay either way. +- **`ChartStyle.gridDashPattern`** dashes the grid lines with a list of on/off + lengths, e.g. `[4, 3]`. Null is solid; an empty list or a non-positive length + is read as solid. +- The example app has switches for the grid options, a sizing chooser for the + panes, an Edit layout chip, and a chip to maximize each pane and the volume. + +### Changed + +- Dragged pane heights move with their pane when `onReorderPane` reorders the + indicators, instead of staying at their position. +- `ChartStyle.minPaneHeight` is held at 32 or more when a pane is dragged or + maximized around, so a pane can never be made so small that its edge cannot be + grabbed to make it bigger again. +- `KChartHost` has nine new members for the above. Only an app that implements + it itself, such as a test double, needs to add them. + ## 3.0.0 - 2026-09-19 ### Breaking diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f35deda..7652858 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,20 @@ their shape rather than inventing a new one. page too. A new top-level feature gets a new page, linked from both [`README.md`](README.md) and [`doc/README.md`](doc/README.md). +## Releasing + +Maintainers only. Releases are published to pub.dev automatically: + +1. Merge `develop` into `main` with the version bumped in `pubspec.yaml` and + the `## Unreleased` heading in `CHANGELOG.md` renamed to + `## - `. +2. Create a GitHub release from `main` with the tag `v`. + +The tag starts [`publish.yml`](.github/workflows/publish.yml), which checks the +tag is on `main`, matches the pubspec version and has its changelog entry, and +then publishes. It needs automated publishing enabled on pub.dev for this +repository, with the tag pattern `v{{version}}` and the `pub.dev` environment. + ## Reporting a bug or requesting a feature Use the issue templates — they ask for the couple of things that are always diff --git a/README.md b/README.md index 0cf1866..e18c77e 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,7 @@ indicator and drawing tool is included; there is no paid tier. ```yaml dependencies: material_ui: ">=1.0.0 <2.0.0" - ohlcv_chart: ">=3.0.0 <4.0.0" + ohlcv_chart: ">=3.1.0 <4.0.0" ``` Every widget here is built on the `material_ui` package rather than the diff --git a/doc/date-axis.md b/doc/date-axis.md index 78256d0..71e7232 100644 --- a/doc/date-axis.md +++ b/doc/date-axis.md @@ -16,6 +16,35 @@ Like the price axis, the date axis selects round values before placing labels. `ChartStyle.gridColumns` controls label density, in the same way as `gridRows` on the price axis. +By default the grid's vertical lines rule wherever a candle crosses a time +bucket (`GridColumnMode.dateTicks`), so each line lines up with a round time. +With only a handful of candles in view — a sparse intraday window, say — +there may be only one or two such crossings, leaving most of the chart +without a line. Set `ChartStyle.gridColumnMode` to +`GridColumnMode.evenlySpaced` to divide the chart width into `gridColumns` +equal bands instead, so the grid always spans the chart regardless of candle +count — at the cost of the lines no longer landing on round times: + +```dart +ChartStyle( + gridColumnMode: GridColumnMode.evenlySpaced, +); +``` + +The grid can also be trimmed or dashed, each part on its own: + +```dart +ChartStyle( + showGridRows: false, // vertical lines only + showGridColumns: true, + gridDashPattern: [4, 3], // 4px dash, 3px gap; null is solid +); +``` + +`hideGrid` on the widget still hides the whole grid. The date labels keep +their round times in either column mode, so with `evenlySpaced` a line and a +label need not meet. + To control formatting, use `ChartStyle.dateTimeFormat` for a fixed pattern, or `dateFormatter` for full control. `dateFormatter` receives each candle and a flag indicating whether the long form (used by the crosshair) is required: diff --git a/doc/panes.md b/doc/panes.md index 4aa58e7..2fa82ee 100644 --- a/doc/panes.md +++ b/doc/panes.md @@ -29,16 +29,157 @@ KChartWidget( ## Height and order -Pane heights are managed by the chart, constrained between -`ChartStyle.minPaneHeight` and `maxPaneHeight`, and reset when the set of panes -changes. +By default pane heights are managed by the chart: 100 each, or whatever the user +dragged them to, constrained between `ChartStyle.minPaneHeight` and +`maxPaneHeight`, and reset when panes are added or removed. A reorder carries +each height along with its pane. Pane order is owned by your indicator list. The chart reports the move through `onReorderPane`, and your code applies it. +## Sizing panes from your code + +Two ways to set heights from outside — a "maximize this indicator" button is +the usual reason. + +**`KChartController`** takes commands: + +```dart +chart.maximizePane(1); // pane 1 fills the chart, candles shrink to a strip +chart.restorePanes(); // …and back +chart.toggleMaximizePane(1); // one button for both +chart.maximizeVolume(); // the volume pane instead +chart.setPaneHeight(0, 220); // any single pane, any positive height +chart.resetPaneHeights(); // every pane back to the standard height +chart.paneHeights; // what is drawn now +``` + +![Three panes at the standard height](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-default.jpg) +![The RSI pane maximized](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-maximized-rsi.jpg) +![The OBV pane maximized](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-maximized-obv.jpg) + +Only one pane — or the volume pane — is maximized at a time. A maximized pane follows the chart's size and moves with its indicator when you +reorder; dragging any pane lets go of it. It needs the chart to size its own +candle area, so it has no effect when `mBaseHeight` is set. + +**`KChartWidget.paneHeights`** is the same thing as state: + +```dart +KChartWidget( + candles, + ChartColors(), + indicators: indicators, + paneHeights: maximized ? [60, 500] : null, // null: the chart's own layout + volumeHeight: 80, // the volume pane, default 60 + onPaneHeightsChanged: (heights) => setState(() => saved = heights), +); +``` + +- Heights set from code are used as given, not held to `minPaneHeight` and + `maxPaneHeight`, so a pane can be taller than a user could drag it. A height + too big for the box is cut back so the candles keep a strip above it. +- A pane missing from a shorter list, or given a height that is not a positive + number, gets the standard height. +- While a list is given you own the heights, like a controlled text field. A + drag (with `resizablePanes` on) or `setPaneHeight` does not move a pane by + itself: it is reported through `onPaneHeightsChanged`, and the pane moves when + you pass the new list back. Pass `null` to give the heights back to the chart. + Maximizing through the controller still works over a list, and lets go of + whenever a height changes. +- Panes in `paneHeights` are matched by position. When `onReorderPane` moves + one, move its height with it. + `ChartStyle.paneResizeTolerance` and `paneGrabHeight` set the size of the resize and reorder hit areas. +## Sizing modes + +`paneSizeMode` chooses how the heights of the candles, the volume pane and the +indicator panes are worked out: + +| Mode | Heights come from | Who changes them | +|---|---|---| +| `PaneSizeMode.heights` (default) | pixels: 100 per pane, 60 for volume, or `paneHeights` / `volumeHeight` | you, or the user dragging a pane's lower edge with `resizablePanes` | +| `PaneSizeMode.ratios` | proportions in `paneRatios` | you | +| `PaneSizeMode.custom` | the user's own layout | the user, dragging lines in edit mode | + +`ratios` and `custom` need the box to bound the chart's height and `mBaseHeight` +to be left off; otherwise the pixel heights are used. + +### Proportions + +```dart +KChartWidget( + candles, + ChartColors(), + indicators: [RsiIndicator()], + paneSizeMode: PaneSizeMode.ratios, + paneRatios: [3, 1, 2], // 6 units: candles 3, volume 1, RSI 2 +); +``` + +![Candles 3, volume 1, MACD 2 and RSI 2](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-ratios.jpg) + +- The list runs top to bottom: the candles, the volume pane, then each indicator + pane. The height left after the legend rows above the candles is split by + those units, and the split holds as the box is resized. +- With `volHidden` the volume's number is left out, so the list is one shorter. +- A part missing from a shorter list, or given a number that is not above zero, + counts as 1. Without `paneRatios` the mode has nothing to split by, and the + pixel heights are used. +- You own the proportions, as with `paneHeights`. With `resizablePanes` on, a + drag moves room between the two parts either side of the edge — the last pane + takes from the part above — and `onPaneRatiosChanged` reports the new list, + keeping its total. Pass it back for the drag to take effect: + +```dart +paneRatios: ratios, +resizablePanes: true, +onPaneRatiosChanged: (next) => setState(() => ratios = next), +``` + +### Custom layout + +The user lays the chart out themselves. Put it in `custom` mode and turn editing +on from the controller; a line appears between each two parts, the candles and +volume included, to drag up or down: + +```dart +final chart = KChartController(); + +KChartWidget( + candles, + ChartColors(), + controller: chart, + indicators: [RsiIndicator()], + paneSizeMode: PaneSizeMode.custom, + onPaneRatiosChanged: (ratios) => saved = ratios, // optional +); + +chart.editPanes(); // show the lines +chart.finishEditingPanes(); // put them away, keeping the layout +chart.toggleEditPanes(); // one button for both +chart.resetPaneHeights(); // back to the standard layout +``` + +![Edit mode: a line between each two parts](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-custom-edit.jpg) +![The line between MACD and RSI dragged up](https://raw.githubusercontent.com/CtrlAltDevelop/ohlcv_chart/main/screenshots/panes-custom-dragged.jpg) + +- A line moves room between the two parts either side of it, and stops where + either reaches its limit: `ChartStyle.minPaneHeight` and `maxPaneHeight` for + the volume and the panes, a 60 px floor for the candles, which have no + ceiling because they are what is left. +- However small `minPaneHeight` is set, a pane or the volume pane is held at 32 + px or more when dragged or maximized around, so there is always an edge or + line left to pull it back out. +- The chart owns the layout and keeps it as proportions, so it holds through a + resize. It starts from `paneRatios` when you give some, or from the standard + heights, and passing a different `paneRatios` later starts it over from that. +- `onPaneRatiosChanged` reports every change in `paneRatios` order, which is + what to save and pass back as `paneRatios` next time. +- The lines are not shown while a pane is maximized, and `resizablePanes` has no + effect in this mode; the lines replace it. + --- [← All docs](README.md) · [Package README](../README.md) diff --git a/example/lib/src/chart_page.dart b/example/lib/src/chart_page.dart index 9c1c776..bcd2899 100644 --- a/example/lib/src/chart_page.dart +++ b/example/lib/src/chart_page.dart @@ -128,6 +128,10 @@ class _Chart extends StatelessWidget { crosshairOnHover: state.crosshairOnHover, timeZoneOffset: state.timeZoneOffset, resizablePanes: state.resizablePanes, + paneSizeMode: state.paneMode, + paneRatios: state.paneRatios, + onPaneRatiosChanged: (ratios) => + state.update(() => state.paneRatios = ratios), reorderablePanes: state.reorderablePanes, onReorderPane: state.reorderPane, selectAfterDrawing: !state.keepToolArmed, diff --git a/example/lib/src/controls.dart b/example/lib/src/controls.dart index 94918fb..e5dd2e7 100644 --- a/example/lib/src/controls.dart +++ b/example/lib/src/controls.dart @@ -412,6 +412,17 @@ class Controls extends StatelessWidget { value: state.reorderablePanes, onChanged: (v) => state.update(() => state.reorderablePanes = v), ), + _Choice( + label: 'Sizing', + value: state.paneMode, + options: const { + PaneSizeMode.heights: 'pixels', + PaneSizeMode.ratios: '3 : 1 : 2', + PaneSizeMode.custom: 'custom', + }, + onChanged: state.setPaneMode, + ), + _PaneMaximizer(state: state), ], ), _Section( @@ -458,6 +469,30 @@ class Controls extends StatelessWidget { value: state.fitContent, onChanged: (v) => state.update(() => state.fitContent = v), ), + _Toggle( + label: 'Even grid columns', + subtitle: 'Grid lines spread over the chart width, not candles', + value: state.evenGridColumns, + onChanged: (v) => state.update(() => state.evenGridColumns = v), + ), + _Toggle( + label: 'Grid rows', + subtitle: 'The horizontal price lines', + value: state.showGridRows, + onChanged: (v) => state.update(() => state.showGridRows = v), + ), + _Toggle( + label: 'Grid columns', + subtitle: 'The vertical time lines', + value: state.showGridColumns, + onChanged: (v) => state.update(() => state.showGridColumns = v), + ), + _Toggle( + label: 'Dashed grid', + subtitle: 'gridDashPattern of 4 on, 3 off', + value: state.dashedGrid, + onChanged: (v) => state.update(() => state.dashedGrid = v), + ), _Toggle( label: 'Prices as currency', subtitle: r'priceFormatter writes them as $1234.50', @@ -933,3 +968,57 @@ class _Choice extends StatelessWidget { ); } } + +/// One button per indicator pane that stretches it over the chart, through +/// `KChartController.toggleMaximizePane`, and one that puts the panes back. +class _PaneMaximizer extends StatelessWidget { + const _PaneMaximizer({required this.state}); + + final DemoState state; + + @override + Widget build(BuildContext context) { + final panes = [ + for (final indicator in state.indicators) + if (indicator.placement != IndicatorPlacement.overlay) indicator, + ]; + return ListenableBuilder( + listenable: state.chart, + builder: (context, _) { + final maximized = state.chart.maximizedPane; + return Wrap( + spacing: 8, + children: [ + if (state.paneMode == PaneSizeMode.custom) + FilterChip( + label: const Text('Edit layout'), + selected: state.chart.isEditingPanes, + onSelected: (_) => state.chart.toggleEditPanes(), + ), + if (!state.volHidden) + FilterChip( + label: const Text('Maximize volume'), + selected: state.chart.isVolumeMaximized, + onSelected: (on) => on + ? state.chart.maximizeVolume() + : state.chart.restorePanes(), + ), + for (var i = 0; i < panes.length; i++) + FilterChip( + label: Text('Maximize ${panes[i].label}'), + selected: maximized == i, + onSelected: (_) => state.chart.toggleMaximizePane(i), + ), + ActionChip( + label: const Text('Reset heights'), + onPressed: () { + state.chart.restorePanes(); + state.chart.resetPaneHeights(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/example/lib/src/demo_state.dart b/example/lib/src/demo_state.dart index 056ba14..71849f7 100644 --- a/example/lib/src/demo_state.dart +++ b/example/lib/src/demo_state.dart @@ -153,6 +153,25 @@ class DemoState extends ChangeNotifier { /// Whether an indicator pane can be dragged taller or shorter. bool resizablePanes = true; + /// How the panes are sized: in pixels, by proportion, or laid out by dragging. + PaneSizeMode paneMode = PaneSizeMode.heights; + + /// Proportions of the chart's height — candles, volume, then each pane — for + /// [PaneSizeMode.ratios], and where the custom layout starts from. + List? paneRatios; + + /// Switches how the panes are sized. Proportions start as 3 for the candles, + /// 1 for the volume and 2 for each indicator pane. + void setPaneMode(PaneSizeMode mode) => update(() { + paneMode = mode; + final panes = indicators + .where((i) => i.placement != IndicatorPlacement.overlay) + .length; + paneRatios = mode == PaneSizeMode.ratios + ? [3, if (!volHidden) 1, for (var i = 0; i < panes; i++) 2] + : null; + }); + /// Whether an indicator pane can be dragged up or down the stack. bool reorderablePanes = true; @@ -272,6 +291,17 @@ class DemoState extends ChangeNotifier { /// the left at the fixed spacing. bool fitContent = false; + /// Spreads the grid's vertical lines evenly across the chart width rather + /// than ruling them at candle timestamps. + bool evenGridColumns = false; + + /// Whether the grid's horizontal and vertical lines are drawn. + bool showGridRows = true; + bool showGridColumns = true; + + /// Dashes the grid lines rather than drawing them solid. + bool dashedGrid = false; + /// Writes the prices as currency rather than as plain decimals. bool currencyPrices = false; @@ -323,6 +353,12 @@ class DemoState extends ChangeNotifier { showSessionDividers: sessionDividers, priceAxisWidth: fixedPriceAxis ? 56.0 : 0.0, fitContent: fitContent, + gridColumnMode: evenGridColumns + ? GridColumnMode.evenlySpaced + : GridColumnMode.dateTicks, + showGridRows: showGridRows, + showGridColumns: showGridColumns, + gridDashPattern: dashedGrid ? const [4, 3] : null, ); } diff --git a/lib/src/chart_controller.dart b/lib/src/chart_controller.dart index b99c73c..46d7b87 100644 --- a/lib/src/chart_controller.dart +++ b/lib/src/chart_controller.dart @@ -63,6 +63,35 @@ abstract interface class KChartHost { /// Shifts the price axis to [pan], within the range the chart allows. void setChartPricePan(double pan); + + /// The height each indicator pane is drawn at, in the order they are stacked. + List get chartPaneHeights; + + /// Sizes pane [index] to [height]; false when there is no such pane or the + /// height is not a positive number. + bool setChartPaneHeight(int index, double height); + + /// Hands every pane back to the standard height. + void resetChartPaneHeights(); + + /// Which pane is stretched to fill the chart, or null when none is. + int? get chartMaximizedPane; + + /// Stretches pane [index] over the chart, or lets go of the stretch for null. + bool maximizeChartPane(int? index); + + /// Whether the volume pane is stretched over the chart. + bool get chartVolumeMaximized; + + /// Stretches the volume pane over the chart; false when it is hidden. + bool maximizeChartVolume(); + + /// Whether the lines between the parts are showing, to be dragged. + bool get chartEditingPanes; + + /// Shows or hides those lines; false unless the chart is in + /// `PaneSizeMode.custom`. + bool setChartEditingPanes(bool editing); } /// Drives a chart from outside it: where it is scrolled, how far it is zoomed, @@ -239,6 +268,94 @@ class KChartController extends ChangeNotifier { /// Shifts the price axis to [value], within the range the chart allows. void setPricePan(double value) => _host?.setChartPricePan(value); + + // ── Pane heights ───────────────────────────────────────────────────────── + + /// The height each indicator pane is drawn at, in the order they are stacked. + /// + /// Empty while nothing is attached. A pane that is [maximizedPane] reads as + /// the height it is currently stretched to. + List get paneHeights => _host?.chartPaneHeights ?? const []; + + /// Sizes the indicator pane at [index] to [height]. + /// + /// With `KChartWidget.paneHeights` given the host owns the heights, so this + /// is reported through `onPaneHeightsChanged` for the host to pass back + /// rather than moving the pane itself. + /// + /// Unlike a drag, this is not held between `ChartStyle.minPaneHeight` and + /// `ChartStyle.maxPaneHeight`: any positive height is used as given, so a host + /// can grow a pane past what the user could drag it to. Panes are told apart + /// by position, and the heights are thrown away when panes are added or + /// removed. Reports whether there was such a pane to size. + bool setPaneHeight(int index, double height) => + _host?.setChartPaneHeight(index, height) ?? false; + + /// Hands every pane back to the standard height, undoing anything dragged, + /// set from here, or given through `KChartWidget.paneHeights`. + void resetPaneHeights() => _host?.resetChartPaneHeights(); + + /// Which pane is stretched to fill the chart, or null when none is. + int? get maximizedPane => _host?.chartMaximizedPane; + + /// Makes the pane at [index] take all the height it can, squashing the + /// candles to a strip above it and the other panes to their smallest. + /// + /// The stretch follows the chart's size and the pane itself, so it survives a + /// resize or the panes being reordered. [restorePanes] — or dragging any pane + /// — undoes it, handing back the heights the panes had before. Reports + /// whether there was such a pane. + /// + /// Needs the chart to size its own candle area, so it has no effect on one + /// given a fixed `mBaseHeight`. + bool maximizePane(int index) => _host?.maximizeChartPane(index) ?? false; + + /// Whether the volume pane is stretched over the chart. + bool get isVolumeMaximized => _host?.chartVolumeMaximized ?? false; + + /// Makes the volume pane take all the height it can, in the way + /// [maximizePane] does for an indicator pane. Reports false when the volume + /// pane is hidden. Only one pane, or the volume, is maximized at a time. + bool maximizeVolume() => _host?.maximizeChartVolume() ?? false; + + /// Lets go of [maximizePane] or [maximizeVolume], putting the panes back as + /// they were. + void restorePanes() => _host?.maximizeChartPane(null); + + /// Whether the lines between the parts are showing, to be dragged. + bool get isEditingPanes => _host?.chartEditingPanes ?? false; + + /// Shows a line between each two parts of the chart — the candles, the volume + /// and each pane — for the user to drag up or down, between the smallest and + /// largest height each may take. + /// + /// Only has an effect in `PaneSizeMode.custom`, and reports whether it did. + /// The lines are not shown while a pane is maximized. + bool editPanes() => _host?.setChartEditingPanes(true) ?? false; + + /// Hides the lines, keeping the layout they were dragged to. + void finishEditingPanes() => _host?.setChartEditingPanes(false); + + /// Shows the lines, or hides them if they are showing: the shape an "edit + /// layout" button wants. + bool toggleEditPanes() { + if (isEditingPanes) { + finishEditingPanes(); + return true; + } + return editPanes(); + } + + /// Maximizes the pane at [index], or restores the panes if it already is. + /// + /// The shape a "maximize this indicator" button wants. + bool toggleMaximizePane(int index) { + if (maximizedPane == index) { + restorePanes(); + return true; + } + return maximizePane(index); + } } /// The candles in [candles] that cover [from] to [to], as an index pair. diff --git a/lib/src/chart_style.dart b/lib/src/chart_style.dart index 2aedd7b..cb59317 100644 --- a/lib/src/chart_style.dart +++ b/lib/src/chart_style.dart @@ -436,6 +436,23 @@ class ChartColors { } } +/// How the grid's vertical lines are placed. +enum GridColumnMode { + /// Rules a line wherever a candle crosses a time-bucket boundary — see + /// [ChartStyle.gridColumns]. Reads well once there are enough candles in + /// view to cross several boundaries, since each line then lines up with a + /// round time. With only a handful of candles on screen — a sparse + /// intraday window, say — there may be only one or two such crossings, so + /// the lines cluster together instead of spanning the chart. + dateTicks, + + /// Divides the chart width into [ChartStyle.gridColumns] equal bands, + /// ignoring candle timestamps entirely. The lines always span the full + /// chart, regardless of how many candles are in view, but they no longer + /// line up with round times the way [dateTicks] does. + evenlySpaced, +} + /// Geometry of the candlestick chart: paddings, stroke widths, gaps and /// text sizes. class ChartStyle { @@ -461,6 +478,10 @@ class ChartStyle { this.nowPriceDashed = true, this.gridRows = 8, this.gridColumns = 4, + this.gridColumnMode = GridColumnMode.dateTicks, + this.showGridRows = true, + this.showGridColumns = true, + this.gridDashPattern, this.gridStrokeWidth = 0.5, this.separatorWidth = 1.0, this.hollowUpCandles = false, @@ -553,6 +574,28 @@ class ChartStyle { /// vertical line at each one that survives. final int gridColumns; + /// Whether the grid's vertical lines follow candle timestamps or are + /// spread evenly across the chart width. Defaults to + /// [GridColumnMode.dateTicks], matching the chart's previous behavior. + final GridColumnMode gridColumnMode; + + /// Whether the grid's horizontal (price) lines are drawn. + /// + /// Independent of [showGridColumns]; `hideGrid` on the widget still hides + /// the whole grid. The pane borders and separators are not part of the grid + /// and stay. + final bool showGridRows; + + /// Whether the grid's vertical (time) lines are drawn. See [gridColumnMode] + /// for where they go. + final bool showGridColumns; + + /// On/off lengths, in logical pixels, that dash the grid's lines — `[4, 3]` + /// is a 4px dash followed by a 3px gap. Null (the default) draws solid + /// lines. An empty list, or one with a non-positive or non-finite length, + /// is treated as solid. + final List? gridDashPattern; + /// Stroke width of the grid's hairlines. final double gridStrokeWidth; @@ -602,7 +645,10 @@ class ChartStyle { /// sized to. Every other bar is drawn relative to that one. final double profileWidth; - /// Shortest an indicator pane may be dragged. + /// Shortest an indicator pane, or the volume pane, may be dragged. + /// + /// Held at 32 or more whatever is set here, so a pane can never be dragged so + /// small that its edge cannot be grabbed to make it bigger again. final double minPaneHeight; /// Tallest an indicator pane may be dragged. @@ -684,6 +730,10 @@ class ChartStyle { bool? nowPriceDashed, int? gridRows, int? gridColumns, + GridColumnMode? gridColumnMode, + bool? showGridRows, + bool? showGridColumns, + List? gridDashPattern, double? gridStrokeWidth, double? separatorWidth, bool? hollowUpCandles, @@ -730,6 +780,10 @@ class ChartStyle { nowPriceDashed: nowPriceDashed ?? this.nowPriceDashed, gridRows: gridRows ?? this.gridRows, gridColumns: gridColumns ?? this.gridColumns, + gridColumnMode: gridColumnMode ?? this.gridColumnMode, + showGridRows: showGridRows ?? this.showGridRows, + showGridColumns: showGridColumns ?? this.showGridColumns, + gridDashPattern: gridDashPattern ?? this.gridDashPattern, gridStrokeWidth: gridStrokeWidth ?? this.gridStrokeWidth, separatorWidth: separatorWidth ?? this.separatorWidth, hollowUpCandles: hollowUpCandles ?? this.hollowUpCandles, diff --git a/lib/src/export.dart b/lib/src/export.dart index b00ca1e..c190539 100644 --- a/lib/src/export.dart +++ b/lib/src/export.dart @@ -7,6 +7,7 @@ export './comparison.dart'; export './chart_style.dart'; export './chart_translations.dart'; export './chart_type.dart'; +export './pane_size_mode.dart'; export './chart_workspace.dart'; export './depth_chart.dart'; export './depth_ladder.dart'; diff --git a/lib/src/k_chart_widget.dart b/lib/src/k_chart_widget.dart index c3214ac..983c44a 100644 --- a/lib/src/k_chart_widget.dart +++ b/lib/src/k_chart_widget.dart @@ -18,6 +18,7 @@ import 'trading.dart'; import 'chart_style.dart'; import 'chart_translations.dart'; import 'chart_type.dart'; +import 'pane_size_mode.dart'; import 'components/popup_info_view.dart'; import 'drawing/drawing_controller.dart'; import 'drawing/drawing_coordinates.dart'; @@ -285,6 +286,12 @@ class KChartWidget extends StatefulWidget { this.resizablePanes = false, this.reorderablePanes = false, this.onReorderPane, + this.paneHeights, + this.volumeHeight, + this.onPaneHeightsChanged, + this.paneSizeMode = PaneSizeMode.heights, + this.paneRatios, + this.onPaneRatiosChanged, this.onAddTrendLine, this.onAddHorizontalLine, this.onAddVerticalLine, @@ -692,6 +699,95 @@ class KChartWidget extends StatefulWidget { /// ``` final void Function(int from, int to)? onReorderPane; + /// Heights for the indicator panes, in the order they are stacked. + /// + /// Null — the default — leaves the heights to the chart: the standard height, + /// or whatever has been dragged when [resizablePanes] is on. A list sizes the + /// panes from outside, which is how a host makes one pane (nearly) fill the + /// chart and puts the layout back afterwards by passing null again. + /// + /// Heights are used as given, not held between `ChartStyle.minPaneHeight` and + /// `ChartStyle.maxPaneHeight` as a drag is, so a pane can be made taller than + /// a user could drag it. A pane missing from a shorter list, or given a + /// height that is not a positive number, gets the standard height. When the + /// chart sizes its own candle area, a height too big for the box is cut back + /// so the candles keep a strip above it. + /// + /// While a list is given the host owns the heights, like a controlled text + /// field: a drag (with [resizablePanes] on), or a call to + /// `KChartController.setPaneHeight`, does not move a pane by itself. It is + /// reported through [onPaneHeightsChanged] instead, and the pane moves when + /// the host passes the new list back. Panes are told apart by position, so + /// when [onReorderPane] moves one, move its height with it: + /// + /// ```dart + /// onReorderPane: (from, to) => setState(() { + /// indicators.insert(to, indicators.removeAt(from)); + /// heights.insert(to, heights.removeAt(from)); + /// }), + /// ``` + /// + /// To make a pane fill the chart without working out the heights yourself, + /// use `KChartController.maximizePane`. + final List? paneHeights; + + /// Height of the volume pane, in place of the standard 60. + /// + /// Null leaves it to the chart. It has no effect while [volHidden] is on, and + /// anything not a positive number is ignored. + final double? volumeHeight; + + /// Called with every pane's height when the user drags one, or the heights + /// are changed through `KChartController`. + /// + /// With [paneHeights] given this is how the host hears what was asked for, to + /// pass back; without it the chart has already moved the pane. Not called for + /// a change made through [paneHeights] itself. + final ValueChanged>? onPaneHeightsChanged; + + /// How the heights of the candles, the volume pane and the indicator panes + /// are worked out: in pixels ([PaneSizeMode.heights], the default), by + /// proportion ([PaneSizeMode.ratios]), or laid out by the user dragging the + /// lines between them ([PaneSizeMode.custom]). + /// + /// The proportions and the editing only apply when the box bounds the chart's + /// height and `mBaseHeight` is left off; otherwise the pixel heights are used. + final PaneSizeMode paneSizeMode; + + /// Proportions of the chart's height, top to bottom: the candles, then the + /// volume pane (left out of the list when [volHidden]), then each indicator + /// pane. + /// + /// `[3, 1, 2]` over candles, volume and one indicator pane makes six units: + /// half the height for the candles, a sixth for the volume and a third for + /// the indicator. The proportions hold as the chart is resized, which is + /// what pixel heights cannot do. The room is what is left once the legend + /// rows above the candles have taken theirs. + /// + /// A part missing from a shorter list, or given a number that is not above + /// zero, counts as 1. + /// + /// Used by [PaneSizeMode.ratios], where the host owns the proportions: a drag + /// with [resizablePanes] on moves the edge between two neighbours, and is + /// reported through [onPaneRatiosChanged] for the host to pass back. In + /// [PaneSizeMode.custom] it is where the user starts from, and passing a + /// different list puts the layout back to that. A maximized pane (see + /// `KChartController.maximizePane`) wins over either. + final List? paneRatios; + + /// Called with the new proportions when the user drags a pane's edge or a + /// line in [PaneSizeMode.custom], in the order [paneRatios] uses and keeping + /// their total, so a drag only moves room between the two parts either side. + /// + /// In [PaneSizeMode.custom] the chart has already moved; this is for saving + /// the layout and passing it back as [paneRatios] later. + /// + /// ```dart + /// paneRatios: ratios, + /// onPaneRatiosChanged: (next) => setState(() => ratios = next), + /// ``` + final ValueChanged>? onPaneRatiosChanged; + /// Shifted onto every candle's time before it is shown. /// /// Candle times are read as they come — usually UTC — and this is what puts @@ -1099,12 +1195,38 @@ class _KChartWidgetState extends State late Offset _toolbarOffset; - /// The height of each indicator pane, once the user has dragged one. + /// The height of each indicator pane, once one has been sized. /// - /// Empty until then, and thrown away whenever the panes change, so a new - /// indicator never inherits a height meant for a different one. + /// Dragged by the user, set through the controller, or given by + /// `KChartWidget.paneHeights`. Thrown away whenever panes are added or + /// removed, so a new indicator never inherits a height meant for a different + /// one; a reorder carries each height along with its pane. List _paneHeights = const []; + /// The indicator behind each of [_paneHeights], to follow a reorder. + List _paneOwners = const []; + + /// Whether the heights came from the host rather than the chart, so they are + /// cut back to fit the box instead of being trusted to. + bool _hostSizedPanes = false; + + /// The indicator whose pane is stretched over the chart, if any. + Indicator? _maximizedOwner; + + /// Whether the volume pane is stretched over the chart. + bool _volumeMaximized = false; + + /// The proportions the user has laid the chart out to in + /// [PaneSizeMode.custom], or empty until they are first worked out. + List _customRatios = const []; + + /// Whether the lines between the parts are showing, to be dragged. + bool _editingPanes = false; + + /// Set when `KChartWidget.paneHeights` changed and is yet to be taken up, + /// which waits for the build because the panes it applies to may change too. + bool _hostPaneHeightsStale = false; + /// True while a drag that began on the price labels is stretching the axis. bool _scalingPrice = false; @@ -1655,6 +1777,16 @@ class _KChartWidgetState extends State widget.replay?.addListener(_onReplayChanged); } if (!identical(oldWidget.candles, widget.candles)) _resolveIndicators(); + if (!listEquals(oldWidget.paneHeights, widget.paneHeights)) { + _hostPaneHeightsStale = true; + } + if (oldWidget.paneSizeMode != widget.paneSizeMode || + !listEquals(oldWidget.paneRatios, widget.paneRatios)) { + // A new mode, or new proportions from the host, start the user's layout + // over from there. + _customRatios = const []; + } + if (widget.paneSizeMode != PaneSizeMode.custom) _editingPanes = false; if (oldWidget.lockPriceScale && !widget.lockPriceScale) { // Unlocked, the axis goes back to fitting the window. _lockedPriceRange = null; @@ -1781,6 +1913,7 @@ class _KChartWidgetState extends State paneCount: _resolved.panes.length, legendRowCount: _legendRowCount, paneHeights: _effectivePaneHeights, + volHeight: _volumeHeightOverride, ), ); } @@ -1793,21 +1926,234 @@ class _KChartWidgetState extends State ChartType get _chartType => widget.chartType ?? (widget.isLine ? ChartType.area : ChartType.candles); - /// How many legend rows sit above the candles: one per indicator group, plus - /// one for the OHLC legend when it is shown. + /// The parts of the chart sized by proportion, or null where that does not + /// apply: pixel heights, a fixed candle height, or a box that does not bound + /// the height. + /// + /// In [PaneSizeMode.custom] the proportions are the user's, worked out from + /// the standard layout until they first change them. + ({List ratios, double unit})? get _ratioSplit { + final mode = widget.paneSizeMode; + if (mode == PaneSizeMode.heights || + widget.mBaseHeight != null || + !mHeight.isFinite || + mHeight <= 0) { + return null; + } + final parts = 1 + (widget.volHidden ? 0 : 1) + _resolved.panes.length; + final room = mHeight - BaseDimension.legendRowHeight * _legendRowCount; + if (room <= 0) return null; + + List filled(List given) => [ + for (var i = 0; i < parts; i++) + i < given.length && _usableHeight(given[i]) ? given[i] : 1.0, + ]; + + final List ratios; + if (mode == PaneSizeMode.ratios) { + final given = widget.paneRatios; + if (given == null) return null; + ratios = filled(given); + } else { + if (_customRatios.length != parts) { + _customRatios = _startingRatios(parts, room, filled); + } + ratios = _customRatios; + } + return (ratios: ratios, unit: room / ratios.fold(0.0, (a, b) => a + b)); + } + + /// Where the user's layout starts: the host's proportions when it gave some, + /// or the standard heights, with the candles taking what they leave. + List _startingRatios( + int parts, + double room, + List Function(List) filled, + ) { + final given = widget.paneRatios; + if (given != null) return filled(given); + + final volume = widget.volHidden + ? 0.0 + : (widget.volumeHeight != null && _usableHeight(widget.volumeHeight!) + ? widget.volumeHeight! + : BaseDimension.volumeHeight); + final heights = widget.paneHeights; + final panes = [ + for (var i = 0; i < _resolved.panes.length; i++) + heights != null && i < heights.length && _usableHeight(heights[i]) + ? heights[i] + : BaseDimension.secondaryPaneHeight, + ]; + final main = math.max( + BaseChartPainter.minMainHeight, + room - volume - panes.fold(0.0, (a, b) => a + b), + ); + return [main, if (!widget.volHidden) volume, ...panes]; + } + + /// The volume pane's height when the host gave a usable one. + double? get _volumeHeightOverride { + if (_volumeMaximized && + !widget.volHidden && + widget.mBaseHeight == null && + mHeight.isFinite && + mHeight > 0) { + // Everything else at its smallest, the volume pane gets what is left. + final small = _minPaneHeight; + return math.max( + small, + mHeight - + BaseChartPainter.minMainHeight - + BaseDimension.legendRowHeight * _legendRowCount - + small * _paneOwners.length, + ); + } + final split = _ratioSplit; + if (split != null && !widget.volHidden) { + return split.ratios[1] * split.unit; + } + final height = widget.volumeHeight; + return height != null && height.isFinite && height > 0 ? height : null; + } + + /// The least a pane or the volume pane may be dragged to, or maximized + /// around: `ChartStyle.minPaneHeight`, but never so little that the edge or + /// line left to drag it back out cannot be reached. The drag zones either + /// side of a part are 10 px each, so 32 leaves a gap between them. + double get _minPaneHeight => + math.max(widget.chartStyle.minPaneHeight, _smallestGrabbablePane); + + /// Shortest a part may be and still have its edges grabbed one at a time. + static const double _smallestGrabbablePane = 32; + + /// A height worth drawing: finite and above zero. + static bool _usableHeight(double height) => height.isFinite && height > 0; + /// The pane heights to lay out with, defaulting each pane to the standard - /// height until it has been dragged. + /// height until it has been sized. List get _effectivePaneHeights { - final count = _resolved.panes.length; - if (_paneHeights.length != count) { + final owners = [for (final pane in _resolved.panes) pane.indicator]; + final restructured = _paneHeights.length != owners.length; + + if (restructured) { + // Panes came or went: no height is safe to keep. + _paneHeights = List.filled( + owners.length, + BaseDimension.secondaryPaneHeight, + ); + _hostSizedPanes = false; + _clearMaximized(); + } else if (!listEquals(_paneOwners, owners)) { + // The same number of panes in another order: each height goes with its + // pane, and a pane never seen before starts at the standard height. + final before = _paneHeights; + _paneHeights = [ + for (final owner in owners) + _paneOwners.contains(owner) + ? before[_paneOwners.indexOf(owner)] + : BaseDimension.secondaryPaneHeight, + ]; + } + _paneOwners = owners; + + final given = widget.paneHeights; + if (given != null) { + // The host owns the heights: whatever it passes is what is drawn. + _paneHeights = [ + for (var i = 0; i < owners.length; i++) + i < given.length && _usableHeight(given[i]) + ? given[i] + : BaseDimension.secondaryPaneHeight, + ]; + _hostSizedPanes = true; + if (restructured || _hostPaneHeightsStale) _clearMaximized(); + } else if (_hostPaneHeightsStale) { + // The host let go: back to the chart's own layout. _paneHeights = List.filled( - count, + owners.length, BaseDimension.secondaryPaneHeight, ); + _hostSizedPanes = false; + _clearMaximized(); + } + _hostPaneHeightsStale = false; + + if (_volumeMaximized && !widget.volHidden) { + final small = _minPaneHeight; + return List.filled(owners.length, small); + } + final maximized = _maximizedPaneIndex; + if (maximized != null) return _heightsWithMaximized(maximized); + final split = _ratioSplit; + if (split != null) { + final first = widget.volHidden ? 1 : 2; + return [ + for (var i = 0; i < owners.length; i++) + split.ratios[first + i] * split.unit, + ]; + } + return _hostSizedPanes ? _cutBackToFit(_paneHeights) : _paneHeights; + } + + /// Whether the host owns the pane heights, by having passed a list. + bool get _heightsLocked => widget.paneHeights != null; + + /// Lets go of any pane, or the volume pane, stretched over the chart. + void _clearMaximized() { + _maximizedOwner = null; + _volumeMaximized = false; + } + + /// Where the maximized pane sits now, or null when none is. + int? get _maximizedPaneIndex { + final owner = _maximizedOwner; + if (owner == null) return null; + final at = _paneOwners.indexOf(owner); + return at == -1 ? null : at; + } + + /// Height the box has left for panes once the candles keep their strip, or + /// null when the box does not bound its height or the caller fixed the + /// candle height. + double? get _paneRoom { + if (widget.mBaseHeight != null || !mHeight.isFinite || mHeight <= 0) { + return null; } - return _paneHeights; + return mHeight - + BaseChartPainter.minMainHeight - + (widget.volHidden + ? 0 + : _volumeHeightOverride ?? BaseDimension.volumeHeight) - + BaseDimension.legendRowHeight * _legendRowCount; + } + + /// [_paneHeights] with the maximized pane given everything the others leave, + /// and the others at their smallest. + List _heightsWithMaximized(int index) { + final small = _minPaneHeight; + final room = _paneRoom; + return [ + for (var i = 0; i < _paneHeights.length; i++) + i != index + ? small + : room == null + ? _paneHeights[i] + : math.max(small, room - small * (_paneHeights.length - 1)), + ]; + } + + /// Host-given [heights], with any single pane too tall for the box cut back + /// so the candles are not squeezed out. + List _cutBackToFit(List heights) { + final room = _paneRoom; + if (room == null) return heights; + final ceiling = math.max(room, _minPaneHeight); + return [for (final height in heights) math.min(height, ceiling)]; } + /// How many legend rows sit above the candles: one per indicator group, plus + /// one for the OHLC legend when it is shown. int get _legendRowCount => _resolved.legendRowCount + (widget.showOhlcLegend ? 1 : 0) + @@ -2119,6 +2465,7 @@ class _KChartWidgetState extends State paneCount: _resolved.panes.length, legendRowCount: _legendRowCount, paneHeights: _effectivePaneHeights, + volHeight: _volumeHeightOverride, ); // Taken from the axis as it stands, which is last frame's fit: this @@ -2551,6 +2898,7 @@ class _KChartWidgetState extends State (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android))) _buildScaleX(), + ..._buildPaneDividers(baseDimension), if (widget.showScrollToNowButton && !isChartAtRightEdge) _buildScrollToNowButton(), ], @@ -2565,7 +2913,9 @@ class _KChartWidgetState extends State /// Which indicator pane's lower edge is within reach of [pos], if any. int? _paneEdgeAt(Offset pos) { - if (!widget.resizablePanes) return null; + if (!widget.resizablePanes || widget.paneSizeMode == PaneSizeMode.custom) { + return null; + } final tolerance = widget.chartStyle.paneResizeTolerance; for (var i = 0; i < painter.mSecondaryRectList.length; i++) { final rect = painter.mSecondaryRectList[i].mRect; @@ -2594,12 +2944,169 @@ class _KChartWidgetState extends State final heights = [..._effectivePaneHeights]; if (index < 0 || index >= heights.length) return; + final split = _ratioSplit; + if (split != null && _maximizedPaneIndex == null && !_volumeMaximized) { + _resizeByRatio(split, index, delta); + return; + } + + // A pane the host made taller than the usual ceiling keeps its height + // under the finger rather than snapping down to it. heights[index] = (heights[index] + delta).clamp( - widget.chartStyle.minPaneHeight, - widget.chartStyle.maxPaneHeight, + _minPaneHeight, + math.max(widget.chartStyle.maxPaneHeight, heights[index]), + ); + // A host that owns the heights hears about the drag and passes it back. + if (!_heightsLocked) _paneHeights = heights; + _clearMaximized(); + notifyChanged(); + widget.controller?.hostChanged(); + widget.onPaneHeightsChanged?.call(List.unmodifiable(heights)); + } + + /// Smallest height part [part] may take in [PaneSizeMode.custom]: the candles + /// have their own floor, the volume and the panes share one. + double _smallestPart(int part) => + part == 0 ? BaseChartPainter.minMainHeight : _minPaneHeight; + + /// Largest height part [part] may take in [PaneSizeMode.custom]; the candles + /// have no ceiling, as they are what is left. + double _largestPart(int part) => + part == 0 ? double.infinity : widget.chartStyle.maxPaneHeight; + + /// Moves the line between part [line] and the one below it by [delta] pixels, + /// within what each of the two may take. + void _moveDivider(int line, double delta) { + final split = _ratioSplit; + if (split == null || widget.paneSizeMode != PaneSizeMode.custom) return; + if (line < 0 || line + 1 >= split.ratios.length) return; + + final above = split.ratios[line] * split.unit; + final below = split.ratios[line + 1] * split.unit; + final lowest = math.max( + _smallestPart(line) - above, + below - _largestPart(line + 1), + ); + final highest = math.min( + _largestPart(line) - above, + below - _smallestPart(line + 1), ); - _paneHeights = heights; + // Parts already outside their bounds, say after a resize, are left alone + // rather than snapped. + if (lowest > highest) return; + final moved = delta.clamp(lowest, highest); + if (moved == 0) return; + + final ratios = [...split.ratios]; + ratios[line] += moved / split.unit; + ratios[line + 1] -= moved / split.unit; + setState(() => _customRatios = ratios); + widget.controller?.hostChanged(); + widget.onPaneRatiosChanged?.call(List.unmodifiable(ratios)); + } + + /// Where each line between the parts sits, top to bottom, or none where the + /// chart is not laid out for lines. + List _dividerPositions(BaseDimension dimension) { + final paneHeights = dimension.paneHeights; + final volume = dimension.mVolumeHeight; + final main = + dimension.mDisplayHeight - + painter.mTopPadding - + painter.mBottomPadding - + volume - + dimension.totalSecondaryHeight; + if (main < BaseChartPainter.minMainHeight) return const []; + + final lines = []; + var y = painter.mTopPadding + main; + lines.add(y); + if (!widget.volHidden) { + y += volume; + lines.add(y); + } + for (var i = 0; i < paneHeights.length - 1; i++) { + y += paneHeights[i]; + lines.add(y); + } + return lines; + } + + /// The lines between the parts, while they are being edited. + List _buildPaneDividers(BaseDimension dimension) { + if (!_editingPanes || + widget.paneSizeMode != PaneSizeMode.custom || + _ratioSplit == null || + _maximizedPaneIndex != null || + _volumeMaximized) { + return const []; + } + final accent = widget.chartColors.kLineColor; + final lines = _dividerPositions(dimension); + return [ + for (var i = 0; i < lines.length; i++) + Positioned( + left: 0, + right: 0, + top: lines[i] - 10, + height: 20, + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) => + _moveDivider(i, details.delta.dy), + child: Center( + child: Stack( + alignment: Alignment.center, + children: [ + Container(height: 2, color: accent.withValues(alpha: 0.8)), + Container( + width: 36, + height: 6, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), + ), + ), + ), + ), + ]; + } + + /// Moves room between pane [index] and its neighbour by [delta] pixels. + /// + /// The edge dragged is the pane's lower one, so the room comes from the pane + /// below. The last pane has nothing below it and takes from the part above. + /// The total is kept, and no part is squeezed under its smallest. The host + /// owns the proportions, so this only reports where they would go. + void _resizeByRatio( + ({List ratios, double unit}) split, + int index, + double delta, + ) { + final ratios = [...split.ratios]; + final slot = (widget.volHidden ? 1 : 2) + index; + final other = slot + 1 < ratios.length ? slot + 1 : slot - 1; + + double floorOf(int part) => + (part == 0 ? BaseChartPainter.minMainHeight : _minPaneHeight) / + split.unit; + + // Pixels moved, in proportion units, held so neither side drops below its + // floor: what the pane can gain is what the other can spare, and back. + final lowest = floorOf(slot) - ratios[slot]; + final highest = ratios[other] - floorOf(other); + if (lowest > highest) return; + final move = (delta / split.unit).clamp(lowest, highest); + ratios[slot] += move; + ratios[other] -= move; notifyChanged(); + widget.onPaneRatiosChanged?.call(List.unmodifiable(ratios)); } /// Works out where the pane being dragged was dropped, and reports the move. @@ -4130,6 +4637,120 @@ class _KChartWidgetState extends State widget.controller?.hostChanged(); } + @override + List get chartPaneHeights => + List.unmodifiable(_effectivePaneHeights); + + @override + bool setChartPaneHeight(int index, double height) { + final heights = [..._effectivePaneHeights]; + if (index < 0 || index >= heights.length || !_usableHeight(height)) { + return false; + } + heights[index] = height; + setState(() { + // A host that owns the heights hears about it and passes it back. + if (!_heightsLocked) { + _paneHeights = heights; + _hostSizedPanes = true; + } + _clearMaximized(); + }); + widget.controller?.hostChanged(); + widget.onPaneHeightsChanged?.call(List.unmodifiable(heights)); + return true; + } + + @override + void resetChartPaneHeights() { + _effectivePaneHeights; + if (widget.paneSizeMode == PaneSizeMode.custom && + _customRatios.isNotEmpty) { + setState(() { + _customRatios = const []; + _clearMaximized(); + }); + widget.controller?.hostChanged(); + return; + } + final standard = List.filled( + _paneOwners.length, + BaseDimension.secondaryPaneHeight, + ); + final untouched = + _maximizedOwner == null && + !_volumeMaximized && + listEquals(_paneHeights, standard) && + !_heightsLocked; + if (untouched) return; + setState(() { + if (!_heightsLocked) { + _paneHeights = standard; + _hostSizedPanes = false; + } + _clearMaximized(); + }); + widget.controller?.hostChanged(); + widget.onPaneHeightsChanged?.call(List.unmodifiable(standard)); + } + + @override + int? get chartMaximizedPane { + _effectivePaneHeights; + return _volumeMaximized ? null : _maximizedPaneIndex; + } + + @override + bool maximizeChartPane(int? index) { + _effectivePaneHeights; + if (index == null) { + if (_maximizedOwner == null && !_volumeMaximized) return true; + setState(_clearMaximized); + widget.controller?.hostChanged(); + return true; + } + if (index < 0 || index >= _paneOwners.length) return false; + final owner = _paneOwners[index]; + if (_maximizedOwner != owner || _volumeMaximized) { + setState(() { + _volumeMaximized = false; + _maximizedOwner = owner; + }); + widget.controller?.hostChanged(); + } + return true; + } + + @override + bool get chartEditingPanes => + _editingPanes && widget.paneSizeMode == PaneSizeMode.custom; + + @override + bool setChartEditingPanes(bool editing) { + if (widget.paneSizeMode != PaneSizeMode.custom) return false; + if (_editingPanes != editing) { + setState(() => _editingPanes = editing); + widget.controller?.hostChanged(); + } + return true; + } + + @override + bool get chartVolumeMaximized => _volumeMaximized && !widget.volHidden; + + @override + bool maximizeChartVolume() { + if (widget.volHidden) return false; + if (!_volumeMaximized) { + setState(() { + _maximizedOwner = null; + _volumeMaximized = true; + }); + widget.controller?.hostChanged(); + } + return true; + } + @override void showChartCrosshair(int? index, {double? price}) { if (index == null) { diff --git a/lib/src/pane_size_mode.dart b/lib/src/pane_size_mode.dart new file mode 100644 index 0000000..7457a2e --- /dev/null +++ b/lib/src/pane_size_mode.dart @@ -0,0 +1,32 @@ +/// How the heights of the candles, the volume pane and the indicator panes are +/// worked out. +/// +/// Pass one to `KChartWidget.paneSizeMode`. +enum PaneSizeMode { + /// Pixel heights: 100 for each indicator pane and 60 for the volume pane, or + /// what `KChartWidget.paneHeights` and `volumeHeight` say, with the candles + /// taking the rest. The default. + /// + /// With `resizablePanes` on, dragging a pane's lower edge changes its height. + heights, + + /// Proportions: `KChartWidget.paneRatios` splits the height between the + /// candles, the volume pane and each indicator pane, and the split holds as + /// the chart is resized. + /// + /// The host owns the proportions; a drag with `resizablePanes` on reports the + /// new ones through `onPaneRatiosChanged`. + ratios, + + /// The user lays the chart out themselves. Turn editing on with + /// `KChartController.editPanes` and a line appears between each two parts, + /// to be dragged up or down between the smallest and largest height each part + /// may take (`ChartStyle.minPaneHeight` and `maxPaneHeight`, and a minimum + /// for the candles). + /// + /// The chart owns the layout and keeps it as proportions, so it holds through + /// a resize. It starts from `paneRatios` when given, or from the standard + /// heights, and the result is reported through `onPaneRatiosChanged` for + /// saving. + custom, +} diff --git a/lib/src/renderer/base_chart_renderer.dart b/lib/src/renderer/base_chart_renderer.dart index 6e3da68..96eb067 100644 --- a/lib/src/renderer/base_chart_renderer.dart +++ b/lib/src/renderer/base_chart_renderer.dart @@ -15,13 +15,16 @@ abstract class BaseChartRenderer { Color? separatorColor, Color? gridColumnColor, double gridStrokeWidth = 0.5, + this.showGridRows = true, + this.showGridColumns = true, + List? gridDashPattern, double separatorWidth = 1.0, this.labelCornerRadius = const BorderRadius.all(Radius.circular(3)), this.legendPadding = 4.0, this.legendBgColor, this.priceAxisGutter = 0.0, this.priceAxisGutterOnLeft = false, - }) { + }) : gridDashPattern = _validDash(gridDashPattern) { if (maxValue == minValue) { maxValue *= 1.5; minValue /= 2; @@ -38,6 +41,45 @@ abstract class BaseChartRenderer { ..strokeWidth = gridStrokeWidth; } + /// Whether the horizontal grid lines are drawn. + final bool showGridRows; + + /// Whether the vertical grid lines are drawn. + final bool showGridColumns; + + /// Validated dash lengths for the grid, or null for solid lines. + final List? gridDashPattern; + + static List? _validDash(List? pattern) { + if (pattern == null || pattern.isEmpty) return null; + if (pattern.any((v) => !v.isFinite || v <= 0)) return null; + return List.unmodifiable(pattern); + } + + /// Draws one grid line, dashed when [gridDashPattern] is set. + void drawGridLine(Canvas canvas, Offset from, Offset to, Paint paint) { + final pattern = gridDashPattern; + if (pattern == null) { + canvas.drawLine(from, to, paint); + return; + } + final delta = to - from; + final length = delta.distance; + if (length == 0) return; + final unit = delta / length; + var travelled = 0.0; + var i = 0; + while (travelled < length) { + final segment = pattern[i % pattern.length]; + if (i.isEven) { + final end = (travelled + segment).clamp(0.0, length); + canvas.drawLine(from + unit * travelled, from + unit * end, paint); + } + travelled += segment; + i++; + } + } + /// Corner radius of the legend pill. final BorderRadius labelCornerRadius; diff --git a/lib/src/renderer/base_dimension.dart b/lib/src/renderer/base_dimension.dart index ed712a6..53e5166 100644 --- a/lib/src/renderer/base_dimension.dart +++ b/lib/src/renderer/base_dimension.dart @@ -10,9 +10,10 @@ class BaseDimension { required int paneCount, required int legendRowCount, List? paneHeights, + double? volHeight, }) { _mBaseHeight = mBaseHeight; - _mVolumeHeight = volHidden ? 0 : volumeHeight; + _mVolumeHeight = volHidden ? 0 : volHeight ?? volumeHeight; _paneHeights = [ for (var i = 0; i < paneCount; i++) paneHeights != null && i < paneHeights.length @@ -49,6 +50,7 @@ class BaseDimension { required int paneCount, required int legendRowCount, List? paneHeights, + double? volHeight, }) { var panes = 0.0; for (var i = 0; i < paneCount; i++) { @@ -56,7 +58,7 @@ class BaseDimension { ? paneHeights[i] : secondaryPaneHeight; } - return (volHidden ? 0 : volumeHeight) + + return (volHidden ? 0 : volHeight ?? volumeHeight) + panes + legendRowHeight * legendRowCount; } diff --git a/lib/src/renderer/chart_painter.dart b/lib/src/renderer/chart_painter.dart index 27960d5..cfc12e6 100644 --- a/lib/src/renderer/chart_painter.dart +++ b/lib/src/renderer/chart_painter.dart @@ -509,9 +509,14 @@ class ChartPainter extends BaseChartPainter { if (!hideGrid) { // Every pane rules itself on the same columns, so they line up down the // stack and each one meets its own date label at the bottom. - final columnXs = [ - for (final index in dateTickIndices()) translateXtoX(getX(index)), - ]; + // + // `evenlySpaced` skips candle timestamps entirely — passing columnXs: + // null tells each renderer to divide the chart width into gridColumns + // equal bands itself, so the grid always spans the chart no matter how + // few candles are in view. + final columnXs = chartStyle.gridColumnMode == GridColumnMode.dateTicks + ? [for (final index in dateTickIndices()) translateXtoX(getX(index))] + : null; mMainRenderer.drawGrid( canvas, mGridRows, diff --git a/lib/src/renderer/indicator_pane_renderer.dart b/lib/src/renderer/indicator_pane_renderer.dart index 50ea283..714061b 100644 --- a/lib/src/renderer/indicator_pane_renderer.dart +++ b/lib/src/renderer/indicator_pane_renderer.dart @@ -39,6 +39,9 @@ class IndicatorPaneRenderer extends BaseChartRenderer { separatorColor: chartColors.effectiveSeparatorColor, gridColumnColor: chartColors.effectiveGridColumnColor, gridStrokeWidth: chartStyle.gridStrokeWidth, + showGridRows: chartStyle.showGridRows, + showGridColumns: chartStyle.showGridColumns, + gridDashPattern: chartStyle.gridDashPattern, separatorWidth: chartStyle.separatorWidth, labelCornerRadius: chartStyle.labelCornerRadius, legendPadding: chartStyle.legendPadding, @@ -250,16 +253,22 @@ class IndicatorPaneRenderer extends BaseChartRenderer { gridPaint, ); - if (indicator.fixedRange == null) { + if (showGridRows && indicator.fixedRange == null) { for (final value in valueTicks) { final y = getY(value); if (!y.isFinite || y <= chartRect.top || y >= chartRect.bottom) { continue; } - canvas.drawLine(Offset(0, y), Offset(chartRect.width, y), gridPaint); + drawGridLine( + canvas, + Offset(0, y), + Offset(chartRect.width, y), + gridPaint, + ); } } + if (!showGridColumns) return; final columns = columnXs ?? [ @@ -267,7 +276,8 @@ class IndicatorPaneRenderer extends BaseChartRenderer { chartRect.width / gridColumns * i, ]; for (final x in columns) { - canvas.drawLine( + drawGridLine( + canvas, Offset(x, chartRect.top - topPadding), Offset(x, chartRect.bottom), columnGridPaint, diff --git a/lib/src/renderer/main_renderer.dart b/lib/src/renderer/main_renderer.dart index e3f2e98..b69969b 100644 --- a/lib/src/renderer/main_renderer.dart +++ b/lib/src/renderer/main_renderer.dart @@ -68,6 +68,9 @@ class MainRenderer extends BaseChartRenderer { separatorColor: chartColors.effectiveSeparatorColor, gridColumnColor: chartColors.effectiveGridColumnColor, gridStrokeWidth: chartStyle.gridStrokeWidth, + showGridRows: chartStyle.showGridRows, + showGridColumns: chartStyle.showGridColumns, + gridDashPattern: chartStyle.gridDashPattern, separatorWidth: chartStyle.separatorWidth, labelCornerRadius: chartStyle.labelCornerRadius, legendPadding: chartStyle.legendPadding, @@ -1109,16 +1112,20 @@ class MainRenderer extends BaseChartRenderer { List? columnXs, }) { // Rule the pane where the labels are, not on evenly spaced pixels. - for (final value in priceTicks(gridRows)) { - final y = getY(value); - if (!y.isFinite || y < chartRect.top || y > chartRect.bottom) continue; - canvas.drawLine( - Offset(chartRect.left, y), - Offset(chartRect.right, y), - gridPaint, - ); + if (showGridRows) { + for (final value in priceTicks(gridRows)) { + final y = getY(value); + if (!y.isFinite || y < chartRect.top || y > chartRect.bottom) continue; + drawGridLine( + canvas, + Offset(chartRect.left, y), + Offset(chartRect.right, y), + gridPaint, + ); + } } + if (!showGridColumns) return; final columns = columnXs ?? [ @@ -1126,7 +1133,8 @@ class MainRenderer extends BaseChartRenderer { chartRect.width / gridColumns * i, ]; for (final x in columns) { - canvas.drawLine( + drawGridLine( + canvas, Offset(x, 0), Offset(x, chartRect.bottom), columnGridPaint, diff --git a/lib/src/renderer/vol_renderer.dart b/lib/src/renderer/vol_renderer.dart index e414528..2d1a0a5 100644 --- a/lib/src/renderer/vol_renderer.dart +++ b/lib/src/renderer/vol_renderer.dart @@ -30,6 +30,9 @@ class VolRenderer extends BaseChartRenderer { separatorColor: chartColors.effectiveSeparatorColor, gridColumnColor: chartColors.effectiveGridColumnColor, gridStrokeWidth: chartStyle.gridStrokeWidth, + showGridRows: chartStyle.showGridRows, + showGridColumns: chartStyle.showGridColumns, + gridDashPattern: chartStyle.gridDashPattern, separatorWidth: chartStyle.separatorWidth, labelCornerRadius: chartStyle.labelCornerRadius, legendPadding: chartStyle.legendPadding, @@ -207,12 +210,22 @@ class VolRenderer extends BaseChartRenderer { ); // A mark part-way up gives the bars something to be read against. - for (final value in volumeTicks) { - final y = getVolY(value); - if (!y.isFinite || y <= chartRect.top || y >= chartRect.bottom) continue; - canvas.drawLine(Offset(0, y), Offset(chartRect.width, y), gridPaint); + if (showGridRows) { + for (final value in volumeTicks) { + final y = getVolY(value); + if (!y.isFinite || y <= chartRect.top || y >= chartRect.bottom) { + continue; + } + drawGridLine( + canvas, + Offset(0, y), + Offset(chartRect.width, y), + gridPaint, + ); + } } + if (!showGridColumns) return; final columns = columnXs ?? [ @@ -220,7 +233,8 @@ class VolRenderer extends BaseChartRenderer { chartRect.width / gridColumns * i, ]; for (final x in columns) { - canvas.drawLine( + drawGridLine( + canvas, Offset(x, chartRect.top - topPadding), Offset(x, chartRect.bottom), columnGridPaint, diff --git a/pubspec.yaml b/pubspec.yaml index 23b4020..3a4b785 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Candlestick charts for Flutter with 31 indicators, 29 drawing tools and 39 more chart widgets: depth, order flow, series, pie, treemap, sankey. Pure CustomPainter, no WebView. -version: 3.0.0 +version: 3.1.0 homepage: https://github.com/CtrlAltDevelop repository: https://github.com/CtrlAltDevelop/ohlcv_chart issue_tracker: https://github.com/CtrlAltDevelop/ohlcv_chart/issues diff --git a/screenshots/grid-options.jpg b/screenshots/grid-options.jpg new file mode 100644 index 0000000..74794e5 Binary files /dev/null and b/screenshots/grid-options.jpg differ diff --git a/screenshots/panes-custom-dragged.jpg b/screenshots/panes-custom-dragged.jpg new file mode 100644 index 0000000..5ffef80 Binary files /dev/null and b/screenshots/panes-custom-dragged.jpg differ diff --git a/screenshots/panes-custom-edit.jpg b/screenshots/panes-custom-edit.jpg new file mode 100644 index 0000000..ba9dce7 Binary files /dev/null and b/screenshots/panes-custom-edit.jpg differ diff --git a/screenshots/panes-default.jpg b/screenshots/panes-default.jpg new file mode 100644 index 0000000..30c521c Binary files /dev/null and b/screenshots/panes-default.jpg differ diff --git a/screenshots/panes-maximized-obv.jpg b/screenshots/panes-maximized-obv.jpg new file mode 100644 index 0000000..6dd79ff Binary files /dev/null and b/screenshots/panes-maximized-obv.jpg differ diff --git a/screenshots/panes-maximized-rsi.jpg b/screenshots/panes-maximized-rsi.jpg new file mode 100644 index 0000000..1a3d239 Binary files /dev/null and b/screenshots/panes-maximized-rsi.jpg differ diff --git a/screenshots/panes-ratios.jpg b/screenshots/panes-ratios.jpg new file mode 100644 index 0000000..7538a7f Binary files /dev/null and b/screenshots/panes-ratios.jpg differ diff --git a/test/chart_link_test.dart b/test/chart_link_test.dart index cca9060..d0cc031 100644 --- a/test/chart_link_test.dart +++ b/test/chart_link_test.dart @@ -124,6 +124,25 @@ class _FakeHost implements KChartHost { pricePan = pan; controller?.hostChanged(); } + + @override + List get chartPaneHeights => const []; + @override + bool setChartPaneHeight(int index, double height) => false; + @override + void resetChartPaneHeights() {} + @override + int? get chartMaximizedPane => null; + @override + bool maximizeChartPane(int? index) => false; + @override + bool get chartVolumeMaximized => false; + @override + bool maximizeChartVolume() => false; + @override + bool get chartEditingPanes => false; + @override + bool setChartEditingPanes(bool editing) => false; } ({KChartController controller, _FakeHost host}) chart({ diff --git a/test/chart_overview_test.dart b/test/chart_overview_test.dart index f075dad..68c460f 100644 --- a/test/chart_overview_test.dart +++ b/test/chart_overview_test.dart @@ -63,6 +63,25 @@ class _FakeHost implements KChartHost { double get chartPricePan => 0; @override void setChartPricePan(double pan) {} + + @override + List get chartPaneHeights => const []; + @override + bool setChartPaneHeight(int index, double height) => false; + @override + void resetChartPaneHeights() {} + @override + int? get chartMaximizedPane => null; + @override + bool maximizeChartPane(int? index) => false; + @override + bool get chartVolumeMaximized => false; + @override + bool maximizeChartVolume() => false; + @override + bool get chartEditingPanes => false; + @override + bool setChartEditingPanes(bool editing) => false; } List series(int count) => [ diff --git a/test/grid_style_test.dart b/test/grid_style_test.dart new file mode 100644 index 0000000..0c6ec27 --- /dev/null +++ b/test/grid_style_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; +import 'package:ohlcv_chart/src/renderer/chart_painter.dart'; + +import 'test_utils.dart'; + +const double _width = 500; +const double _height = 600; + +// Distinct colours so the probe can tell row lines from column lines. +const Color _rowColor = Color(0xFF112233); +const Color _columnColor = Color(0xFF445566); + +ChartPainter _painterOf(WidgetTester tester) { + final dynamic state = tester.state(find.byType(KChartWidget)); + // ignore: avoid_dynamic_calls + return state.painter as ChartPainter; +} + +Widget _chart({required ChartStyle style, int count = 3}) { + final data = candles(rampThenFall(count)); + DataUtil.calculate(data); + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: _width, + height: _height, + child: KChartWidget( + data, + ChartColors(gridColor: _rowColor, gridColumnColor: _columnColor), + isTrendLine: false, + timeFrame: const Duration(minutes: 15), + chartStyle: style, + ), + ), + ), + ); +} + +/// Records every line drawn, with the colour it was drawn in. +class _LineProbe implements Canvas { + final List<(Offset, Offset, Color)> lines = []; + + Iterable<(Offset, Offset, Color)> get columns => + lines.where((l) => l.$3.toARGB32() == _columnColor.toARGB32()); + Iterable<(Offset, Offset, Color)> get rows => + lines.where((l) => l.$3.toARGB32() == _rowColor.toARGB32()); + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + lines.add((p1, p2, paint.color)); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +Future<_LineProbe> _paint(WidgetTester tester, ChartStyle style) async { + await tester.pumpWidget(_chart(style: style)); + final probe = _LineProbe(); + _painterOf(tester).paint(probe, const Size(_width, _height)); + return probe; +} + +/// Distinct x positions of the vertical column lines in the main pane. +List _columnXs(_LineProbe probe) => + ({for (final l in probe.columns) l.$1.dx}.toList()..sort()); + +void main() { + testWidgets('evenlySpaced spans the chart however few candles there are', ( + tester, + ) async { + final probe = await _paint( + tester, + ChartStyle(gridColumnMode: GridColumnMode.evenlySpaced), + ); + final xs = _columnXs(probe); + expect(xs.length, ChartStyle().gridColumns + 1); + final gaps = [for (var i = 1; i < xs.length; i++) xs[i] - xs[i - 1]]; + for (final gap in gaps) { + expect(gap, closeTo(gaps.first, 0.01)); + } + }); + + testWidgets('dateTicks stays the default and clusters on sparse data', ( + tester, + ) async { + expect(ChartStyle().gridColumnMode, GridColumnMode.dateTicks); + final probe = await _paint(tester, ChartStyle()); + // Three candles cross at most a couple of time buckets. + expect(_columnXs(probe).length, lessThanOrEqualTo(2)); + }); + + testWidgets('showGridColumns: false draws no vertical lines', (tester) async { + final probe = await _paint(tester, ChartStyle(showGridColumns: false)); + expect(probe.columns, isEmpty); + expect(probe.rows, isNotEmpty); + }); + + testWidgets('showGridRows: false draws no price lines', (tester) async { + final all = await _paint(tester, ChartStyle()); + final probe = await _paint( + tester, + ChartStyle( + showGridRows: false, + gridColumnMode: GridColumnMode.evenlySpaced, + ), + ); + // Only the pane borders remain; they are not part of the grid. + expect(probe.rows.length, lessThan(all.rows.length ~/ 2)); + expect(probe.columns, isNotEmpty); + }); + + testWidgets('gridDashPattern breaks lines into segments', (tester) async { + final solid = await _paint(tester, ChartStyle()); + final dashed = await _paint( + tester, + ChartStyle(gridDashPattern: const [4, 3]), + ); + expect(dashed.rows.length, greaterThan(solid.rows.length)); + expect(dashed.columns.length, greaterThan(solid.columns.length)); + final dashes = dashed.rows.where((l) => (l.$2 - l.$1).distance <= 4.001); + expect(dashes, isNotEmpty); + }); + + testWidgets('an invalid dash pattern falls back to solid', (tester) async { + final solid = await _paint(tester, ChartStyle()); + for (final bad in [ + const [], + const [0.0, 3.0], + const [4.0, -1.0], + const [double.infinity], + ]) { + final probe = await _paint(tester, ChartStyle(gridDashPattern: bad)); + expect(probe.rows.length, solid.rows.length, reason: '$bad'); + } + }); + + test('copyWith carries the grid options', () { + final style = ChartStyle().copyWith( + gridColumnMode: GridColumnMode.evenlySpaced, + showGridRows: false, + showGridColumns: false, + gridDashPattern: const [2, 2], + ); + expect(style.gridColumnMode, GridColumnMode.evenlySpaced); + expect(style.showGridRows, isFalse); + expect(style.showGridColumns, isFalse); + expect(style.gridDashPattern, const [2.0, 2.0]); + // Untouched fields survive. + expect(style.gridColumns, ChartStyle().gridColumns); + }); +} diff --git a/test/pane_height_control_test.dart b/test/pane_height_control_test.dart new file mode 100644 index 0000000..aff745d --- /dev/null +++ b/test/pane_height_control_test.dart @@ -0,0 +1,945 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:ohlcv_chart/ohlcv_chart.dart'; +import 'package:ohlcv_chart/src/renderer/chart_painter.dart'; + +import 'test_utils.dart'; + +const double _boxHeight = 700; +const double _tallBox = 1100; + +List _candles() { + final data = candles(rampThenFall(60)); + DataUtil.calculate(data); + return data; +} + +Widget _chart({ + List? indicators, + List? paneHeights, + double? volumeHeight, + KChartController? controller, + ValueChanged>? onChanged, + bool resizable = false, + double boxHeight = _boxHeight, + List? ratios, + ValueChanged>? onRatios, + bool volHidden = false, + PaneSizeMode? mode, + ChartStyle? style, +}) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: boxHeight, + child: KChartWidget( + _candles(), + ChartColors(), + isTrendLine: false, + timeFrame: const Duration(minutes: 1), + showNowPrice: false, + indicators: indicators ?? [RsiIndicator(), MacdIndicator()], + paneHeights: paneHeights, + volumeHeight: volumeHeight, + controller: controller, + onPaneHeightsChanged: onChanged, + resizablePanes: resizable, + paneSizeMode: + mode ?? + (ratios != null ? PaneSizeMode.ratios : PaneSizeMode.heights), + paneRatios: ratios, + chartStyle: style ?? ChartStyle(), + onPaneRatiosChanged: onRatios, + volHidden: volHidden, + ), + ), + ), +); + +ChartPainter _painterOf(WidgetTester tester) { + final paint = tester.widget( + find + .descendant( + of: find.byType(KChartWidget), + matching: find.byWidgetPredicate( + (w) => w is CustomPaint && w.painter is ChartPainter, + ), + ) + .first, + ); + return paint.painter! as ChartPainter; +} + +List _paneHeightsOf(WidgetTester tester) => [ + for (final r in _painterOf(tester).mSecondaryRectList) r.mRect.height, +]; + +/// A test window tall enough for [_tallBox]. +void _tallView(WidgetTester tester) { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets('panes keep the standard height by default', (tester) async { + await tester.pumpWidget(_chart()); + final heights = _paneHeightsOf(tester); + expect(heights, hasLength(2)); + // The pane rect leaves room for the legend strip above it. + expect(heights[0], heights[1]); + }); + + testWidgets('paneHeights sizes the panes and null puts them back', ( + tester, + ) async { + await tester.pumpWidget(_chart()); + final standard = _paneHeightsOf(tester); + + await tester.pumpWidget(_chart(paneHeights: [250, 80])); + final sized = _paneHeightsOf(tester); + expect(sized[0], greaterThan(sized[1])); + expect(sized[0] - standard[0], closeTo(150, 0.01)); + expect(sized[1] - standard[1], closeTo(-20, 0.01)); + + await tester.pumpWidget(_chart()); + expect(_paneHeightsOf(tester), standard); + }); + + testWidgets('a short or unusable list falls back to the standard height', ( + tester, + ) async { + await tester.pumpWidget(_chart()); + final standard = _paneHeightsOf(tester); + + await tester.pumpWidget(_chart(paneHeights: [double.nan, -5])); + expect(_paneHeightsOf(tester), standard); + + await tester.pumpWidget(_chart(paneHeights: [200])); + final sized = _paneHeightsOf(tester); + expect(sized[0] - standard[0], closeTo(100, 0.01)); + expect(sized[1], standard[1]); + }); + + testWidgets('a height past the drag ceiling is used, not clamped', ( + tester, + ) async { + _tallView(tester); + await tester.pumpWidget(_chart(boxHeight: _tallBox)); + final standard = _paneHeightsOf(tester); + + await tester.pumpWidget( + _chart(paneHeights: [450, 100], boxHeight: _tallBox), + ); + expect(_paneHeightsOf(tester)[0] - standard[0], closeTo(350, 0.01)); + }); + + testWidgets('a height too big for the box is cut back to leave the candles', ( + tester, + ) async { + await tester.pumpWidget(_chart(paneHeights: [5000, 100])); + final painter = _painterOf(tester); + expect(painter.mMainRect.height, greaterThanOrEqualTo(60)); + expect( + painter.mSecondaryRectList.last.mRect.bottom, + lessThanOrEqualTo(_boxHeight), + ); + }); + + testWidgets('volumeHeight resizes the volume pane', (tester) async { + await tester.pumpWidget(_chart()); + final standard = _painterOf(tester).mVolRect!.height; + + await tester.pumpWidget(_chart(volumeHeight: 140)); + expect(_painterOf(tester).mVolRect!.height - standard, closeTo(80, 0.01)); + + await tester.pumpWidget(_chart(volumeHeight: -1)); + expect(_painterOf(tester).mVolRect!.height, standard); + }); + + testWidgets('the controller sizes and resets a pane', (tester) async { + final controller = KChartController(); + final reported = >[]; + await tester.pumpWidget( + _chart(controller: controller, onChanged: reported.add), + ); + final standard = _paneHeightsOf(tester); + expect(controller.paneHeights, [100, 100]); + + expect(controller.setPaneHeight(1, 300), isTrue); + await tester.pump(); + expect(controller.paneHeights, [100, 300]); + expect(_paneHeightsOf(tester)[1] - standard[1], closeTo(200, 0.01)); + expect(reported.last, [100, 300]); + + expect(controller.setPaneHeight(5, 300), isFalse); + expect(controller.setPaneHeight(0, 0), isFalse); + expect(controller.setPaneHeight(0, double.infinity), isFalse); + + controller.resetPaneHeights(); + await tester.pump(); + expect(_paneHeightsOf(tester), standard); + expect(reported.last, [100, 100]); + }); + + testWidgets('maximizePane fills the box and restorePanes undoes it', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + final standard = _paneHeightsOf(tester); + final mainBefore = _painterOf(tester).mMainRect.height; + + expect(controller.maximizePane(1), isTrue); + await tester.pump(); + expect(controller.maximizedPane, 1); + + final painter = _painterOf(tester); + final heights = _paneHeightsOf(tester); + expect(heights[1], greaterThan(heights[0] * 3)); + expect(painter.mMainRect.height, lessThan(mainBefore)); + expect(painter.mMainRect.height, greaterThanOrEqualTo(60)); + expect( + painter.mSecondaryRectList.last.mRect.bottom, + lessThanOrEqualTo(_boxHeight), + ); + + controller.restorePanes(); + await tester.pump(); + expect(controller.maximizedPane, isNull); + expect(_paneHeightsOf(tester), standard); + }); + + testWidgets('toggleMaximizePane flips and the stretch follows a reorder', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + + controller.toggleMaximizePane(0); + await tester.pump(); + expect(controller.maximizedPane, 0); + + // The same two indicators in the other order: the stretch goes with RSI. + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [MacdIndicator(), RsiIndicator()], + ), + ); + expect(controller.maximizedPane, 1); + + controller.toggleMaximizePane(1); + await tester.pump(); + expect(controller.maximizedPane, isNull); + }); + + testWidgets('maximizing is dropped when the panes change', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + controller.maximizePane(0); + await tester.pump(); + + await tester.pumpWidget( + _chart(controller: controller, indicators: [RsiIndicator()]), + ); + expect(controller.maximizedPane, isNull); + }); + + testWidgets('a reorder carries the dragged heights with their panes', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + controller.setPaneHeight(0, 300); + await tester.pump(); + expect(controller.paneHeights, [300, 100]); + + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [MacdIndicator(), RsiIndicator()], + ), + ); + expect(controller.paneHeights, [100, 300]); + }); + + testWidgets('adding a pane throws the sized heights away', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + controller.setPaneHeight(0, 300); + await tester.pump(); + + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator(), KdjIndicator()], + ), + ); + expect(controller.paneHeights, [100, 100, 100]); + }); + + testWidgets('a drag on a pane edge reports the new heights', (tester) async { + final reported = >[]; + await tester.pumpWidget(_chart(resizable: true, onChanged: reported.add)); + final edge = _painterOf(tester).mSecondaryRectList.first.mRect.bottom; + final box = tester.getTopLeft(find.byType(KChartWidget)); + + final gesture = await tester.startGesture(box + Offset(200, edge)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.up(); + await tester.pump(); + + expect(reported, isNotEmpty); + expect(reported.last[0], greaterThan(100)); + expect(reported.last[1], 100); + }); + + testWidgets('a host-sized pane taller than the ceiling is not snapped down ' + 'by a drag', (tester) async { + _tallView(tester); + final reported = >[]; + await tester.pumpWidget( + _chart( + resizable: true, + paneHeights: [450, 100], + onChanged: reported.add, + boxHeight: _tallBox, + ), + ); + final edge = _painterOf(tester).mSecondaryRectList.first.mRect.bottom; + final box = tester.getTopLeft(find.byType(KChartWidget)); + + final gesture = await tester.startGesture(box + Offset(200, edge)); + await gesture.moveBy(const Offset(0, -20)); + await gesture.moveBy(const Offset(0, -20)); + await gesture.up(); + await tester.pump(); + + expect(reported, isNotEmpty); + expect(reported.last[0], greaterThan(400)); + }); + + testWidgets('a host that passes paneHeights owns them: a drag only reports', ( + tester, + ) async { + _tallView(tester); + final reported = >[]; + await tester.pumpWidget( + _chart( + resizable: true, + paneHeights: [100, 100], + onChanged: reported.add, + boxHeight: _tallBox, + ), + ); + final before = _paneHeightsOf(tester); + final edge = _painterOf(tester).mSecondaryRectList.first.mRect.bottom; + final box = tester.getTopLeft(find.byType(KChartWidget)); + + final gesture = await tester.startGesture(box + Offset(200, edge)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.up(); + await tester.pump(); + + expect(reported, isNotEmpty); + expect(reported.last[0], greaterThan(100)); + expect(_paneHeightsOf(tester), before); + + // Passing the reported heights back is what moves the pane. + await tester.pumpWidget( + _chart( + resizable: true, + paneHeights: reported.last, + onChanged: reported.add, + boxHeight: _tallBox, + ), + ); + expect(_paneHeightsOf(tester)[0], greaterThan(before[0])); + }); + + testWidgets('setPaneHeight on a host-owned chart reports without moving', ( + tester, + ) async { + final controller = KChartController(); + final reported = >[]; + await tester.pumpWidget( + _chart( + controller: controller, + paneHeights: [100, 100], + onChanged: reported.add, + ), + ); + final before = _paneHeightsOf(tester); + + expect(controller.setPaneHeight(0, 250), isTrue); + await tester.pump(); + expect(reported.last, [250, 100]); + expect(_paneHeightsOf(tester), before); + }); + + testWidgets('maximizePane still works over host-owned heights', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart(controller: controller, paneHeights: [100, 100]), + ); + final before = _paneHeightsOf(tester); + + controller.maximizePane(0); + await tester.pump(); + expect(_paneHeightsOf(tester)[0], greaterThan(before[0] * 2)); + + controller.restorePanes(); + await tester.pump(); + expect(_paneHeightsOf(tester), before); + }); + + testWidgets('maximizeVolume fills the box and restorePanes undoes it', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + final standard = _painterOf(tester).mVolRect!.height; + final panes = _paneHeightsOf(tester); + + expect(controller.maximizeVolume(), isTrue); + await tester.pump(); + expect(controller.isVolumeMaximized, isTrue); + expect(controller.maximizedPane, isNull); + + final painter = _painterOf(tester); + expect(painter.mVolRect!.height, greaterThan(standard * 3)); + expect(painter.mMainRect.height, greaterThanOrEqualTo(60)); + expect( + painter.mSecondaryRectList.last.mRect.bottom, + lessThanOrEqualTo(_boxHeight), + ); + + // Maximizing a pane takes the volume pane back down. + controller.maximizePane(0); + await tester.pump(); + expect(controller.isVolumeMaximized, isFalse); + expect(controller.maximizedPane, 0); + + controller.maximizeVolume(); + controller.restorePanes(); + await tester.pump(); + expect(controller.isVolumeMaximized, isFalse); + expect(_painterOf(tester).mVolRect!.height, standard); + expect(_paneHeightsOf(tester), panes); + }); + + testWidgets('the volume pane cannot be maximized while it is hidden', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: _boxHeight, + child: KChartWidget( + _candles(), + ChartColors(), + isTrendLine: false, + timeFrame: const Duration(minutes: 1), + volHidden: true, + controller: controller, + ), + ), + ), + ), + ); + expect(controller.maximizeVolume(), isFalse); + expect(controller.isVolumeMaximized, isFalse); + }); + + group('paneRatios', () { + testWidgets('divides the height between candles, volume and panes', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [3, 1, 2, 4], + ), + ); + final heights = controller.paneHeights; + // The two panes hold their 2 : 4 proportion exactly. + expect(heights[1] / heights[0], closeTo(2, 0.001)); + + // The parts fill the box (the test window is shorter than the box), + // less the strip the date axis takes under the last pane. + final painter = _painterOf(tester); + final bottom = painter.mSecondaryRectList.last.mRect.bottom; + expect( + bottom, + closeTo( + tester.view.physicalSize.height / tester.view.devicePixelRatio, + 30, + ), + ); + + // 3 : 1 : 2 : 4 — the first pane is 2/10 of what is left of the box. + final main = painter.mMainRect.height; + final volume = painter.mVolRect!.height; + expect(main, greaterThan(volume * 2)); + expect(heights[0], greaterThan(volume)); + }); + + testWidgets('keeps the proportions when the box is resized', ( + tester, + ) async { + _tallView(tester); + final controller = KChartController(); + Widget at(double height) => _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [3, 1, 2, 4], + boxHeight: height, + ); + + await tester.pumpWidget(at(600)); + final small = controller.paneHeights; + await tester.pumpWidget(at(1000)); + final large = controller.paneHeights; + + expect(large[0], greaterThan(small[0])); + expect(large[1] / large[0], closeTo(2, 0.001)); + expect(large[0] / small[0], closeTo(large[1] / small[1], 0.001)); + }); + + testWidgets('leaves the volume out of the list when it is hidden', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + volHidden: true, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [2, 1, 3], + ), + ); + final heights = controller.paneHeights; + expect(heights[1] / heights[0], closeTo(3, 0.001)); + }); + + testWidgets('a short list or a bad number counts as 1', (tester) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [3, 1, double.nan], + ), + ); + final heights = controller.paneHeights; + expect(heights[0], closeTo(heights[1], 0.001)); + }); + + testWidgets('wins over paneHeights and volumeHeight, and null lets go', ( + tester, + ) async { + final controller = KChartController(); + Widget chart({List? ratios}) => _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + paneHeights: [300, 40], + volumeHeight: 200, + ratios: ratios, + ); + + await tester.pumpWidget(chart(ratios: [3, 1, 2, 2])); + final heights = controller.paneHeights; + expect(heights[0], closeTo(heights[1], 0.001)); + + await tester.pumpWidget(chart()); + expect(controller.paneHeights, [300, 40]); + }); + + testWidgets('maximizePane wins over it, and restores to it', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [3, 1, 2, 2], + ), + ); + final before = controller.paneHeights; + + controller.maximizePane(0); + await tester.pump(); + expect(controller.paneHeights[0], greaterThan(before[0] * 2)); + + controller.restorePanes(); + await tester.pump(); + expect(controller.paneHeights, before); + }); + + testWidgets('a drag reports moved proportions with the total kept', ( + tester, + ) async { + final reported = >[]; + await tester.pumpWidget( + _chart( + resizable: true, + indicators: [RsiIndicator(), MacdIndicator()], + ratios: [3, 1, 2, 4], + onRatios: reported.add, + ), + ); + final before = _paneHeightsOf(tester); + final edge = _painterOf(tester).mSecondaryRectList.first.mRect.bottom; + final box = tester.getTopLeft(find.byType(KChartWidget)); + + final gesture = await tester.startGesture(box + Offset(200, edge)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 20)); + await gesture.up(); + await tester.pump(); + + expect(reported, isNotEmpty); + final last = reported.last; + expect(last.fold(0, (a, b) => a + b), closeTo(10, 0.0001)); + // The pane grew and the one below it gave the room; the rest held. + expect(last[2], greaterThan(2)); + expect(last[3], lessThan(4)); + expect(last[0], 3); + expect(last[1], 1); + // The host owns the proportions, so nothing moved until it passes them back. + expect(_paneHeightsOf(tester), before); + }); + + testWidgets('is ignored when the candle height is fixed', (tester) async { + final controller = KChartController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: _boxHeight, + child: KChartWidget( + _candles(), + ChartColors(), + isTrendLine: false, + timeFrame: const Duration(minutes: 1), + mBaseHeight: 300, + indicators: [RsiIndicator()], + paneSizeMode: PaneSizeMode.ratios, + paneRatios: const [1, 1, 5], + controller: controller, + ), + ), + ), + ), + ); + expect(controller.paneHeights, [100]); + }); + }); + group('PaneSizeMode.custom', () { + Finder dividers() => find.byWidgetPredicate( + (w) => w is MouseRegion && w.cursor == SystemMouseCursors.resizeUpDown, + ); + + Widget custom({ + KChartController? controller, + ValueChanged>? onRatios, + List? ratios, + double boxHeight = _boxHeight, + }) => _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + mode: PaneSizeMode.custom, + ratios: ratios, + onRatios: onRatios, + boxHeight: boxHeight, + ); + + double total(List list) => list.fold(0, (a, b) => a + b); + + testWidgets('starts from the standard layout', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + final standard = controller.paneHeights; + final volume = _painterOf(tester).mVolRect!.height; + + await tester.pumpWidget(custom(controller: controller)); + expect(controller.paneHeights, standard); + expect(_painterOf(tester).mVolRect!.height, closeTo(volume, 0.01)); + }); + + testWidgets('editing shows a line between each two parts', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller)); + expect(dividers(), findsNothing); + expect(controller.isEditingPanes, isFalse); + + expect(controller.editPanes(), isTrue); + await tester.pump(); + expect(controller.isEditingPanes, isTrue); + // Candles | volume | pane | pane. + expect(dividers(), findsNWidgets(3)); + + controller.finishEditingPanes(); + await tester.pump(); + expect(dividers(), findsNothing); + }); + + testWidgets('editing needs the custom mode', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(_chart(controller: controller)); + expect(controller.editPanes(), isFalse); + expect(controller.isEditingPanes, isFalse); + expect(dividers(), findsNothing); + }); + + testWidgets('dragging a line moves room between its two parts', ( + tester, + ) async { + final controller = KChartController(); + final reported = >[]; + await tester.pumpWidget( + custom(controller: controller, onRatios: reported.add), + ); + controller.editPanes(); + await tester.pump(); + final before = controller.paneHeights; + + // The line between the two panes, dragged up. + await tester.drag(dividers().at(2), const Offset(0, -30)); + await tester.pump(); + + final after = controller.paneHeights; + expect(after[0], closeTo(before[0] - 30, 0.5)); + expect(after[1], closeTo(before[1] + 30, 0.5)); + expect(reported, isNotEmpty); + expect(total(reported.last), closeTo(total(reported.first), 0.001)); + + // The layout stays once the lines are put away. + controller.finishEditingPanes(); + await tester.pump(); + expect(controller.paneHeights, after); + }); + + testWidgets( + 'a pane stops at the smallest height, and so does its neighbour', + (tester) async { + _tallView(tester); + final controller = KChartController(); + await tester.pumpWidget( + custom(controller: controller, boxHeight: _tallBox), + ); + controller.editPanes(); + await tester.pump(); + + // Pulled up past its floor, the first pane stops at the smallest height. + await tester.drag(dividers().at(2), const Offset(0, -300)); + await tester.pump(); + expect(controller.paneHeights[0], closeTo(40, 0.5)); + + // Pushed the other way, it stops where the pane below reaches its own + // floor: the two share 200 and the lower one keeps 40. + await tester.drag(dividers().at(2), const Offset(0, 500)); + await tester.pump(); + expect(controller.paneHeights[0], closeTo(160, 0.5)); + expect(controller.paneHeights[1], closeTo(40, 0.5)); + }, + ); + + testWidgets('a pane stops at the largest height', (tester) async { + _tallView(tester); + final controller = KChartController(); + await tester.pumpWidget( + custom( + controller: controller, + ratios: [2, 1, 3, 3], + boxHeight: _tallBox, + ), + ); + expect(controller.paneHeights[0], lessThan(400)); + controller.editPanes(); + await tester.pump(); + + await tester.drag(dividers().at(2), const Offset(0, 150)); + await tester.pump(); + expect(controller.paneHeights[0], closeTo(400, 0.5)); + }); + + testWidgets('the candles keep a strip however far a line is pulled', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller)); + controller.editPanes(); + await tester.pump(); + + await tester.drag(dividers().at(0), const Offset(0, -500)); + await tester.pump(); + expect(_painterOf(tester).mMainRect.height, greaterThanOrEqualTo(59)); + }); + + testWidgets('the layout holds as proportions when the box is resized', ( + tester, + ) async { + _tallView(tester); + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller, boxHeight: 700)); + controller.editPanes(); + await tester.pump(); + await tester.drag(dividers().at(2), const Offset(0, -30)); + await tester.pump(); + final small = controller.paneHeights; + + await tester.pumpWidget(custom(controller: controller, boxHeight: 1000)); + final large = controller.paneHeights; + expect(large[0] / large[1], closeTo(small[0] / small[1], 0.001)); + expect(large[0], greaterThan(small[0])); + }); + + testWidgets('resetPaneHeights puts the standard layout back', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller)); + final standard = controller.paneHeights; + controller.editPanes(); + await tester.pump(); + await tester.drag(dividers().at(2), const Offset(0, -30)); + await tester.pump(); + expect(controller.paneHeights, isNot(standard)); + + controller.resetPaneHeights(); + await tester.pump(); + expect(controller.paneHeights, standard); + }); + + testWidgets('paneRatios is the layout to start from, and a new list ' + 'starts it over', (tester) async { + final controller = KChartController(); + await tester.pumpWidget( + custom(controller: controller, ratios: [3, 1, 2, 4]), + ); + final heights = controller.paneHeights; + expect(heights[1] / heights[0], closeTo(2, 0.001)); + + controller.editPanes(); + await tester.pump(); + await tester.drag(dividers().at(2), const Offset(0, -20)); + await tester.pump(); + final dragged = controller.paneHeights; + expect(dragged[1] / dragged[0], isNot(closeTo(2, 0.01))); + + await tester.pumpWidget( + custom(controller: controller, ratios: [3, 1, 4, 2]), + ); + final restarted = controller.paneHeights; + expect(restarted[1] / restarted[0], closeTo(0.5, 0.001)); + }); + + testWidgets('no lines show while a pane is maximized', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller)); + controller.editPanes(); + controller.maximizePane(0); + await tester.pump(); + expect(dividers(), findsNothing); + + controller.restorePanes(); + await tester.pump(); + expect(dividers(), findsNWidgets(3)); + }); + + testWidgets('leaving the custom mode puts the lines away', (tester) async { + final controller = KChartController(); + await tester.pumpWidget(custom(controller: controller)); + controller.editPanes(); + await tester.pump(); + expect(dividers(), findsNWidgets(3)); + + await tester.pumpWidget(_chart(controller: controller)); + expect(dividers(), findsNothing); + expect(controller.isEditingPanes, isFalse); + }); + }); + + group('smallest height', () { + // Asked for no minimum at all, the chart still keeps one that can be + // grabbed again. + final noMinimum = ChartStyle(minPaneHeight: 0); + + Finder dividers() => find.byWidgetPredicate( + (w) => w is MouseRegion && w.cursor == SystemMouseCursors.resizeUpDown, + ); + + testWidgets('a line cannot squeeze a pane past what can be grabbed', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + mode: PaneSizeMode.custom, + style: noMinimum, + ), + ); + controller.editPanes(); + await tester.pump(); + + await tester.drag(dividers().at(2), const Offset(0, -300)); + await tester.pump(); + expect(controller.paneHeights[0], closeTo(32, 0.5)); + + // And the line is still there to pull it back out. + expect(dividers(), findsNWidgets(3)); + await tester.drag(dividers().at(2), const Offset(0, 40)); + await tester.pump(); + expect(controller.paneHeights[0], closeTo(72, 0.5)); + }); + + testWidgets('a dragged edge stops at the same floor', (tester) async { + final reported = >[]; + await tester.pumpWidget( + _chart(resizable: true, onChanged: reported.add, style: noMinimum), + ); + final edge = _painterOf(tester).mSecondaryRectList.first.mRect.bottom; + final box = tester.getTopLeft(find.byType(KChartWidget)); + + final gesture = await tester.startGesture(box + Offset(200, edge)); + for (var i = 0; i < 12; i++) { + await gesture.moveBy(const Offset(0, -10)); + } + await gesture.up(); + await tester.pump(); + + expect(reported.last[0], closeTo(32, 0.5)); + }); + + testWidgets('maximizing leaves the other panes at that floor', ( + tester, + ) async { + final controller = KChartController(); + await tester.pumpWidget( + _chart( + controller: controller, + indicators: [RsiIndicator(), MacdIndicator()], + style: noMinimum, + ), + ); + controller.maximizePane(0); + await tester.pump(); + expect(controller.paneHeights[1], closeTo(32, 0.5)); + }); + }); +}