diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs
index 2163a9b..93a2041 100644
--- a/crates/store/src/lib.rs
+++ b/crates/store/src/lib.rs
@@ -18,7 +18,7 @@ mod text_utils;
pub use settings::StyleCheckSettings;
pub use settings::UiLayoutSettings;
-pub use settings::{CaptureDocRef, CaptureMode, GlobalCaptureSettings};
+pub use settings::{CaptureDocRef, CaptureMode, FocusDimmingMode, GlobalCaptureSettings};
const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout";
const STYLE_CHECK_SETTINGS_KEY: &str = "style_check";
@@ -1495,6 +1495,11 @@ mod tests {
syntax_highlighting_enabled: false,
editor_font_size: 18,
editor_font_family: "Monaspace Neon".to_string(),
+ calm_ui_enabled: false,
+ calm_ui_auto_hide: false,
+ calm_ui_focus_mode: false,
+ focus_typewriter_scrolling_enabled: false,
+ focus_dimming_mode: FocusDimmingMode::Paragraph,
};
store.ui_layout_set(&settings).unwrap();
@@ -1528,6 +1533,11 @@ mod tests {
assert!(loaded.syntax_highlighting_enabled);
assert_eq!(loaded.editor_font_size, 16);
assert_eq!(loaded.editor_font_family, "IBM Plex Mono");
+ assert!(loaded.calm_ui_enabled);
+ assert!(loaded.calm_ui_auto_hide);
+ assert!(loaded.calm_ui_focus_mode);
+ assert!(loaded.focus_typewriter_scrolling_enabled);
+ assert_eq!(loaded.focus_dimming_mode, FocusDimmingMode::Sentence);
}
#[test]
diff --git a/crates/store/src/settings.rs b/crates/store/src/settings.rs
index 7bc75b8..33b994f 100644
--- a/crates/store/src/settings.rs
+++ b/crates/store/src/settings.rs
@@ -21,6 +21,35 @@ fn default_inbox_dir() -> String {
"inbox".to_string()
}
+fn default_calm_ui_enabled() -> bool {
+ true
+}
+
+fn default_calm_ui_auto_hide() -> bool {
+ true
+}
+
+fn default_calm_ui_focus_mode() -> bool {
+ true
+}
+
+fn default_focus_typewriter_scrolling_enabled() -> bool {
+ true
+}
+
+fn default_focus_dimming_mode() -> FocusDimmingMode {
+ FocusDimmingMode::Sentence
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
+#[serde(rename_all = "lowercase")]
+pub enum FocusDimmingMode {
+ Off,
+ #[default]
+ Sentence,
+ Paragraph,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StyleCheckCategorySettings {
pub filler: bool,
@@ -59,6 +88,16 @@ pub struct UiLayoutSettings {
pub editor_font_size: u16,
#[serde(default = "default_editor_font_family")]
pub editor_font_family: String,
+ #[serde(default = "default_calm_ui_enabled")]
+ pub calm_ui_enabled: bool,
+ #[serde(default = "default_calm_ui_auto_hide")]
+ pub calm_ui_auto_hide: bool,
+ #[serde(default = "default_calm_ui_focus_mode")]
+ pub calm_ui_focus_mode: bool,
+ #[serde(default = "default_focus_typewriter_scrolling_enabled")]
+ pub focus_typewriter_scrolling_enabled: bool,
+ #[serde(default = "default_focus_dimming_mode")]
+ pub focus_dimming_mode: FocusDimmingMode,
}
impl Default for UiLayoutSettings {
@@ -72,6 +111,11 @@ impl Default for UiLayoutSettings {
syntax_highlighting_enabled: true,
editor_font_size: default_editor_font_size(),
editor_font_family: default_editor_font_family(),
+ calm_ui_enabled: default_calm_ui_enabled(),
+ calm_ui_auto_hide: default_calm_ui_auto_hide(),
+ calm_ui_focus_mode: default_calm_ui_focus_mode(),
+ focus_typewriter_scrolling_enabled: default_focus_typewriter_scrolling_enabled(),
+ focus_dimming_mode: default_focus_dimming_mode(),
}
}
}
diff --git a/fixtures/markdown/basic.html b/fixtures/markdown/basic.html
index 6569299..3325e75 100644
--- a/fixtures/markdown/basic.html
+++ b/fixtures/markdown/basic.html
@@ -1,45 +1,39 @@
-
Test Document
-This is a paragraph with bold and italic text.
- Section
- 1
+ Test Document
+This is a paragraph with bold and italic text.
+ Section 1
Here is some code:
fn main() {
println!("Hello, world!");
}
- Subsection
+ Subsection
- Item 1
- Item 2
- Item 3
+Item 1
+Item 2
+Item 3
- Section 2
+ Section 2
-
-
- Name
- Value
-
-
-
-
- A
- 1
-
-
- B
- 2
-
-
+
+
+Name
+Value
+
+
+
+
+A
+1
+
+
+B
+2
+
+
Strikethrough text
-Visit https://example.com
- for more info.
+Visit https://example.com for more info.
\ No newline at end of file
diff --git a/fixtures/render/xss-safety.html b/fixtures/render/xss-safety.html
index 592cc87..7012ec7 100644
--- a/fixtures/render/xss-safety.html
+++ b/fixtures/render/xss-safety.html
@@ -1,7 +1,6 @@
- XSS Test
-
+ XSS Test
This tests that raw HTML is properly escaped or filtered.
<img src=x onerror=alert('XSS')>
Click me
-
+
\ No newline at end of file
diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs
index aa6556b..8b7e1a8 100644
--- a/src-tauri/src/capture.rs
+++ b/src-tauri/src/capture.rs
@@ -12,7 +12,6 @@ use writer_store::{CaptureMode, GlobalCaptureSettings};
const QUICK_CAPTURE_WINDOW_LABEL: &str = "quick_capture";
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_ICON_ID: &str = "global_capture_tray";
-
const MENU_ITEM_NEW_QUICK_NOTE: &str = "capture.new_quick_note";
const MENU_ITEM_OPEN_WRITER: &str = "capture.open_writer";
const MENU_ITEM_TOGGLE_SHORTCUT: &str = "capture.toggle_shortcut";
@@ -109,7 +108,11 @@ pub fn reconcile_shortcut_registration(app: &AppHandle, settings: &GlobalCapture
}
fn tray_pause_label(paused: bool) -> &'static str {
- if paused { "Resume Shortcut" } else { "Pause Shortcut" }
+ if paused {
+ "Resume Shortcut"
+ } else {
+ "Pause Shortcut"
+ }
}
fn tray_shortcut_status(settings: &GlobalCaptureSettings) -> String {
diff --git a/src/App.tsx b/src/App.tsx
index 50d1ac8..3f8b6b6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,8 +1,9 @@
import { logger } from "$logger";
import type { PdfExportOptions, PdfRenderResult } from "$pdf/types";
import { renderMarkdownForPdf, runCmd, styleCheckGet, styleCheckSet, uiLayoutGet, uiLayoutSet } from "$ports";
+import { stateToLayoutSettings, uiSettingsToCalmUI, uiSettingsToFocusMode } from "$state/helpers";
import type { DocMeta, DocRef, Tab } from "$types";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppHeaderBar } from "./components/layout/AppHeaderBar";
import { BackendAlerts } from "./components/layout/BackendAlerts";
import { FocusModePanel } from "./components/layout/FocusModePanel";
@@ -16,6 +17,7 @@ import { useLayoutHotkeys } from "./hooks/useLayoutHotkeys";
import { usePdfExport } from "./hooks/usePdfExport";
import { usePreview } from "./hooks/usePreview";
import { useSearchController } from "./hooks/useSearchController";
+import { useTypingActivity } from "./hooks/useTypingActivity";
import { useWorkspaceController } from "./hooks/useWorkspaceController";
import { useWorkspaceSync } from "./hooks/useWorkspaceSync";
import {
@@ -25,6 +27,7 @@ import {
useLayoutChromeState,
usePdfExportActions,
usePdfExportState,
+ useViewModeActions,
useViewModeState,
useWriterToolsActions,
useWriterToolsState,
@@ -91,7 +94,8 @@ function App() {
useLayoutHotkeys();
const layoutChrome = useLayoutChromeState();
- const { setSidebarCollapsed, setTopBarsCollapsed, setStatusBarCollapsed } = useLayoutChromeActions();
+ const { setSidebarCollapsed, setTopBarsCollapsed, setStatusBarCollapsed, setCalmUiSettings } =
+ useLayoutChromeActions();
const editorPresentation = useEditorPresentationState();
const {
setLineNumbersVisible,
@@ -100,11 +104,13 @@ function App() {
setEditorFontSize,
setEditorFontFamily,
} = useEditorPresentationActions();
- const { isFocusMode } = useViewModeState();
+ const { isFocusMode, focusModeSettings } = useViewModeState();
+ const { setFocusMode, setFocusModeSettings } = useViewModeActions();
const { styleCheckSettings } = useWriterToolsState();
const { setStyleCheckSettings } = useWriterToolsActions();
const workspace = useWorkspaceController(openDoc);
const search = useSearchController(workspace.handleSelectDocument);
+ const { isTyping, handleTypingActivity } = useTypingActivity({ idleTimeout: 1500 });
const activeTab = useMemo(() => workspace.tabs.find((tab) => tab.id === workspace.activeTabId) ?? null, [
workspace.activeTabId,
@@ -215,6 +221,20 @@ function App() {
layoutChrome.statusBarCollapsed,
]);
+ const calmUiEffectiveVisibility = useMemo(() => {
+ const { calmUiSettings, chromeTemporarilyVisible } = layoutChrome;
+ if (!calmUiSettings.enabled || chromeTemporarilyVisible) {
+ return { sidebar: true, statusBar: true, tabBar: true };
+ }
+
+ const hideWhileTyping = calmUiSettings.autoHide && isTyping;
+ return {
+ sidebar: !hideWhileTyping,
+ statusBar: !hideWhileTyping,
+ tabBar: !hideWhileTyping,
+ };
+ }, [layoutChrome, isTyping]);
+
const sidebarProps = useMemo(
() => ({
handleAddLocation: workspace.handleAddLocation,
@@ -253,15 +273,20 @@ function App() {
],
);
+ const handleEditorChangeWithTyping = useCallback((text: string) => {
+ handleEditorChange(text);
+ handleTypingActivity();
+ }, [handleEditorChange, handleTypingActivity]);
+
const editorProps = useMemo(
() => ({
initialText: editorModel.text,
- onChange: handleEditorChange,
+ onChange: handleEditorChangeWithTyping,
onSave: handleSave,
onCursorMove: handleCursorMove,
onSelectionChange: handleSelectionChange,
}),
- [editorModel.text, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange],
+ [editorModel.text, handleEditorChangeWithTyping, handleSave, handleCursorMove, handleSelectionChange],
);
const statusBarProps = useMemo(
@@ -364,6 +389,22 @@ function App() {
return () => clearTimeout(timeoutId);
}, [activeTab, editorModel.text, renderPreview]);
+ const lastEnteredFocusDocRef = useRef(null);
+
+ useEffect(() => {
+ if (!layoutSettingsHydrated || !activeTab || isFocusMode) {
+ return;
+ }
+
+ const { calmUiSettings } = layoutChrome;
+ const docKey = `${activeTab.docRef.location_id}:${activeTab.docRef.rel_path}`;
+
+ if (calmUiSettings.enabled && calmUiSettings.focusMode && lastEnteredFocusDocRef.current !== docKey) {
+ lastEnteredFocusDocRef.current = docKey;
+ setFocusMode(true);
+ }
+ }, [layoutSettingsHydrated, activeTab, isFocusMode, layoutChrome, setFocusMode]);
+
useEffect(() => {
let isCancelled = false;
@@ -380,6 +421,10 @@ function App() {
setSyntaxHighlightingEnabled(settings.syntax_highlighting_enabled);
setEditorFontSize(settings.editor_font_size);
setEditorFontFamily(settings.editor_font_family);
+
+ const calmUiSettings = uiSettingsToCalmUI(settings);
+ setCalmUiSettings(calmUiSettings);
+ setFocusModeSettings(uiSettingsToFocusMode(settings));
setLayoutSettingsHydrated(true);
}, () => {
if (!isCancelled) {
@@ -412,6 +457,8 @@ function App() {
setSyntaxHighlightingEnabled,
setTextWrappingEnabled,
setTopBarsCollapsed,
+ setCalmUiSettings,
+ setFocusModeSettings,
]);
useEffect(() => {
@@ -419,33 +466,8 @@ function App() {
return;
}
- void runCmd(
- uiLayoutSet(
- {
- sidebar_collapsed: layoutChrome.sidebarCollapsed,
- top_bars_collapsed: layoutChrome.topBarsCollapsed,
- status_bar_collapsed: layoutChrome.statusBarCollapsed,
- line_numbers_visible: editorPresentation.lineNumbersVisible,
- text_wrapping_enabled: editorPresentation.textWrappingEnabled,
- syntax_highlighting_enabled: editorPresentation.syntaxHighlightingEnabled,
- editor_font_size: editorPresentation.editorFontSize,
- editor_font_family: editorPresentation.editorFontFamily,
- },
- () => {},
- () => {},
- ),
- );
- }, [
- layoutSettingsHydrated,
- layoutChrome.sidebarCollapsed,
- layoutChrome.topBarsCollapsed,
- layoutChrome.statusBarCollapsed,
- editorPresentation.lineNumbersVisible,
- editorPresentation.textWrappingEnabled,
- editorPresentation.syntaxHighlightingEnabled,
- editorPresentation.editorFontSize,
- editorPresentation.editorFontFamily,
- ]);
+ void runCmd(uiLayoutSet(stateToLayoutSettings(layoutChrome, editorPresentation, focusModeSettings), () => {}, () => {}));
+ }, [layoutSettingsHydrated, layoutChrome, editorPresentation, focusModeSettings]);
useEffect(() => {
if (!layoutSettingsHydrated) {
@@ -478,8 +500,9 @@ function App() {
editor: editorProps,
preview: previewProps,
statusBar: statusBarProps,
+ calmUiVisibility: calmUiEffectiveVisibility,
}),
- [sidebarProps, toolbarProps, tabProps, editorProps, previewProps, statusBarProps],
+ [sidebarProps, toolbarProps, tabProps, editorProps, previewProps, statusBarProps, calmUiEffectiveVisibility],
);
useEffect(() => {
diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx
index bc0a379..9d9d37c 100644
--- a/src/__tests__/Editor.test.tsx
+++ b/src/__tests__/Editor.test.tsx
@@ -229,7 +229,9 @@ describe(Editor, () => {
fireEvent.click(screen.getByTestId("editor-container"));
await waitFor(() => {
- expect(container.querySelector(".cm-content")).toBe(document.activeElement);
+ const editorRoot = container.querySelector(".cm-editor");
+ expect(editorRoot).toBeInTheDocument();
+ expect(editorRoot?.contains(document.activeElement)).toBe(true);
});
});
diff --git a/src/__tests__/appStore.test.ts b/src/__tests__/appStore.test.ts
index 5903271..bde4210 100644
--- a/src/__tests__/appStore.test.ts
+++ b/src/__tests__/appStore.test.ts
@@ -300,3 +300,128 @@ describe("appStore", () => {
expect(result.current.focusModeSettings.typewriterScrollingEnabled).toBe(!initialValue);
});
});
+
+describe("Calm UI state", () => {
+ beforeEach(() => {
+ resetAppStore();
+ });
+
+ it("should have default Calm UI settings", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ expect(result.current.calmUiSettings).toEqual({
+ enabled: true,
+ autoHide: true,
+ focusMode: true,
+ });
+ expect(result.current.chromeTemporarilyVisible).toBe(false);
+ });
+
+ it("should toggle Calm UI", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.toggleCalmUi();
+ });
+
+ expect(result.current.calmUiSettings.enabled).toBe(false);
+
+ act(() => {
+ result.current.toggleCalmUi();
+ });
+
+ expect(result.current.calmUiSettings.enabled).toBe(true);
+ });
+
+ it("should apply collapsed chrome preset when toggling Calm UI", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.toggleCalmUi();
+ });
+
+ expect(result.current.calmUiSettings.enabled).toBe(false);
+ expect(result.current.sidebarCollapsed).toBe(false);
+ expect(result.current.topBarsCollapsed).toBe(false);
+ expect(result.current.statusBarCollapsed).toBe(false);
+
+ act(() => {
+ result.current.toggleCalmUi();
+ });
+
+ expect(result.current.calmUiSettings.enabled).toBe(true);
+ expect(result.current.sidebarCollapsed).toBe(true);
+ expect(result.current.topBarsCollapsed).toBe(true);
+ expect(result.current.statusBarCollapsed).toBe(true);
+
+ act(() => {
+ result.current.toggleCalmUi();
+ });
+
+ expect(result.current.calmUiSettings.enabled).toBe(false);
+ expect(result.current.sidebarCollapsed).toBe(false);
+ expect(result.current.topBarsCollapsed).toBe(false);
+ expect(result.current.statusBarCollapsed).toBe(false);
+ });
+
+ it("should set Calm UI auto-hide", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.setCalmUiAutoHide(false);
+ });
+
+ expect(result.current.calmUiSettings.autoHide).toBe(false);
+ });
+
+ it("should set Calm UI focus mode", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.setCalmUiFocusMode(false);
+ });
+
+ expect(result.current.calmUiSettings.focusMode).toBe(false);
+ });
+
+ it("should set all Calm UI settings at once", () => {
+ const { result } = renderHook(() => useAppStore());
+ const newSettings = {
+ enabled: false,
+ autoHide: false,
+ focusMode: false,
+ };
+
+ act(() => {
+ result.current.setCalmUiSettings(newSettings);
+ });
+
+ expect(result.current.calmUiSettings).toEqual(newSettings);
+ });
+
+ it("should set chrome temporarily visible", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.setChromeTemporarilyVisible(true);
+ });
+
+ expect(result.current.chromeTemporarilyVisible).toBe(true);
+
+ act(() => {
+ result.current.setChromeTemporarilyVisible(false);
+ });
+
+ expect(result.current.chromeTemporarilyVisible).toBe(false);
+ });
+
+ it("should reveal chrome temporarily", () => {
+ const { result } = renderHook(() => useAppStore());
+
+ act(() => {
+ result.current.revealChromeTemporarily();
+ });
+
+ expect(result.current.chromeTemporarilyVisible).toBe(true);
+ });
+});
diff --git a/src/__tests__/focus-dimming.test.ts b/src/__tests__/focus-dimming.test.ts
index 164bad9..ce36552 100644
--- a/src/__tests__/focus-dimming.test.ts
+++ b/src/__tests__/focus-dimming.test.ts
@@ -26,6 +26,29 @@ describe("focusDimming", () => {
view.destroy();
});
+ it("should keep all lines in the active paragraph undimmed", () => {
+ const doc =
+ "First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph line one.\nSecond paragraph line two.";
+ const state = EditorState.create({
+ doc,
+ extensions: [focusDimming("paragraph"), focusDimmingTheme],
+ });
+ const view = new EditorView({ state });
+
+ view.dispatch({ selection: { anchor: doc.indexOf("First paragraph line two.") } });
+
+ const dimmedText = Array.from(view.dom.querySelectorAll(".cm-dimmed-text"))
+ .map((node) => node.textContent ?? "")
+ .join(" ");
+
+ expect(dimmedText).toContain("Second paragraph line one.");
+ expect(dimmedText).toContain("Second paragraph line two.");
+ expect(dimmedText).not.toContain("First paragraph line one.");
+ expect(dimmedText).not.toContain("First paragraph line two.");
+
+ view.destroy();
+ });
+
it("should not apply dimming when mode is off", () => {
const state = EditorState.create({ doc: "Some text here.", extensions: [focusDimming("off")] });
const view = new EditorView({ state });
diff --git a/src/__tests__/helpers.test.ts b/src/__tests__/helpers.test.ts
new file mode 100644
index 0000000..ddb2fac
--- /dev/null
+++ b/src/__tests__/helpers.test.ts
@@ -0,0 +1,32 @@
+import { pick, pickBy } from "$state/helpers";
+import { describe, expect, it, vi } from "vitest";
+
+describe("helpers", () => {
+ describe("pick", () => {
+ it("returns only the requested keys", () => {
+ const source = { a: 1, b: 2, c: 3 };
+ expect(pick(source, ["a", "c"])).toEqual({ a: 1, c: 3 });
+ });
+
+ it("returns an empty object when no keys are provided", () => {
+ const source = { a: 1, b: 2 };
+ expect(pick(source, [])).toEqual({});
+ });
+ });
+
+ describe("pickBy", () => {
+ it("returns only keys accepted by the predicate", () => {
+ const source = { enabled: true, autoHide: false, focusMode: true };
+ const result = pickBy(source, (key) => key !== "autoHide");
+ expect(result).toEqual({ enabled: true, focusMode: true });
+ });
+
+ it("evaluates each key and returns empty object when predicate rejects all", () => {
+ const source = { a: 1, b: 2, c: 3 };
+ const predicate = vi.fn(() => false);
+ const result = pickBy(source, predicate);
+ expect(predicate).toHaveBeenCalledTimes(3);
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts
index a1516e3..8ab1f42 100644
--- a/src/__tests__/ports.test.ts
+++ b/src/__tests__/ports.test.ts
@@ -738,6 +738,11 @@ describe("ui layout Commands", () => {
syntax_highlighting_enabled: false,
editor_font_size: 18,
editor_font_family: "Monaspace Neon",
+ calm_ui_enabled: true,
+ calm_ui_auto_hide: true,
+ calm_ui_focus_mode: true,
+ focus_typewriter_scrolling_enabled: true,
+ focus_dimming_mode: "sentence",
},
onOk,
onErr,
@@ -755,6 +760,11 @@ describe("ui layout Commands", () => {
syntax_highlighting_enabled: false,
editor_font_size: 18,
editor_font_family: "Monaspace Neon",
+ calm_ui_enabled: true,
+ calm_ui_auto_hide: true,
+ calm_ui_focus_mode: true,
+ focus_typewriter_scrolling_enabled: true,
+ focus_dimming_mode: "sentence",
},
});
});
diff --git a/src/__tests__/useTypingActivity.test.ts b/src/__tests__/useTypingActivity.test.ts
new file mode 100644
index 0000000..f4d03cd
--- /dev/null
+++ b/src/__tests__/useTypingActivity.test.ts
@@ -0,0 +1,125 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useTypingActivity } from "../hooks/useTypingActivity";
+
+describe("useTypingActivity", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("should start with isTyping false", () => {
+ const { result } = renderHook(() => useTypingActivity());
+ expect(result.current.isTyping).toBe(false);
+ });
+
+ it("should set isTyping to true on typing activity", () => {
+ const { result } = renderHook(() => useTypingActivity());
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(result.current.isTyping).toBe(true);
+ });
+
+ it("should set isTyping to false after idle timeout", () => {
+ const { result } = renderHook(() => useTypingActivity({ idleTimeout: 1000 }));
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(result.current.isTyping).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.isTyping).toBe(false);
+ });
+
+ it("should reset timeout on continued typing", () => {
+ const { result } = renderHook(() => useTypingActivity({ idleTimeout: 1000 }));
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(result.current.isTyping).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ result.current.handleTypingActivity();
+ });
+
+ expect(result.current.isTyping).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(result.current.isTyping).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(result.current.isTyping).toBe(false);
+ });
+
+ it("should call onTypingStart callback when typing starts", () => {
+ const onTypingStart = vi.fn();
+ const { result } = renderHook(() => useTypingActivity({ onTypingStart }));
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(onTypingStart).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(onTypingStart).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onTypingEnd callback when typing stops", () => {
+ const onTypingEnd = vi.fn();
+ const { result } = renderHook(() => useTypingActivity({ idleTimeout: 500, onTypingEnd }));
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ expect(onTypingEnd).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(onTypingEnd).toHaveBeenCalledTimes(1);
+ });
+
+ it("should clean up timeout on unmount", () => {
+ const { result, unmount } = renderHook(() => useTypingActivity({ idleTimeout: 1000 }));
+
+ act(() => {
+ result.current.handleTypingActivity();
+ });
+
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.isTyping).toBe(true);
+ });
+});
+
+import { afterEach } from "vitest";
diff --git a/src/components/capture/QuickCaptureForm.tsx b/src/components/capture/QuickCaptureForm.tsx
index 2f04e43..10c6873 100644
--- a/src/components/capture/QuickCaptureForm.tsx
+++ b/src/components/capture/QuickCaptureForm.tsx
@@ -1,6 +1,6 @@
import { XIcon } from "$icons";
import type { CaptureMode } from "$types";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type QuickCaptureFormProps = {
defaultMode?: CaptureMode;
@@ -32,6 +32,8 @@ const ModeButton = ({ currentMode, setMode, isSubmitting, mode }: ModeButtonProp
setMode(mode);
}, [mode, setMode]);
+ const modeLabel = useMemo(() => getModeLabel(mode), [mode]);
+
return (
- {getModeLabel(mode)}
+ {modeLabel}
);
};
@@ -132,13 +134,7 @@ export function QuickCaptureForm(
return;
}
- if (
- mode !== "WritingSession"
- && e.key === "Enter"
- && !hasMod
- && !e.shiftKey
- && !e.altKey
- ) {
+ if (mode !== "WritingSession" && e.key === "Enter" && !hasMod && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleSubmit();
}
diff --git a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx
index 1a1fcc5..cc66c5f 100644
--- a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx
+++ b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx
@@ -1,5 +1,7 @@
import { XIcon } from "$icons";
import {
+ useCalmUiActions,
+ useCalmUiSettings,
useLayoutSettingsChromeState,
useLayoutSettingsEditorState,
useLayoutSettingsFocusState,
@@ -115,6 +117,42 @@ function ChromeSettingsSection() {
);
}
+function CalmUiSettingsSection() {
+ const { enabled, autoHide, focusMode } = useCalmUiSettings();
+ const {
+ toggleCalmUi,
+ setCalmUiAutoHide,
+ setCalmUiFocusMode,
+ } = useCalmUiActions();
+
+ return (
+
+
+
+ {enabled && (
+
+
Calm UI Options
+
+
+
Hold Ctrl+Shift+H to reveal hidden UI
+
+ )}
+
+ );
+}
+
function EditorSettingsSection() {
const {
lineNumbersVisible,
@@ -210,6 +248,7 @@ export const LayoutSettingsPanel = ({ isVisible, onClose }: LayoutSettingsPanelP
+
diff --git a/src/components/layout/WorkspacePanel.tsx b/src/components/layout/WorkspacePanel.tsx
index 636a33e..9ad4d77 100644
--- a/src/components/layout/WorkspacePanel.tsx
+++ b/src/components/layout/WorkspacePanel.tsx
@@ -20,6 +20,8 @@ export type WorkspaceEditorProps = Pick;
type PK = "renderResult" | "theme" | "editorLine" | "onScrollToLine";
export type WorkspacePreviewProps = Pick;
+export type CalmUiVisibility = { sidebar: boolean; statusBar: boolean; tabBar: boolean };
+
export type WorkspacePanelProps = {
sidebar: Pick;
toolbar: Pick<
@@ -30,6 +32,7 @@ export type WorkspacePanelProps = {
editor: WorkspaceEditorProps;
preview: WorkspacePreviewProps;
statusBar: StatusBarProps;
+ calmUiVisibility?: CalmUiVisibility;
};
const SPLIT_PANEL_MIN_WIDTH = 280;
@@ -95,7 +98,9 @@ function getPanelMode(isSplitView: boolean, isPreviewVisible: boolean): PanelMod
return "editor";
}
-export function WorkspacePanel({ sidebar, toolbar, tabs, editor, preview, statusBar }: WorkspacePanelProps) {
+export function WorkspacePanel(
+ { sidebar, toolbar, tabs, editor, preview, statusBar, calmUiVisibility }: WorkspacePanelProps,
+) {
const { sidebarCollapsed } = useWorkspacePanelSidebarState();
const { isSplitView, isPreviewVisible } = useWorkspacePanelModeState();
const topBarsCollapsed = useWorkspacePanelTopBarsCollapsed();
@@ -104,6 +109,12 @@ export function WorkspacePanel({ sidebar, toolbar, tabs, editor, preview, status
const viewportWidth = globalThis.innerWidth || FALLBACK_VIEWPORT_WIDTH;
const splitMaxWidth = Math.max(SPLIT_PANEL_MIN_WIDTH, viewportWidth - SPLIT_PANEL_MIN_WIDTH);
+ const effectiveSidebarVisible = calmUiVisibility ? calmUiVisibility.sidebar && !sidebarCollapsed : !sidebarCollapsed;
+ const effectiveTabBarVisible = calmUiVisibility ? calmUiVisibility.tabBar && !topBarsCollapsed : !topBarsCollapsed;
+ const effectiveStatusBarVisible = calmUiVisibility
+ ? calmUiVisibility.statusBar && !statusBarCollapsed
+ : !statusBarCollapsed;
+
const { size: sidebarWidth, isResizing, startResizing } = useResizable({
initialSize: 280,
minSize: 220,
@@ -130,23 +141,25 @@ export function WorkspacePanel({ sidebar, toolbar, tabs, editor, preview, status
return (
- {sidebarCollapsed ? null : (
-
- )}
+ {effectiveSidebarVisible
+ ? (
+
+ )
+ : null}
- {topBarsCollapsed ? null : }
+ {effectiveTabBarVisible ? : null}
- {statusBarCollapsed ? null : }
+ {effectiveStatusBarVisible ? : null}
);
diff --git a/src/editor/focus-dimming.ts b/src/editor/focus-dimming.ts
index 583017e..e39b481 100644
--- a/src/editor/focus-dimming.ts
+++ b/src/editor/focus-dimming.ts
@@ -4,9 +4,10 @@ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@
import type { FocusDimmingMode } from "../types";
const DIMMED_CLASS = "cm-dimmed-text";
+type Region = { start: number; end: number };
-function findSentenceBoundaries(text: string): Array<{ start: number; end: number }> {
- const sentences: Array<{ start: number; end: number }> = [];
+function findSentenceBoundaries(text: string): Region[] {
+ const sentences: Region[] = [];
const sentenceRegex = /[^.!?]+[.!?]+\s*/g;
let match;
@@ -24,36 +25,77 @@ function findSentenceBoundaries(text: string): Array<{ start: number; end: numbe
return sentences;
}
-function createDimmingDecorations(view: EditorView, mode: FocusDimmingMode): DecorationSet {
- if (mode === "off") {
- return Decoration.none;
- }
-
- const cursorPos = view.state.selection.main.head;
+function findParagraphBoundaries(view: EditorView): Region[] {
+ const paragraphs: Region[] = [];
const doc = view.state.doc;
- const builder = new RangeSetBuilder();
+ let paragraphStart: number | null = null;
+ let paragraphEnd = 0;
+
+ for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
+ const line = doc.line(lineNumber);
+ const isBlankLine = line.text.trim().length === 0;
- for (let i = 1; i <= doc.lines; i++) {
- const line = doc.line(i);
- const lineText = line.text;
+ if (isBlankLine) {
+ if (paragraphStart !== null) {
+ paragraphs.push({ start: paragraphStart, end: paragraphEnd });
+ paragraphStart = null;
+ }
+ continue;
+ }
+
+ if (paragraphStart === null) {
+ paragraphStart = line.from;
+ }
+ paragraphEnd = line.to;
+ }
- if (lineText.trim().length === 0) continue;
+ if (paragraphStart !== null) {
+ paragraphs.push({ start: paragraphStart, end: paragraphEnd });
+ }
- let regions: Array<{ start: number; end: number }>;
+ return paragraphs;
+}
- if (mode === "sentence") {
- regions = findSentenceBoundaries(lineText);
- regions = regions.map((r) => ({ start: line.from + r.start, end: line.from + r.end }));
- } else {
- regions = [{ start: line.from, end: line.to }];
+function createRegions(view: EditorView, mode: FocusDimmingMode): Region[] {
+ const paragraphs = findParagraphBoundaries(view);
+ if (mode === "paragraph") {
+ return paragraphs;
+ }
+
+ const doc = view.state.doc;
+ const sentenceRegions: Region[] = [];
+ for (const paragraph of paragraphs) {
+ const paragraphText = doc.sliceString(paragraph.start, paragraph.end);
+ const sentenceBoundaries = findSentenceBoundaries(paragraphText);
+ for (const sentence of sentenceBoundaries) {
+ sentenceRegions.push({
+ start: paragraph.start + sentence.start,
+ end: paragraph.start + sentence.end,
+ });
}
+ }
+
+ return sentenceRegions;
+}
- const activeRegion = regions.find((r) => cursorPos >= r.start && cursorPos <= r.end);
+function isWithinRegion(cursorPos: number, region: Region, docLength: number): boolean {
+ return cursorPos >= region.start && (cursorPos < region.end || (cursorPos === docLength && region.end === docLength));
+}
- for (const region of regions) {
- if (!activeRegion || region.start !== activeRegion.start) {
- builder.add(region.start, region.end, Decoration.mark({ class: DIMMED_CLASS }));
- }
+function createDimmingDecorations(view: EditorView, mode: FocusDimmingMode): DecorationSet {
+ if (mode === "off") {
+ return Decoration.none;
+ }
+
+ const cursorPos = view.state.selection.main.head;
+ const builder = new RangeSetBuilder();
+ const regions = createRegions(view, mode);
+ const docLength = view.state.doc.length;
+ const activeRegion = regions.find((region) => isWithinRegion(cursorPos, region, docLength));
+
+ for (const region of regions) {
+ if (!activeRegion || region.start !== activeRegion.start || region.end !== activeRegion.end) {
+ builder.add(region.start, region.end, Decoration.mark({ class: DIMMED_CLASS }));
}
}
diff --git a/src/hooks/useLayoutHotkeys.ts b/src/hooks/useLayoutHotkeys.ts
index 6b1561f..06b112e 100644
--- a/src/hooks/useLayoutHotkeys.ts
+++ b/src/hooks/useLayoutHotkeys.ts
@@ -2,7 +2,13 @@ import { useEffect } from "react";
import { useLayoutChromeActions, useViewModeActions } from "../state/appStore";
export function useLayoutHotkeys(): void {
- const { toggleShowSearch, toggleSidebarCollapsed, toggleTabBarCollapsed } = useLayoutChromeActions();
+ const {
+ toggleShowSearch,
+ toggleSidebarCollapsed,
+ toggleTabBarCollapsed,
+ revealChromeTemporarily,
+ setChromeTemporarilyVisible,
+ } = useLayoutChromeActions();
const { toggleFocusMode, toggleSplitView } = useViewModeActions();
useEffect(() => {
@@ -34,9 +40,34 @@ export function useLayoutHotkeys(): void {
event.preventDefault();
toggleSplitView();
}
+
+ if (hasMod && event.shiftKey && lowerKey === "h") {
+ event.preventDefault();
+ revealChromeTemporarily();
+ }
+ };
+
+ const handleKeyUp = (event: KeyboardEvent) => {
+ const hasMod = event.ctrlKey || event.metaKey;
+
+ if (!hasMod) {
+ setChromeTemporarilyVisible(false);
+ }
};
globalThis.addEventListener("keydown", handleKeyDown);
- return () => globalThis.removeEventListener("keydown", handleKeyDown);
- }, [toggleFocusMode, toggleShowSearch, toggleSidebarCollapsed, toggleTabBarCollapsed, toggleSplitView]);
+ globalThis.addEventListener("keyup", handleKeyUp);
+ return () => {
+ globalThis.removeEventListener("keydown", handleKeyDown);
+ globalThis.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [
+ toggleFocusMode,
+ toggleShowSearch,
+ toggleSidebarCollapsed,
+ toggleTabBarCollapsed,
+ toggleSplitView,
+ revealChromeTemporarily,
+ setChromeTemporarilyVisible,
+ ]);
}
diff --git a/src/hooks/useTypingActivity.ts b/src/hooks/useTypingActivity.ts
new file mode 100644
index 0000000..7a4f479
--- /dev/null
+++ b/src/hooks/useTypingActivity.ts
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type UseTypingActivityOptions = { idleTimeout?: number; onTypingStart?: () => void; onTypingEnd?: () => void };
+
+export function useTypingActivity(
+ options: UseTypingActivityOptions = {},
+): { isTyping: boolean; handleTypingActivity: () => void } {
+ const { idleTimeout = 2000, onTypingStart, onTypingEnd } = options;
+ const [isTyping, setIsTyping] = useState(false);
+ const timeoutRef = useRef | null>(null);
+ const wasTypingRef = useRef(false);
+
+ const clearExistingTimeout = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }, []);
+
+ const handleTypingActivity = useCallback(() => {
+ clearExistingTimeout();
+
+ if (!wasTypingRef.current) {
+ wasTypingRef.current = true;
+ setIsTyping(true);
+ onTypingStart?.();
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ wasTypingRef.current = false;
+ setIsTyping(false);
+ onTypingEnd?.();
+ }, idleTimeout);
+ }, [clearExistingTimeout, idleTimeout, onTypingStart, onTypingEnd]);
+
+ useEffect(() => {
+ return () => {
+ clearExistingTimeout();
+ };
+ }, [clearExistingTimeout]);
+
+ return { isTyping, handleTypingActivity };
+}
diff --git a/src/ports.ts b/src/ports.ts
index 861fa7b..b0715eb 100644
--- a/src/ports.ts
+++ b/src/ports.ts
@@ -2,8 +2,8 @@ import { logger } from "$logger";
import type { PdfRenderResult } from "$pdf/types";
import type {
AppError,
- CaptureMode,
CaptureDocRef,
+ CaptureMode,
CaptureSubmitInput,
CaptureSubmitResult,
DocContent,
@@ -11,6 +11,7 @@ import type {
DocRef,
EditorFontFamily,
ErrorCode,
+ FocusDimmingMode,
GlobalCaptureSettings,
LocationDescriptor,
LocationId,
@@ -54,6 +55,11 @@ export type UiLayoutSettings = {
syntax_highlighting_enabled: boolean;
editor_font_size: number;
editor_font_family: EditorFontFamily;
+ calm_ui_enabled: boolean;
+ calm_ui_auto_hide: boolean;
+ calm_ui_focus_mode: boolean;
+ focus_typewriter_scrolling_enabled: boolean;
+ focus_dimming_mode: FocusDimmingMode;
};
export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string };
@@ -357,13 +363,7 @@ function normalizeGlobalCaptureSettings(value: unknown): GlobalCaptureSettings {
function normalizeCaptureSubmitResult(value: unknown): CaptureSubmitResult {
if (!isRecord(value)) {
- return {
- success: false,
- savedTo: null,
- locationId: 0,
- shouldClose: true,
- lastCaptureTarget: null,
- };
+ return { success: false, savedTo: null, locationId: 0, shouldClose: true, lastCaptureTarget: null };
}
return {
diff --git a/src/state/appStore.ts b/src/state/appStore.ts
index cb3293f..aa87cfc 100644
--- a/src/state/appStore.ts
+++ b/src/state/appStore.ts
@@ -1,5 +1,6 @@
import type {
AppTheme,
+ CalmUiSettings,
DocMeta,
DocRef,
EditorFontFamily,
@@ -27,6 +28,8 @@ export type LayoutChromeState = {
topBarsCollapsed: boolean;
statusBarCollapsed: boolean;
showSearch: boolean;
+ calmUiSettings: CalmUiSettings;
+ chromeTemporarilyVisible: boolean;
};
export type EditorPresentationState = {
@@ -56,6 +59,12 @@ export type LayoutChromeActions = {
toggleStatusBarCollapsed: () => void;
setShowSearch: (value: boolean) => void;
toggleShowSearch: () => void;
+ setCalmUiSettings: (settings: CalmUiSettings) => void;
+ toggleCalmUi: () => void;
+ setCalmUiAutoHide: (value: boolean) => void;
+ setCalmUiFocusMode: (value: boolean) => void;
+ setChromeTemporarilyVisible: (value: boolean) => void;
+ revealChromeTemporarily: () => void;
};
export type EditorPresentationActions = {
@@ -158,6 +167,12 @@ const getInitialLayoutChromeState = (): LayoutChromeState => ({
topBarsCollapsed: false,
statusBarCollapsed: false,
showSearch: false,
+ calmUiSettings: {
+ enabled: true,
+ autoHide: true,
+ focusMode: true,
+ },
+ chromeTemporarilyVisible: false,
});
const getInitialEditorPresentationState = (): EditorPresentationState => ({
@@ -228,6 +243,23 @@ const createLayoutChromeSlice: StateCreator set({ showSearch: value }),
toggleShowSearch: () => set((state) => ({ showSearch: !state.showSearch })),
+
+ setCalmUiSettings: (settings) => set({ calmUiSettings: settings }),
+ toggleCalmUi: () =>
+ set((state) => {
+ const nextEnabled = !state.calmUiSettings.enabled;
+ return {
+ calmUiSettings: { ...state.calmUiSettings, enabled: nextEnabled },
+ sidebarCollapsed: nextEnabled,
+ topBarsCollapsed: nextEnabled,
+ statusBarCollapsed: nextEnabled,
+ chromeTemporarilyVisible: false,
+ };
+ }),
+ setCalmUiAutoHide: (value) => set((state) => ({ calmUiSettings: { ...state.calmUiSettings, autoHide: value } })),
+ setCalmUiFocusMode: (value) => set((state) => ({ calmUiSettings: { ...state.calmUiSettings, focusMode: value } })),
+ setChromeTemporarilyVisible: (value) => set({ chromeTemporarilyVisible: value }),
+ revealChromeTemporarily: () => set({ chromeTemporarilyVisible: true }),
});
const createEditorPresentationSlice: StateCreator<
@@ -484,6 +516,8 @@ export const useLayoutChromeState = () =>
topBarsCollapsed: state.topBarsCollapsed,
statusBarCollapsed: state.statusBarCollapsed,
showSearch: state.showSearch,
+ calmUiSettings: state.calmUiSettings,
+ chromeTemporarilyVisible: state.chromeTemporarilyVisible,
})),
);
@@ -498,6 +532,12 @@ export const useLayoutChromeActions = () =>
toggleStatusBarCollapsed: state.toggleStatusBarCollapsed,
setShowSearch: state.setShowSearch,
toggleShowSearch: state.toggleShowSearch,
+ setCalmUiSettings: state.setCalmUiSettings,
+ toggleCalmUi: state.toggleCalmUi,
+ setCalmUiAutoHide: state.setCalmUiAutoHide,
+ setCalmUiFocusMode: state.setCalmUiFocusMode,
+ setChromeTemporarilyVisible: state.setChromeTemporarilyVisible,
+ revealChromeTemporarily: state.revealChromeTemporarily,
})),
);
diff --git a/src/state/helpers.ts b/src/state/helpers.ts
new file mode 100644
index 0000000..4e28054
--- /dev/null
+++ b/src/state/helpers.ts
@@ -0,0 +1,55 @@
+import { UiLayoutSettings } from "$ports";
+import type { CalmUiSettings, FocusModeSettings } from "$types";
+import type { EditorPresentationState, LayoutChromeState } from "./appStore";
+
+export const stateToLayoutSettings = (
+ layoutChrome: LayoutChromeState,
+ presentation: EditorPresentationState,
+ focusModeSettings: FocusModeSettings,
+) => ({
+ sidebar_collapsed: layoutChrome.sidebarCollapsed,
+ top_bars_collapsed: layoutChrome.topBarsCollapsed,
+ status_bar_collapsed: layoutChrome.statusBarCollapsed,
+ line_numbers_visible: presentation.lineNumbersVisible,
+ text_wrapping_enabled: presentation.textWrappingEnabled,
+ syntax_highlighting_enabled: presentation.syntaxHighlightingEnabled,
+ editor_font_size: presentation.editorFontSize,
+ editor_font_family: presentation.editorFontFamily,
+ calm_ui_enabled: layoutChrome.calmUiSettings.enabled,
+ calm_ui_auto_hide: layoutChrome.calmUiSettings.autoHide,
+ calm_ui_focus_mode: layoutChrome.calmUiSettings.focusMode,
+ focus_typewriter_scrolling_enabled: focusModeSettings.typewriterScrollingEnabled,
+ focus_dimming_mode: focusModeSettings.dimmingMode,
+});
+
+export const uiSettingsToCalmUI: (uiSettings: UiLayoutSettings) => CalmUiSettings = (uiSettings) => ({
+ enabled: uiSettings.calm_ui_enabled,
+ autoHide: uiSettings.calm_ui_auto_hide,
+ focusMode: uiSettings.calm_ui_focus_mode,
+});
+
+export const uiSettingsToFocusMode: (uiSettings: UiLayoutSettings) => FocusModeSettings = (uiSettings) => ({
+ typewriterScrollingEnabled: uiSettings.focus_typewriter_scrolling_enabled,
+ dimmingMode: uiSettings.focus_dimming_mode,
+});
+
+export function pick(obj: T, keys: K[]): Pick {
+ const result = {} as Pick;
+ for (const key of keys) {
+ result[key] = obj[key];
+ }
+ return result;
+}
+
+export function pickBy, K extends keyof T>(
+ obj: T,
+ predicate: (key: K) => boolean,
+): Pick {
+ const result = {} as Pick;
+ for (const key of Object.keys(obj) as K[]) {
+ if (predicate(key)) {
+ result[key] = obj[key];
+ }
+ }
+ return result;
+}
diff --git a/src/state/panel-selectors.ts b/src/state/panel-selectors.ts
index ab45888..3f39f9e 100644
--- a/src/state/panel-selectors.ts
+++ b/src/state/panel-selectors.ts
@@ -1,4 +1,4 @@
-import type { AppTheme, EditorFontFamily, FocusDimmingMode, StyleCheckSettings } from "$types";
+import type { AppTheme, CalmUiSettings, EditorFontFamily, FocusDimmingMode, StyleCheckSettings } from "$types";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "./appStore";
@@ -147,3 +147,23 @@ export const useEditorPresentationState = () =>
styleCheckSettings: state.isFocusMode ? FOCUS_MODE_STYLE_CHECK_SETTINGS : state.styleCheckSettings,
})),
);
+
+export const useCalmUiSettings = () =>
+ useAppStore(
+ useShallow((state): CalmUiSettings & { chromeTemporarilyVisible: boolean } => ({
+ ...state.calmUiSettings,
+ chromeTemporarilyVisible: state.chromeTemporarilyVisible,
+ })),
+ );
+
+export const useCalmUiActions = () =>
+ useAppStore(
+ useShallow((state) => ({
+ setCalmUiSettings: state.setCalmUiSettings,
+ toggleCalmUi: state.toggleCalmUi,
+ setCalmUiAutoHide: state.setCalmUiAutoHide,
+ setCalmUiFocusMode: state.setCalmUiFocusMode,
+ setChromeTemporarilyVisible: state.setChromeTemporarilyVisible,
+ revealChromeTemporarily: state.revealChromeTemporarily,
+ })),
+ );
diff --git a/src/types.ts b/src/types.ts
index fc4c801..be42927 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -99,6 +99,12 @@ export type CaptureMode = "QuickNote" | "WritingSession" | "Append";
export type CaptureDocRef = { locationId: number; relPath: string };
+export type CalmUiSettings = {
+ enabled: boolean;
+ autoHide: boolean;
+ focusMode: boolean;
+};
+
export type GlobalCaptureSettings = {
enabled: boolean;
shortcut: string;