From 7a4f867f5d1a49d14e6993e7938620bf0660cc7b Mon Sep 17 00:00:00 2001 From: Roland Bengtsson Date: Wed, 16 Sep 2026 20:06:50 +0300 Subject: [PATCH 1/2] fix: keep both panes visible in the studio split view The editor width was remembered as an absolute pixel value and reapplied without checking the space available. Once it exceeded the room left after the contents pane, the editor took the whole area and the preview was given zero width, so split view looked exactly like editor only. Every later return to split reapplied the same value, so switching modes could not recover it, and narrowing the window triggered it with no mode switch at all. Add Markdown4DStudio.SplitLayout, a framework free helper holding the two rules: the editor width is clamped so neither pane falls below MinPaneWidth, and below two minimums the space is shared evenly instead. Both studios call it from the four routes that change the split: entering split view, resizing the window, toggling the contents pane and finishing a splitter drag. A resize or a toggle passes the width the editor already has, so a splitter drag is kept and only trimmed when it no longer fits. The splitter's own minimum is restated on every width change, because a splitter asked to honour a minimum the window cannot give lets a drag collapse the other pane. The FMX splitter has no moved event, so its drag is caught on mouse up instead. --- .../Markdown4DStudioFMX.Main.pas | 55 +++++++- .../Markdown4DStudioFMX.dpr | 1 + .../Markdown4DStudioVCL.Main.pas | 53 +++++++- .../Markdown4DStudioVCL.dpr | 1 + Examples/Shared/Markdown4DStudio.Defines.pas | 3 + .../Shared/Markdown4DStudio.SplitLayout.pas | 63 ++++++++++ Tests/Markdown4D.Tests.dpr | 2 + Tests/Markdown4DStudio.SplitLayout.Tests.pas | 118 ++++++++++++++++++ 8 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 Examples/Shared/Markdown4DStudio.SplitLayout.pas create mode 100644 Tests/Markdown4DStudio.SplitLayout.Tests.pas diff --git a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas index 19635cd..40946d1 100644 --- a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas +++ b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas @@ -197,6 +197,10 @@ TMarkdown4DStudioFMXForm = class(TForm, IPadEditorView, IPadShell) procedure HandleDocumentHandedOver(const FileName: string); procedure SetViewMode(const Mode: TPadViewMode); procedure ApplyViewMode; + function AvailableSplitWidth: Single; + procedure ApplySplitEditorWidth(const DesiredWidth: Single); + procedure HandleSplitterMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Single); procedure ShowFindBar; procedure ShowReplaceBar; procedure CloseFindBar; @@ -313,6 +317,7 @@ implementation Markdown4D.Ast.Interfaces, Markdown4DStudio.Text, Markdown4DStudio.Outline, + Markdown4DStudio.SplitLayout, Markdown4DStudio.SessionSync, Markdown4DStudio.Workspace, Markdown4DStudio.LinkPolicy, @@ -757,6 +762,8 @@ procedure TMarkdown4DStudioFMXForm.BuildEditorAndPreview; FMainSplitter.Parent := Self; FMainSplitter.Align := TAlignLayout.Left; FMainSplitter.Width := SplitterWidth; + // FMX splitters have no moved event, so a finished drag is caught here. + FMainSplitter.OnMouseUp := HandleSplitterMouseUp; FPreview := TMarkdownViewer.Create(Self); FPreview.Parent := Self; @@ -1166,7 +1173,11 @@ procedure TMarkdown4DStudioFMXForm.Resize; LayoutTitleBar; if FZenActive then - UpdateZenPadding; + UpdateZenPadding + else if FViewMode = TPadViewMode.Split then + // A narrower window must take room from the editor rather than from the + // preview, which would otherwise be squeezed to nothing. + ApplySplitEditorWidth(FEditor.Width); if (FPalette <> nil) and FPalette.Visible then FPalette.Position.X := (ClientWidth - FPalette.Width) / 2; @@ -1185,6 +1196,42 @@ procedure TMarkdown4DStudioFMXForm.SetViewMode(const Mode: TPadViewMode); ApplyViewMode; end; +function TMarkdown4DStudioFMXForm.AvailableSplitWidth: Single; +begin + // What the editor and preview actually have to share. + Result := ClientWidth - FMainSplitter.Width; + + if FTocPanel.Visible then + Result := Result - FTocPanel.Width; + if FTocSplitter.Visible then + Result := Result - FTocSplitter.Width; +end; + +procedure TMarkdown4DStudioFMXForm.ApplySplitEditorWidth(const DesiredWidth: Single); +begin + // Entering split view asks for the remembered width; a resize or a contents + // pane toggle asks for the width the editor already has, so that a splitter + // drag is kept and only trimmed when it no longer fits. + const Available = Round(AvailableSplitWidth); + + // Re-stated on every width change, because a splitter told to honour a + // minimum the window cannot give lets a drag collapse the other pane. + FMainSplitter.MinSize := TPadSplitLayout.EffectiveMinPaneWidth(Available, MinPaneWidth); + + FEditor.Width := TPadSplitLayout.ClampEditorWidth(Round(DesiredWidth), Available, + MinPaneWidth); +end; + +procedure TMarkdown4DStudioFMXForm.HandleSplitterMouseUp(Sender: TObject; + Button: TMouseButton; Shift: TShiftState; X, Y: Single); +begin + // A drag is the user stating a preference, so remember it. It still has to + // pass the clamp, because on a narrow window the splitter cannot enforce a + // minimum the window is too small to give. + ApplySplitEditorWidth(FEditor.Width); + FSplitEditorWidth := FEditor.Width; +end; + procedure TMarkdown4DStudioFMXForm.ApplyViewMode; begin case FViewMode of @@ -1192,7 +1239,7 @@ procedure TMarkdown4DStudioFMXForm.ApplyViewMode; begin FEditor.Visible := True; FEditor.Align := TAlignLayout.Left; - FEditor.Width := FSplitEditorWidth; + ApplySplitEditorWidth(FSplitEditorWidth); FMainSplitter.Visible := True; FPreview.Visible := True; end; @@ -1639,6 +1686,10 @@ procedure TMarkdown4DStudioFMXForm.ToggleTocPane; const ShowToc = not FTocPanel.Visible; FTocPanel.Visible := ShowToc; FTocSplitter.Visible := ShowToc; + + // Showing the contents pane takes the same room the two halves share. + if (not FZenActive) and (FViewMode = TPadViewMode.Split) then + ApplySplitEditorWidth(FEditor.Width); end; procedure TMarkdown4DStudioFMXForm.HandleFindClick(Sender: TObject); diff --git a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.dpr b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.dpr index 36c90d5..afc96cf 100644 --- a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.dpr +++ b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.dpr @@ -21,6 +21,7 @@ uses Markdown4DStudio.Defines in '..\Shared\Markdown4DStudio.Defines.pas', Markdown4DStudio.Text in '..\Shared\Markdown4DStudio.Text.pas', Markdown4DStudio.Outline in '..\Shared\Markdown4DStudio.Outline.pas', + Markdown4DStudio.SplitLayout in '..\Shared\Markdown4DStudio.SplitLayout.pas', Markdown4DStudioFMX.Defines in 'Markdown4DStudioFMX.Defines.pas', Markdown4DStudio.Shell in '..\Shared\Markdown4DStudio.Shell.pas', Markdown4DStudio.CommandLine in '..\Shared\Markdown4DStudio.CommandLine.pas', diff --git a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas index a6d90c2..780ea62 100644 --- a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas +++ b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas @@ -245,6 +245,9 @@ TPadChrome = record function BuildCommandActions: TPadCommandActions; procedure SetViewMode(const Mode: TPadViewMode); procedure ApplyViewMode; + function AvailableSplitWidth: Integer; + procedure ApplySplitEditorWidth(const DesiredWidth: Integer); + procedure HandleSplitterMoved(Sender: TObject); procedure EnforceTopBarOrder; procedure EnforceLeftPaneOrder; procedure ShowFindBar; @@ -298,6 +301,7 @@ implementation Markdown4DStudio.SessionSync, Markdown4DStudio.Text, Markdown4DStudio.Outline, + Markdown4DStudio.SplitLayout, Markdown4DStudio.Workspace, Markdown4DStudio.LinkPolicy, Markdown4DStudio.SingleInstance, @@ -368,6 +372,8 @@ procedure TMarkdown4DStudioVCLForm.ConfigureControls; begin BuildReplaceControls; lblFindCount.Caption := EmptyFindCaption; + + splMain.OnMoved := HandleSplitterMoved; end; procedure TMarkdown4DStudioVCLForm.BuildToolbar; @@ -893,6 +899,10 @@ procedure TMarkdown4DStudioVCLForm.ToggleTocPane; pnlToc.Visible := ShowToc; splToc.Visible := ShowToc; + // Showing the contents pane takes the same room the two halves share. + if (not FZenActive) and (FViewMode = TPadViewMode.Split) then + ApplySplitEditorWidth(mdEditor.Width); + EnforceLeftPaneOrder; end; @@ -1716,6 +1726,41 @@ procedure TMarkdown4DStudioVCLForm.SetViewMode(const Mode: TPadViewMode); ApplyViewMode; end; +function TMarkdown4DStudioVCLForm.AvailableSplitWidth: Integer; +begin + // What the editor and preview actually have to share. + Result := ClientWidth - splMain.Width; + + if pnlToc.Visible then + Dec(Result, pnlToc.Width); + if splToc.Visible then + Dec(Result, splToc.Width); +end; + +procedure TMarkdown4DStudioVCLForm.ApplySplitEditorWidth(const DesiredWidth: Integer); +begin + // Entering split view asks for the remembered width; a resize or a contents + // pane toggle asks for the width the editor already has, so that a splitter + // drag is kept and only trimmed when it no longer fits. + const Available = AvailableSplitWidth; + + // Re-stated on every width change, because a splitter told to honour a + // minimum the window cannot give lets a drag collapse the other pane. + splMain.MinSize := TPadSplitLayout.EffectiveMinPaneWidth(Available, MinPaneWidth); + + mdEditor.Width := TPadSplitLayout.ClampEditorWidth(DesiredWidth, Available, + MinPaneWidth); +end; + +procedure TMarkdown4DStudioVCLForm.HandleSplitterMoved(Sender: TObject); +begin + // A drag is the user stating a preference, so remember it. It still has to + // pass the clamp, because on a narrow window the splitter cannot enforce a + // minimum the window is too small to give. + ApplySplitEditorWidth(mdEditor.Width); + FSplitEditorWidth := mdEditor.Width; +end; + procedure TMarkdown4DStudioVCLForm.ApplyViewMode; begin case FViewMode of @@ -1736,7 +1781,7 @@ procedure TMarkdown4DStudioVCLForm.ApplyViewMode; begin mdEditor.Align := alLeft; mdEditor.Visible := True; - mdEditor.Width := FSplitEditorWidth; + ApplySplitEditorWidth(FSplitEditorWidth); splMain.Visible := True; mdPreview.Visible := True; end; @@ -2058,7 +2103,11 @@ procedure TMarkdown4DStudioVCLForm.HandleResize(Sender: TObject); LayoutTitleBar; if FZenActive then - UpdateZenPadding; + UpdateZenPadding + else if FViewMode = TPadViewMode.Split then + // A narrower window must take room from the editor rather than from the + // preview, which would otherwise be squeezed to nothing. + ApplySplitEditorWidth(mdEditor.Width); if FPalette <> nil then FPalette.Left := (ClientWidth - FPalette.Width) div 2; diff --git a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.dpr b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.dpr index ee14acb..e90b375 100644 --- a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.dpr +++ b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.dpr @@ -19,6 +19,7 @@ uses Markdown4DStudio.Defines in '..\Shared\Markdown4DStudio.Defines.pas', Markdown4DStudio.Text in '..\Shared\Markdown4DStudio.Text.pas', Markdown4DStudio.Outline in '..\Shared\Markdown4DStudio.Outline.pas', + Markdown4DStudio.SplitLayout in '..\Shared\Markdown4DStudio.SplitLayout.pas', Markdown4DStudioVCL.Defines in 'Markdown4DStudioVCL.Defines.pas', Markdown4DStudio.Shell in '..\Shared\Markdown4DStudio.Shell.pas', Markdown4DStudio.CommandLine in '..\Shared\Markdown4DStudio.CommandLine.pas', diff --git a/Examples/Shared/Markdown4DStudio.Defines.pas b/Examples/Shared/Markdown4DStudio.Defines.pas index 6116a62..b25a17b 100644 --- a/Examples/Shared/Markdown4DStudio.Defines.pas +++ b/Examples/Shared/Markdown4DStudio.Defines.pas @@ -55,6 +55,9 @@ interface SeparatorWidth = 1; FindEditWidth = 160; TocPanelWidth = 240; + // Neither half of the split view may be squeezed below this, whether by a + // remembered width, a window resize or a splitter drag. + MinPaneWidth = 300; TickIntervalMilliseconds = 100; FindBarHeight = 32; FindBarEditWidth = 240; diff --git a/Examples/Shared/Markdown4DStudio.SplitLayout.pas b/Examples/Shared/Markdown4DStudio.SplitLayout.pas new file mode 100644 index 0000000..22f1ec0 --- /dev/null +++ b/Examples/Shared/Markdown4DStudio.SplitLayout.pas @@ -0,0 +1,63 @@ +unit Markdown4DStudio.SplitLayout; + +// Geometry for the editor/preview split, deliberately free of any framework type +// so the rule that neither pane may collapse is testable without a form and is +// shared by the VCL and FMX studios. + +interface + +type + TPadSplitLayout = record + public + /// + /// Editor width to apply for the window as it is now. + /// + /// + /// Editor width remembered from the last time split view was active. It is + /// an absolute pixel value, so it can outlive the window size it was taken + /// at and ask for more room than there is. + /// + /// + /// What the editor and the preview share, with the contents pane and every + /// splitter already subtracted. + /// + /// + /// The least either pane may become. When the available width cannot hold + /// two of these, the space is shared evenly instead. + /// + class function ClampEditorWidth(const SavedWidth, AvailableWidth, + MinPaneWidth: Integer): Integer; static; + + /// + /// The minimum a splitter can actually enforce for the window as it is + /// now. A splitter asked to honour a minimum the window cannot give lets a + /// drag hand the whole area to one pane, so below two minimums the answer + /// is half of what there is. + /// + class function EffectiveMinPaneWidth(const AvailableWidth, + MinPaneWidth: Integer): Integer; static; + end; + +implementation + +uses + System.Math; + +class function TPadSplitLayout.ClampEditorWidth(const SavedWidth, AvailableWidth, + MinPaneWidth: Integer): Integer; +begin + // Too narrow to honour both minimums: share what there is rather than hand the + // whole area to one pane and leave the other invisible. + if AvailableWidth <= 2 * MinPaneWidth then + Exit(Max(0, AvailableWidth div 2)); + + Result := EnsureRange(SavedWidth, MinPaneWidth, AvailableWidth - MinPaneWidth); +end; + +class function TPadSplitLayout.EffectiveMinPaneWidth(const AvailableWidth, + MinPaneWidth: Integer): Integer; +begin + Result := Min(MinPaneWidth, Max(0, AvailableWidth div 2)); +end; + +end. diff --git a/Tests/Markdown4D.Tests.dpr b/Tests/Markdown4D.Tests.dpr index 5456fa0..ebbae24 100644 --- a/Tests/Markdown4D.Tests.dpr +++ b/Tests/Markdown4D.Tests.dpr @@ -89,6 +89,7 @@ uses Markdown4DStudio.Defines in '..\Examples\Shared\Markdown4DStudio.Defines.pas', Markdown4DStudio.Text in '..\Examples\Shared\Markdown4DStudio.Text.pas', Markdown4DStudio.Outline in '..\Examples\Shared\Markdown4DStudio.Outline.pas', + Markdown4DStudio.SplitLayout in '..\Examples\Shared\Markdown4DStudio.SplitLayout.pas', Markdown4DStudio.CommandLine in '..\Examples\Shared\Markdown4DStudio.CommandLine.pas', Markdown4DStudio.Shell in '..\Examples\Shared\Markdown4DStudio.Shell.pas', Markdown4DStudio.EditorView in '..\Examples\Shared\Markdown4DStudio.EditorView.pas', @@ -105,6 +106,7 @@ uses Markdown4DStudio.Controller.Tests in 'Markdown4DStudio.Controller.Tests.pas', Markdown4DStudio.HtmlExport.Tests in 'Markdown4DStudio.HtmlExport.Tests.pas', Markdown4DStudio.LinkPolicy.Tests in 'Markdown4DStudio.LinkPolicy.Tests.pas', + Markdown4DStudio.SplitLayout.Tests in 'Markdown4DStudio.SplitLayout.Tests.pas', Markdown4D.Text.UrlSafety.Tests in 'Markdown4D.Text.UrlSafety.Tests.pas', Markdown4D.Viewer.ImageSettings.Tests in 'Markdown4D.Viewer.ImageSettings.Tests.pas', StreamingMarkdown.Demo.Tests in 'StreamingMarkdown.Demo.Tests.pas', diff --git a/Tests/Markdown4DStudio.SplitLayout.Tests.pas b/Tests/Markdown4DStudio.SplitLayout.Tests.pas new file mode 100644 index 0000000..e51562c --- /dev/null +++ b/Tests/Markdown4DStudio.SplitLayout.Tests.pas @@ -0,0 +1,118 @@ +unit Markdown4DStudio.SplitLayout.Tests; + +interface + +uses + DUnitX.TestFramework, + Markdown4DStudio.SplitLayout; + +type + [TestFixture] + TPadSplitLayoutTests = class + private + const + // A pane narrower than this is not worth showing, so the split always + // leaves at least this much for each side. + MinPane = 300; + + public + [Test] + procedure ClampEditorWidth_SavedWidthLeavesRoomForBothPanes_KeepsSavedWidth; + + [Test] + procedure ClampEditorWidth_SavedWidthWouldCollapsePreview_ReservesPreviewMinimum; + + [Test] + procedure ClampEditorWidth_SavedWidthBelowMinimum_RaisesToMinimum; + + [Test] + procedure ClampEditorWidth_AvailableTooSmallForTwoPanes_SplitsEvenly; + + [Test] + procedure ClampEditorWidth_WidthSavedAtWiderWindow_StillShowsPreview; + + [Test] + procedure ClampEditorWidth_NoAvailableWidth_ReturnsZero; + + [Test] + procedure EffectiveMinPaneWidth_RoomForBothPanes_ReturnsRequestedMinimum; + + [Test] + procedure EffectiveMinPaneWidth_NotEnoughRoom_ReturnsHalfOfWhatThereIs; + + [Test] + procedure EffectiveMinPaneWidth_NoAvailableWidth_ReturnsZero; + end; + +implementation + +procedure TPadSplitLayoutTests.ClampEditorWidth_SavedWidthLeavesRoomForBothPanes_KeepsSavedWidth; +begin + // 480 for the editor leaves 480 for the preview, so nothing needs adjusting. + const Width = TPadSplitLayout.ClampEditorWidth(480, 960, MinPane); + + Assert.AreEqual(480, Width); +end; + +procedure TPadSplitLayoutTests.ClampEditorWidth_SavedWidthWouldCollapsePreview_ReservesPreviewMinimum; +begin + // The editor asks for everything; the preview must still get its minimum. + const Width = TPadSplitLayout.ClampEditorWidth(960, 960, MinPane); + + Assert.AreEqual(660, Width); +end; + +procedure TPadSplitLayoutTests.ClampEditorWidth_SavedWidthBelowMinimum_RaisesToMinimum; +begin + const Width = TPadSplitLayout.ClampEditorWidth(20, 960, MinPane); + + Assert.AreEqual(MinPane, Width); +end; + +procedure TPadSplitLayoutTests.ClampEditorWidth_AvailableTooSmallForTwoPanes_SplitsEvenly; +begin + // Below two minimums there is no way to honour both, so share what there is. + const Width = TPadSplitLayout.ClampEditorWidth(500, 500, MinPane); + + Assert.AreEqual(250, Width); +end; + +procedure TPadSplitLayoutTests.ClampEditorWidth_WidthSavedAtWiderWindow_StillShowsPreview; +begin + // Regression for the collapsing split view: an editor width remembered at a + // 1200 wide window is applied after the window shrinks to 900. Unclamped it + // swallowed the whole area and the preview disappeared, which made split view + // look exactly like editor only. + const Width = TPadSplitLayout.ClampEditorWidth(838, 650, MinPane); + + Assert.AreEqual(350, Width); + Assert.IsTrue(650 - Width >= MinPane, 'preview pane must keep its minimum width'); +end; + +procedure TPadSplitLayoutTests.ClampEditorWidth_NoAvailableWidth_ReturnsZero; +begin + // A window too small to lay out at all must not produce a negative width. + const Width = TPadSplitLayout.ClampEditorWidth(480, 0, MinPane); + + Assert.AreEqual(0, Width); +end; + +procedure TPadSplitLayoutTests.EffectiveMinPaneWidth_RoomForBothPanes_ReturnsRequestedMinimum; +begin + Assert.AreEqual(MinPane, TPadSplitLayout.EffectiveMinPaneWidth(960, MinPane)); +end; + +procedure TPadSplitLayoutTests.EffectiveMinPaneWidth_NotEnoughRoom_ReturnsHalfOfWhatThereIs; +begin + // A splitter cannot be asked to honour a minimum the window cannot give, or a + // drag ends up handing the whole area to one pane. + Assert.AreEqual(250, TPadSplitLayout.EffectiveMinPaneWidth(500, MinPane)); + Assert.AreEqual(224, TPadSplitLayout.EffectiveMinPaneWidth(448, MinPane)); +end; + +procedure TPadSplitLayoutTests.EffectiveMinPaneWidth_NoAvailableWidth_ReturnsZero; +begin + Assert.AreEqual(0, TPadSplitLayout.EffectiveMinPaneWidth(0, MinPane)); +end; + +end. From 1ff959391304e49289655d7a388ce10ca83142e9 Mon Sep 17 00:00:00 2001 From: Roland Bengtsson Date: Wed, 16 Sep 2026 20:26:35 +0300 Subject: [PATCH 2/2] fix: stop the contents pane starving the split view The clamp covered four routes but not the contents divider, which is a separate control. Dragging it right grew the contents pane, shrank the area the two halves share and left the preview at 36 pixels, far below the minimum. Narrowing the window while the contents pane was wide did the same. Give the contents pane a bounded width through the new ClampSidePanelWidth, applied wherever the split is laid out. The rule is an order of priority: the contents pane gives way first so the editor and preview keep their minimum, but never below its own minimum of 120, past which the two halves share what is left between them. A contents pane the user already dragged narrow is never widened by the clamp. Both studios hook their contents divider as well, the VCL through OnMoved and FMX through the splitter mouse up. --- .../Markdown4DStudioFMX.Main.pas | 23 ++++++++++ .../Markdown4DStudioVCL.Main.pas | 20 ++++++++ Examples/Shared/Markdown4DStudio.Defines.pas | 3 ++ .../Shared/Markdown4DStudio.SplitLayout.pas | 25 ++++++++++ Tests/Markdown4DStudio.SplitLayout.Tests.pas | 46 +++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas index 40946d1..5c3726e 100644 --- a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas +++ b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas @@ -201,6 +201,8 @@ TMarkdown4DStudioFMXForm = class(TForm, IPadEditorView, IPadShell) procedure ApplySplitEditorWidth(const DesiredWidth: Single); procedure HandleSplitterMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Single); + procedure HandleTocSplitterMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Single); procedure ShowFindBar; procedure ShowReplaceBar; procedure CloseFindBar; @@ -747,6 +749,7 @@ procedure TMarkdown4DStudioFMXForm.BuildTocPanel; FTocSplitter.Parent := Self; FTocSplitter.Align := TAlignLayout.Left; FTocSplitter.Width := SplitterWidth; + FTocSplitter.OnMouseUp := HandleTocSplitterMouseUp; end; procedure TMarkdown4DStudioFMXForm.BuildEditorAndPreview; @@ -1212,6 +1215,14 @@ procedure TMarkdown4DStudioFMXForm.ApplySplitEditorWidth(const DesiredWidth: Sin // Entering split view asks for the remembered width; a resize or a contents // pane toggle asks for the width the editor already has, so that a splitter // drag is kept and only trimmed when it no longer fits. + + // The contents pane gives way first, so the two halves keep their minimum for + // as long as the window allows. + if FTocPanel.Visible then + FTocPanel.Width := TPadSplitLayout.ClampSidePanelWidth(Round(FTocPanel.Width), + Round(ClientWidth), Round(FTocSplitter.Width + FMainSplitter.Width), + MinPaneWidth, MinTocPanelWidth); + const Available = Round(AvailableSplitWidth); // Re-stated on every width change, because a splitter told to honour a @@ -1232,6 +1243,18 @@ procedure TMarkdown4DStudioFMXForm.HandleSplitterMouseUp(Sender: TObject; FSplitEditorWidth := FEditor.Width; end; +procedure TMarkdown4DStudioFMXForm.HandleTocSplitterMouseUp(Sender: TObject; + Button: TMouseButton; Shift: TShiftState; X, Y: Single); +begin + // Widening the contents pane takes room from the two halves, so it stops + // where they would lose their minimum. Only split view reserves room for + // two panes; the single pane modes have nothing to protect. + if FZenActive or (FViewMode <> TPadViewMode.Split) then + Exit; + + ApplySplitEditorWidth(FEditor.Width); +end; + procedure TMarkdown4DStudioFMXForm.ApplyViewMode; begin case FViewMode of diff --git a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas index 780ea62..4ab84e2 100644 --- a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas +++ b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas @@ -248,6 +248,7 @@ TPadChrome = record function AvailableSplitWidth: Integer; procedure ApplySplitEditorWidth(const DesiredWidth: Integer); procedure HandleSplitterMoved(Sender: TObject); + procedure HandleTocSplitterMoved(Sender: TObject); procedure EnforceTopBarOrder; procedure EnforceLeftPaneOrder; procedure ShowFindBar; @@ -374,6 +375,7 @@ procedure TMarkdown4DStudioVCLForm.ConfigureControls; lblFindCount.Caption := EmptyFindCaption; splMain.OnMoved := HandleSplitterMoved; + splToc.OnMoved := HandleTocSplitterMoved; end; procedure TMarkdown4DStudioVCLForm.BuildToolbar; @@ -1742,6 +1744,13 @@ procedure TMarkdown4DStudioVCLForm.ApplySplitEditorWidth(const DesiredWidth: Int // Entering split view asks for the remembered width; a resize or a contents // pane toggle asks for the width the editor already has, so that a splitter // drag is kept and only trimmed when it no longer fits. + + // The contents pane gives way first, so the two halves keep their minimum for + // as long as the window allows. + if pnlToc.Visible then + pnlToc.Width := TPadSplitLayout.ClampSidePanelWidth(pnlToc.Width, ClientWidth, + splToc.Width + splMain.Width, MinPaneWidth, MinTocPanelWidth); + const Available = AvailableSplitWidth; // Re-stated on every width change, because a splitter told to honour a @@ -1761,6 +1770,17 @@ procedure TMarkdown4DStudioVCLForm.HandleSplitterMoved(Sender: TObject); FSplitEditorWidth := mdEditor.Width; end; +procedure TMarkdown4DStudioVCLForm.HandleTocSplitterMoved(Sender: TObject); +begin + // Widening the contents pane takes room from the two halves, so it stops + // where they would lose their minimum. Only split view reserves room for + // two panes; the single pane modes have nothing to protect. + if FZenActive or (FViewMode <> TPadViewMode.Split) then + Exit; + + ApplySplitEditorWidth(mdEditor.Width); +end; + procedure TMarkdown4DStudioVCLForm.ApplyViewMode; begin case FViewMode of diff --git a/Examples/Shared/Markdown4DStudio.Defines.pas b/Examples/Shared/Markdown4DStudio.Defines.pas index b25a17b..a75fb86 100644 --- a/Examples/Shared/Markdown4DStudio.Defines.pas +++ b/Examples/Shared/Markdown4DStudio.Defines.pas @@ -58,6 +58,9 @@ interface // Neither half of the split view may be squeezed below this, whether by a // remembered width, a window resize or a splitter drag. MinPaneWidth = 300; + // The contents pane gives way first to protect those two, but never below + // this; past that point the two halves share what is left between them. + MinTocPanelWidth = 120; TickIntervalMilliseconds = 100; FindBarHeight = 32; FindBarEditWidth = 240; diff --git a/Examples/Shared/Markdown4DStudio.SplitLayout.pas b/Examples/Shared/Markdown4DStudio.SplitLayout.pas index 22f1ec0..eb506a7 100644 --- a/Examples/Shared/Markdown4DStudio.SplitLayout.pas +++ b/Examples/Shared/Markdown4DStudio.SplitLayout.pas @@ -36,6 +36,20 @@ TPadSplitLayout = record /// class function EffectiveMinPaneWidth(const AvailableWidth, MinPaneWidth: Integer): Integer; static; + + /// + /// How wide a side panel, such as the contents pane, may be dragged before + /// it starts eating into the two halves of the split. + /// + /// The whole client width. + /// Width taken by the splitters themselves. + /// + /// How far the side panel may be asked to give way. Past that point it has + /// done all it can and the two panes share what is left between them. A + /// panel already narrower than this is never widened. + /// + class function ClampSidePanelWidth(const DesiredWidth, TotalWidth, + ReservedWidth, MinPaneWidth, MinSidePanelWidth: Integer): Integer; static; end; implementation @@ -60,4 +74,15 @@ class function TPadSplitLayout.EffectiveMinPaneWidth(const AvailableWidth, Result := Min(MinPaneWidth, Max(0, AvailableWidth div 2)); end; +class function TPadSplitLayout.ClampSidePanelWidth(const DesiredWidth, TotalWidth, + ReservedWidth, MinPaneWidth, MinSidePanelWidth: Integer): Integer; +begin + // What the side panel could take while both panes still keep their minimum. + const Allowed = Max(0, TotalWidth - ReservedWidth - 2 * MinPaneWidth); + + // Never widen the panel, only ask it to give way, and never past its own + // minimum: below that the two panes share what is left instead. + Result := Min(DesiredWidth, Max(Allowed, MinSidePanelWidth)); +end; + end. diff --git a/Tests/Markdown4DStudio.SplitLayout.Tests.pas b/Tests/Markdown4DStudio.SplitLayout.Tests.pas index e51562c..db76273 100644 --- a/Tests/Markdown4DStudio.SplitLayout.Tests.pas +++ b/Tests/Markdown4DStudio.SplitLayout.Tests.pas @@ -14,6 +14,8 @@ TPadSplitLayoutTests = class // A pane narrower than this is not worth showing, so the split always // leaves at least this much for each side. MinPane = 300; + // The contents pane yields to protect those two, but not below this. + MinSide = 120; public [Test] @@ -42,6 +44,18 @@ TPadSplitLayoutTests = class [Test] procedure EffectiveMinPaneWidth_NoAvailableWidth_ReturnsZero; + + [Test] + procedure ClampSidePanelWidth_LeavesRoomForBothPanes_KeepsRequestedWidth; + + [Test] + procedure ClampSidePanelWidth_WouldStarveThePanes_StopsAtWhatTheyNeed; + + [Test] + procedure ClampSidePanelWidth_WindowTooSmallForBothPanes_StopsAtItsOwnMinimum; + + [Test] + procedure ClampSidePanelWidth_AlreadyNarrowerThanItsMinimum_IsLeftAlone; end; implementation @@ -115,4 +129,36 @@ procedure TPadSplitLayoutTests.EffectiveMinPaneWidth_NoAvailableWidth_ReturnsZer Assert.AreEqual(0, TPadSplitLayout.EffectiveMinPaneWidth(0, MinPane)); end; +procedure TPadSplitLayoutTests.ClampSidePanelWidth_LeavesRoomForBothPanes_KeepsRequestedWidth; +begin + // 1100 wide, 8 for the two splitters, so the contents pane may grow to 492 + // before the editor and preview lose their 300 each. + Assert.AreEqual(240, TPadSplitLayout.ClampSidePanelWidth(240, 1100, 8, MinPane, MinSide)); + Assert.AreEqual(492, TPadSplitLayout.ClampSidePanelWidth(492, 1100, 8, MinPane, MinSide)); +end; + +procedure TPadSplitLayoutTests.ClampSidePanelWidth_WouldStarveThePanes_StopsAtWhatTheyNeed; +begin + // Regression: dragging the contents divider right used to squeeze the preview + // down to a sliver, because nothing bounded how far the contents pane grew. + Assert.AreEqual(492, TPadSplitLayout.ClampSidePanelWidth(800, 1100, 8, MinPane, MinSide)); + // The same rule makes the contents pane give way when the window narrows. + Assert.AreEqual(192, TPadSplitLayout.ClampSidePanelWidth(484, 800, 8, MinPane, MinSide)); +end; + +procedure TPadSplitLayoutTests.ClampSidePanelWidth_WindowTooSmallForBothPanes_StopsAtItsOwnMinimum; +begin + // Past this point the contents pane has given all it can, and the two panes + // share what is left between them instead. + Assert.AreEqual(MinSide, TPadSplitLayout.ClampSidePanelWidth(484, 700, 8, MinPane, MinSide)); + Assert.AreEqual(MinSide, TPadSplitLayout.ClampSidePanelWidth(484, 400, 8, MinPane, MinSide)); +end; + +procedure TPadSplitLayoutTests.ClampSidePanelWidth_AlreadyNarrowerThanItsMinimum_IsLeftAlone; +begin + // A contents pane the user dragged narrow is never widened by the clamp. + Assert.AreEqual(60, TPadSplitLayout.ClampSidePanelWidth(60, 1100, 8, MinPane, MinSide)); + Assert.AreEqual(60, TPadSplitLayout.ClampSidePanelWidth(60, 400, 8, MinPane, MinSide)); +end; + end.