From 0331b40ae2c000cff478fc21f183ca66534dee76 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 30 Apr 2026 15:53:32 +0200 Subject: [PATCH] fix: persist layout panel widths --- docs/ABSTRACTIONS.md | 2 + docs/ARCHITECTURE.md | 8 +- src/App.css | 8 +- src/App.test.tsx | 2 +- src/App.tsx | 8 +- src/constants/appStorage.ts | 3 + src/hooks/useLayoutPanels.test.ts | 98 +++++++++++++++- src/hooks/useLayoutPanels.ts | 106 ++++++++++++++++-- .../useMainWindowSizeConstraints.test.ts | 16 ++- src/hooks/useMainWindowSizeConstraints.ts | 26 ++++- 10 files changed, 245 insertions(+), 32 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 163eeca1..1f3e15f8 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -702,6 +702,8 @@ Per-vault settings stored locally and scoped by vault path: - Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run - One-time migration from localStorage (`configMigration.ts`) +Installation-local layout state that should not sync through a vault stays in localStorage. `useLayoutPanels` stores the clamped sidebar, note-list, and inspector widths under `tolaria:layout-panels` so pane sizing survives app relaunches on the same machine. + ### AI Guidance Files Tolaria tracks managed vault-level AI guidance separately from normal note content: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3db2d28c..6bbb9049 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,14 +203,14 @@ flowchart TD └──────────────────────────────────────────────────────────────┘ ``` -- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The title bar exposes Back/Forward buttons wired to the same note navigation history as the command palette, menu accelerators, and mouse navigation buttons. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. -- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. +- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The title bar exposes Back/Forward buttons wired to the same note navigation history as the command palette, menu accelerators, and mouse navigation buttons. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. +- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). -Panels are separated by `ResizeHandle` components that support drag-to-resize. +Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. -The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing. +The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing. The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline. diff --git a/src/App.css b/src/App.css index fec7e946..6e6aa205 100644 --- a/src/App.css +++ b/src/App.css @@ -16,8 +16,8 @@ } .app__sidebar { - flex-shrink: 1; - min-width: 180px; + flex: 0 0 auto; + min-width: 220px; display: flex; flex-direction: column; } @@ -27,7 +27,7 @@ } .app__note-list { - flex-shrink: 10; + flex: 0 0 auto; min-width: 220px; display: flex; flex-direction: column; @@ -39,7 +39,7 @@ .app__editor { flex: 1; - min-width: 400px; + min-width: 480px; min-height: 0; overflow: hidden; display: flex; diff --git a/src/App.test.tsx b/src/App.test.tsx index 3f7e038f..f2b784a1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1328,7 +1328,7 @@ describe('App', () => { fireEvent.keyDown(window, { key: '3', metaKey: true }) await waitFor(() => { expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', { - minWidth: 880, + minWidth: 1030, minHeight: 400, growToFit: true, }) diff --git a/src/App.tsx b/src/App.tsx index 464cf5db..72a21e1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1217,10 +1217,13 @@ function App() { sidebarVisible: nextSidebarVisible, noteListVisible: nextNoteListVisible, inspectorCollapsed: nextInspectorCollapsed, + sidebarWidth: layout.sidebarWidth, + noteListWidth: layout.noteListWidth, + inspectorWidth: layout.inspectorWidth, }) void applyMainWindowSizeConstraints(minWidth).catch((err) => console.warn('[window] Size constraints failed:', err)) - }, [layout.inspectorCollapsed, noteWindowParams]) + }, [layout.inspectorCollapsed, layout.inspectorWidth, layout.noteListWidth, layout.sidebarWidth, noteWindowParams]) const handleSetViewMode = useCallback((mode: ViewMode) => { setViewMode(mode) @@ -1243,6 +1246,9 @@ function App() { sidebarVisible, noteListVisible, inspectorCollapsed: layout.inspectorCollapsed, + sidebarWidth: layout.sidebarWidth, + noteListWidth: layout.noteListWidth, + inspectorWidth: layout.inspectorWidth, }) const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel) diff --git a/src/constants/appStorage.ts b/src/constants/appStorage.ts index 1ad8b987..91a95c5b 100644 --- a/src/constants/appStorage.ts +++ b/src/constants/appStorage.ts @@ -9,6 +9,7 @@ export const APP_STORAGE_KEYS = { legacyMigrationFlag: 'tolaria:legacy-storage-migrated', sortPreferences: 'tolaria-sort-preferences', sidebarCollapsed: 'tolaria:sidebar-collapsed', + layoutPanels: 'tolaria:layout-panels', welcomeDismissed: 'tolaria_welcome_dismissed', } as const @@ -22,6 +23,7 @@ export const LEGACY_APP_STORAGE_KEYS = { configMigrationFlag: 'laputa:config-migrated-to-vault', sortPreferences: 'laputa-sort-preferences', sidebarCollapsed: 'laputa:sidebar-collapsed', + layoutPanels: 'laputa:layout-panels', welcomeDismissed: 'laputa_welcome_dismissed', } as const @@ -37,6 +39,7 @@ const MIGRATABLE_STORAGE_KEYS: MigratableStorageKey[] = [ 'configMigrationFlag', 'sortPreferences', 'sidebarCollapsed', + 'layoutPanels', 'welcomeDismissed', ] diff --git a/src/hooks/useLayoutPanels.test.ts b/src/hooks/useLayoutPanels.test.ts index 18fde69f..abb79a4c 100644 --- a/src/hooks/useLayoutPanels.test.ts +++ b/src/hooks/useLayoutPanels.test.ts @@ -1,10 +1,46 @@ -import { describe, it, expect } from 'vitest' +import { beforeEach, describe, it, expect } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useLayoutPanels, COLUMN_MIN_WIDTHS } from './useLayoutPanels' +import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +type ExpectedPanelWidths = { + sidebar: number + noteList: number + inspector: number +} + +function storePanelWidths(key: string, widths: ExpectedPanelWidths): void { + localStorage.setItem(key, JSON.stringify(widths)) +} + +function expectPanelWidths( + result: { current: ReturnType }, + widths: ExpectedPanelWidths, +): void { + expect(result.current.sidebarWidth).toBe(widths.sidebar) + expect(result.current.noteListWidth).toBe(widths.noteList) + expect(result.current.inspectorWidth).toBe(widths.inspector) +} describe('useLayoutPanels', () => { + beforeEach(() => { + localStorage.clear() + }) + it('exports column minimum widths', () => { - expect(COLUMN_MIN_WIDTHS.sidebar).toBe(180) + expect(COLUMN_MIN_WIDTHS.sidebar).toBe(220) expect(COLUMN_MIN_WIDTHS.noteList).toBe(220) expect(COLUMN_MIN_WIDTHS.editor).toBe(800) expect(COLUMN_MIN_WIDTHS.inspector).toBe(240) @@ -12,9 +48,7 @@ describe('useLayoutPanels', () => { it('returns default widths', () => { const { result } = renderHook(() => useLayoutPanels()) - expect(result.current.sidebarWidth).toBe(250) - expect(result.current.noteListWidth).toBe(300) - expect(result.current.inspectorWidth).toBe(280) + expectPanelWidths(result, { sidebar: 250, noteList: 300, inspector: 280 }) }) it('clamps sidebar resize to minimum', () => { @@ -62,4 +96,58 @@ describe('useLayoutPanels', () => { const { result } = renderHook(() => useLayoutPanels({ initialInspectorCollapsed: false })) expect(result.current.inspectorCollapsed).toBe(false) }) + + it('restores persisted panel widths', () => { + storePanelWidths(APP_STORAGE_KEYS.layoutPanels, { + sidebar: 280, + noteList: 360, + inspector: 320, + }) + + const { result } = renderHook(() => useLayoutPanels()) + + expectPanelWidths(result, { sidebar: 280, noteList: 360, inspector: 320 }) + }) + + it('clamps persisted panel widths to supported ranges', () => { + storePanelWidths(APP_STORAGE_KEYS.layoutPanels, { + sidebar: 120, + noteList: 700, + inspector: 90, + }) + + const { result } = renderHook(() => useLayoutPanels()) + + expectPanelWidths(result, { + sidebar: COLUMN_MIN_WIDTHS.sidebar, + noteList: 500, + inspector: COLUMN_MIN_WIDTHS.inspector, + }) + }) + + it('falls back to defaults when persisted panel widths are malformed', () => { + localStorage.setItem(APP_STORAGE_KEYS.layoutPanels, '{not json') + + const { result } = renderHook(() => useLayoutPanels()) + + expectPanelWidths(result, { sidebar: 250, noteList: 300, inspector: 280 }) + }) + + it('persists resized panel widths with the Tolaria storage key', () => { + storePanelWidths(LEGACY_APP_STORAGE_KEYS.layoutPanels, { + sidebar: 260, + noteList: 340, + inspector: 300, + }) + + const { result } = renderHook(() => useLayoutPanels()) + act(() => result.current.handleSidebarResize(24)) + + expect(JSON.parse(localStorage.getItem(APP_STORAGE_KEYS.layoutPanels) ?? '{}')).toEqual({ + sidebar: 284, + noteList: 340, + inspector: 300, + }) + expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.layoutPanels)).toBeNull() + }) }) diff --git a/src/hooks/useLayoutPanels.ts b/src/hooks/useLayoutPanels.ts index c1310fa5..5d040704 100644 --- a/src/hooks/useLayoutPanels.ts +++ b/src/hooks/useLayoutPanels.ts @@ -1,19 +1,103 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage' export const COLUMN_MIN_WIDTHS = { - sidebar: 180, + sidebar: 220, noteList: 220, editor: 800, inspector: 240, } as const -export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) { - const [sidebarWidth, setSidebarWidth] = useState(250) - const [noteListWidth, setNoteListWidth] = useState(300) - const [inspectorWidth, setInspectorWidth] = useState(280) - const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? true) - const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(COLUMN_MIN_WIDTHS.sidebar, Math.min(400, w + delta))), []) - const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(COLUMN_MIN_WIDTHS.noteList, Math.min(500, w + delta))), []) - const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(COLUMN_MIN_WIDTHS.inspector, Math.min(500, w - delta))), []) - return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize } +const COLUMN_MAX_WIDTHS = { + sidebar: 400, + noteList: 500, + inspector: 500, +} as const + +const DEFAULT_PANEL_WIDTHS = { + sidebar: 250, + noteList: 300, + inspector: 280, +} as const + +type PanelWidthKey = keyof typeof DEFAULT_PANEL_WIDTHS +type PanelWidths = Record + +function defaultPanelWidths(): PanelWidths { + return { ...DEFAULT_PANEL_WIDTHS } +} + +function clampPanelWidth(key: PanelWidthKey, value: number): number { + return Math.max(COLUMN_MIN_WIDTHS[key], Math.min(COLUMN_MAX_WIDTHS[key], value)) +} + +function isPanelWidthRecord(value: unknown): value is Partial> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readPanelWidth(source: Partial>, key: PanelWidthKey): number { + const value = source[key] + return typeof value === 'number' && Number.isFinite(value) + ? clampPanelWidth(key, value) + : DEFAULT_PANEL_WIDTHS[key] +} + +function normalizePanelWidths(value: unknown): PanelWidths { + if (!isPanelWidthRecord(value)) return defaultPanelWidths() + return { + sidebar: readPanelWidth(value, 'sidebar'), + noteList: readPanelWidth(value, 'noteList'), + inspector: readPanelWidth(value, 'inspector'), + } +} + +function loadPanelWidths(): PanelWidths { + const raw = getAppStorageItem('layoutPanels') + if (!raw) return defaultPanelWidths() + + try { + return normalizePanelWidths(JSON.parse(raw)) + } catch { + return defaultPanelWidths() + } +} + +function savePanelWidths(widths: PanelWidths): void { + try { + localStorage.setItem(APP_STORAGE_KEYS.layoutPanels, JSON.stringify(widths)) + localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.layoutPanels) + } catch { + // Ignore unavailable or restricted localStorage implementations. + } +} + +export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) { + const [panelWidths, setPanelWidths] = useState(loadPanelWidths) + const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? true) + + useEffect(() => { + savePanelWidths(panelWidths) + }, [panelWidths]) + + const resizePanel = useCallback((key: PanelWidthKey, delta: number) => { + setPanelWidths((widths) => ({ + ...widths, + [key]: clampPanelWidth(key, widths[key] + delta), + })) + }, []) + + const handleSidebarResize = useCallback((delta: number) => resizePanel('sidebar', delta), [resizePanel]) + const handleNoteListResize = useCallback((delta: number) => resizePanel('noteList', delta), [resizePanel]) + const handleInspectorResize = useCallback((delta: number) => resizePanel('inspector', -delta), [resizePanel]) + + return { + sidebarWidth: panelWidths.sidebar, + noteListWidth: panelWidths.noteList, + inspectorWidth: panelWidths.inspector, + inspectorCollapsed, + setInspectorCollapsed, + handleSidebarResize, + handleNoteListResize, + handleInspectorResize, + } } diff --git a/src/hooks/useMainWindowSizeConstraints.test.ts b/src/hooks/useMainWindowSizeConstraints.test.ts index e2ff8ce1..7d48190d 100644 --- a/src/hooks/useMainWindowSizeConstraints.test.ts +++ b/src/hooks/useMainWindowSizeConstraints.test.ts @@ -23,7 +23,7 @@ describe('getMainWindowMinWidth', () => { noteListVisible: true, inspectorCollapsed: true, }, - expectedWidth: 880, + expectedWidth: 920, }, { name: 'drops to the narrower editor-only floor when only the editor is visible', @@ -52,6 +52,18 @@ describe('getMainWindowMinWidth', () => { }, expectedWidth: 720, }, + { + name: 'uses restored pane widths when they exceed the minimum allowances', + visibility: { + sidebarVisible: true, + noteListVisible: true, + inspectorCollapsed: false, + sidebarWidth: 360, + noteListWidth: 340, + inspectorWidth: 320, + }, + expectedWidth: 1500, + }, ] as const it.each(minWidthCases)('$name', ({ visibility, expectedWidth }) => { @@ -82,7 +94,7 @@ describe('useMainWindowSizeConstraints', () => { noteListVisible: true, inspectorCollapsed: false, }, - expectedWidth: 1120, + expectedWidth: 1160, }, ] as const diff --git a/src/hooks/useMainWindowSizeConstraints.ts b/src/hooks/useMainWindowSizeConstraints.ts index 480987fb..b6ce2a96 100644 --- a/src/hooks/useMainWindowSizeConstraints.ts +++ b/src/hooks/useMainWindowSizeConstraints.ts @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core' const MAIN_WINDOW_MIN_HEIGHT = 400 const EDITOR_ONLY_MAIN_WINDOW_MIN_WIDTH = 480 -const MAIN_WINDOW_SIDEBAR_MIN_WIDTH = 180 +const MAIN_WINDOW_SIDEBAR_MIN_WIDTH = 220 const MAIN_WINDOW_NOTE_LIST_MIN_WIDTH = 220 const MAIN_WINDOW_INSPECTOR_MIN_WIDTH = 240 @@ -11,22 +11,34 @@ export type MainWindowPaneVisibility = { sidebarVisible: boolean noteListVisible: boolean inspectorCollapsed: boolean + sidebarWidth?: number + noteListWidth?: number + inspectorWidth?: number } export function getMainWindowMinWidth({ sidebarVisible, noteListVisible, inspectorCollapsed, + sidebarWidth, + noteListWidth, + inspectorWidth, }: MainWindowPaneVisibility): number { let minWidth = EDITOR_ONLY_MAIN_WINDOW_MIN_WIDTH - if (sidebarVisible) minWidth += MAIN_WINDOW_SIDEBAR_MIN_WIDTH - if (noteListVisible) minWidth += MAIN_WINDOW_NOTE_LIST_MIN_WIDTH - if (!inspectorCollapsed) minWidth += MAIN_WINDOW_INSPECTOR_MIN_WIDTH + if (sidebarVisible) minWidth += getPaneWidth(sidebarWidth, MAIN_WINDOW_SIDEBAR_MIN_WIDTH) + if (noteListVisible) minWidth += getPaneWidth(noteListWidth, MAIN_WINDOW_NOTE_LIST_MIN_WIDTH) + if (!inspectorCollapsed) minWidth += getPaneWidth(inspectorWidth, MAIN_WINDOW_INSPECTOR_MIN_WIDTH) return minWidth } +function getPaneWidth(width: number | undefined, minimum: number): number { + return typeof width === 'number' && Number.isFinite(width) + ? Math.max(minimum, Math.round(width)) + : minimum +} + type MainWindowSizeConstraintsOptions = MainWindowPaneVisibility & { enabled?: boolean } @@ -46,11 +58,17 @@ export function useMainWindowSizeConstraints({ sidebarVisible, noteListVisible, inspectorCollapsed, + sidebarWidth, + noteListWidth, + inspectorWidth, }: MainWindowSizeConstraintsOptions): void { const minWidth = getMainWindowMinWidth({ sidebarVisible, noteListVisible, inspectorCollapsed, + sidebarWidth, + noteListWidth, + inspectorWidth, }) useEffect(() => {