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

-

Section 2

+

Section 2

- - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + +
NameValue
A1
B2
NameValue
A1
B2

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 ( ); }; @@ -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;