diff --git a/docs/specs/ui-foundation-macos.md b/docs/specs/ui-foundation-macos.md new file mode 100644 index 0000000..f15f589 --- /dev/null +++ b/docs/specs/ui-foundation-macos.md @@ -0,0 +1,88 @@ +# Base 2 (macOS half) — platform UI foundation on macOS (feature/ui-foundation-mac) + +Wires the shared foundation (command registry, key map, settings v2) into the +AppKit UI and splits `ui_macos.m` so later feature categories work in their own +files. Behaviour is preserved except where noted below. The Windows half is a +separate branch. + +## Files + +| File | Role | +|---|---| +| `src/features/ui/platform/macos/ui_macos_internal.h` | `@interface AxyneWorkspaceView` with every ivar, shared types (bottom panel tabs, chrome colours, command state), colour helpers, the method declarations of `ui_macos.m`, the command routing API and the feature hook set | +| `ui_macos.m` | the workspace view (unchanged features), hook call sites, bottom panel tab model, application menu | +| `ui_commands_macos.m` | registry menus (Figma layout table), base handlers, key map, keyboard/chord dispatch, palette command rows, shortcuts reference | +| `ui__macos.m` × 12 | stub categories: `file`, `edit`, `view`, `build`, `debug`, `terminal`, `status`, `tools`, `help`, `lsp_nav`, `diag`, `release` | +| `popup_menu_macos.{h,m}` | `axyne_popup_set_key_text` / `axyne_popup_key_text`: the in-window popup shows key-map text (chords) | +| `src/features/ui/CMakeLists.txt` (APPLE) | new sources; `-ObjC` as an INTERFACE link option of `axyne_ui` so category-only objects are linked | +| `tests/test_menu_consistency.c` (macOS parts) | selectors from the application menu and the base-handler switch must be implemented in `ui_macos.m` or a category file; macOS labels are checked against the registry titles of the commands in the macOS layout table | + +## Feature category contract + +Each category is `@implementation AxyneWorkspaceView (AxyneFeature)` +in `ui__macos.m`, owns the ivar `_Ui` (`id`, nil until the +category creates its private helper object) and implements this fixed hook set +(prefix `file`, `edit`, `view`, `build`, `debug`, `terminal`, `status`, `tools`, +`help`, `lspNav`, `diag`, `release`): + +| Hook | Called | +|---|---| +| `- (NSInteger)

ValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item` | menu validation, keyboard dispatch, palette listing; return `AXYNE_MAC_COMMAND_UNHANDLED` (-1) for commands the category does not implement, else `_DISABLED`/`_ENABLED`; set the item's state for check/radio items (item is nil outside menus) | +| `- (BOOL)

PerformCommand:(AxyneCommandId)command sender:(id)sender` | runs an owned command | +| `- (void)

DocumentActivated:(AxyneDocument *)document` | end of `-loadActiveDocument` (NULL in the empty state) | +| `- (void)

DocumentWillClose:(AxyneDocument *)document` | before a tab or a replaced preview/diff tab is released | +| `- (void)

DocumentSaved:(AxyneDocument *)document` | after Save / Save As succeeded | +| `- (void)

EditorNotification:(SCNotification *)notification` | every Scintilla notification of the active editor | +| `- (BOOL)

DrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors` | in `drawRect:`; YES = the category painted the status segments (default text skipped; it must then show `_chordHint` itself) | +| `- (void)

DrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors` | in `drawRect:` after the tab strip | +| `- (BOOL)

LayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab` | in `-layout` with the selected tab's content rect (NSZeroRect while hidden); YES = the category shows its own view (default output/terminal/problems views are hidden) | +| `- (void)

Layout` | end of `-layout` | +| `- (void)

ApplyPreferences` | end of `-applyPreferences` | +| `- (void)

Teardown` | first thing in `-dealloc`: release `_

Ui`, observers, monitors | + +Order: `AXYNE_MAC_FEATURES(X)` (file, edit, view, build, debug, terminal, +status, tools, help, lspNav, diag, release). The first category that does not +return UNHANDLED owns a command, before the base handlers. Blocks and timers +must never retain the view: use an assign-only `AxyneDiscoveryBox`. + +Base services (`AxyneFoundation` / `AxyneCommandRouting` categories): +`-chromeColors`, `-bottomPanelTabButton:`, `-bottomPanelTabVisible:`, +`-setBottomPanelTab:visible:`, `-selectBottomPanelTab:`, `-bottomPanelTabWidth:`, +`-rebuildKeymap`, `-keyTextForCommand:`, `-commandMenuItem:`, +`-refreshMenuKeyEquivalents`, `-validateCommand:item:`, `-dispatchCommand:sender:`, +`-runCommand:`, `-commandImplemented:`, `-featureValidateCommand:item:`, +`-performCommand:` (action of every registry menu item, tag = command id), +`-handleKeyEvent:`, `-paletteRebuildCommandRows`, `-showKeyboardShortcuts:`. + +## Decisions + +| Decision | Value | Source | Status | +|---|---|---|---| +| Category split | one category file per later feature category with the fixed hook set above, ivars pre-declared in the internal header | USER (task) | CONFIRMED | +| Linking categories | `-ObjC` as INTERFACE link option of `axyne_ui` (app and tests) | DELEGATED | ASSUMED | +| `AxyneCommandPlatform` clash | `build_target.h` and `commands.h` both define `AxyneCommandPlatform`/`AXYNE_PLATFORM_*` with different values; the internal header includes the registry headers under renamed identifiers and macOS code passes `axyne_command_platform_current()` to the registry/key map. Core fix needed (rename one enum) | DELEGATED | ASSUMED | +| Bottom panel tabs | enum OUTPUT/PROBLEMS/TERMINAL/DEBUG_CONSOLE, strip lists visible tabs in enum order 2 px apart (Figma widths 38/38+/50 kept); DEBUG_CONSOLE "디버그 콘솔" hidden until the debug category calls `-setBottomPanelTab:visible:`; hiding the selected tab selects Output; Output cannot be hidden | USER (task) / DELEGATED | ASSUMED | +| Menu routing | every registry item uses `-performCommand:` (tag = command id); feature hooks first, then the old selector as base handler (validated through the old `validateMenuItem:` rules on a proxy item) | DELEGATED | ASSUMED | +| Unimplemented items | shown in Figma position, disabled (no base handler and no feature claims them): 새 프로젝트, 모두 저장, 자동 저장, 창 닫기, 최근 항목 지우기, 블록 주석 토글, 개요/도구 모음/상태 표시줄/동작 항상 표시, 테마 submenu, 전체 화면, 다시 빌드, 정리, CMake items, 빌드 구성 submenu, 빌드 대상, 작업 실행, tasks.json 편집, 다시 시작, 실행 인수, 외부 디버거 열기 (LLDB (터미널), 디버거 경로 설정), zsh/bash/기본 프로필 선택, 외부 도구 구성, theme.json 열기, 키바인딩, 메모리 사용량 보기, 파일 연결, 시작하기, 로그 폴더 열기, 업데이트 확인, 릴리스 노트, 오픈 소스 라이선스 | USER (rule) | CONFIRMED | +| Enabled new entries backed by existing features | 명령 팔레트 (palette ">"), 심볼로 이동 (palette "@", current-file scanner; D20 refines), File > 종료 (terminate) | DELEGATED | ASSUMED | +| macOS menu layout | Figma order; macOS extras kept: 파일 이동 in 편집, Git 패널 in 보기, 일시 중지/계속 before the step commands, Axyne 도움말 and 설정 폴더 표시 in 도움말; Git commands moved from 파일 to 도구 (registry group); 정의로 이동/참조 찾기 moved from 도구 to 편집 (registry group); 실행 is now 디버그 > 디버깅하지 않고 실행 (registry); Runner 설정 last in 빌드; File > 환경 설정 submenu = 설정, 작업 영역 설정, settings.json 열기 (also in 도구); 새 터미널 is a submenu (새 터미널, zsh, bash, 기본 프로필 선택); 최근 항목 = files, "최근 폴더 없음" placeholder, 최근 항목 지우기 | USER (Figma) / DELEGATED | ASSUMED | +| Explorer context menu | registry titles (폴더 열기…, 새 파일, 새 폴더, 이름 바꾸기, 삭제) instead of the old English labels, same handlers | DELEGATED | ASSUMED | +| settings.json (D3) | loads read `settings.json`, else `preferences.json`; saves always write `settings.json`; the old file is never deleted; the read path is resolved on every use; "settings.json 열기" (Tools/File and the preferences window link) always opens `settings.json`; when only `preferences.json` exists its bytes are copied verbatim into `settings.json` first (workspace overrides kept, never an empty profile), when neither exists `settings.json` is created from the profile (a new workspace file overrides nothing); the Tools item stays disabled while neither exists | USER (D3) | CONFIRMED | +| Base-step keys | registry defaults except: 디버깅 시작 Shift+F5, 디버깅하지 않고 실행 Cmd+R (menu) + F5, 중지 Cmd+Shift+., 계속 unbound (current keys; D1 and `axyne_preferences_migrate_run_binding` are left to the Debug category). New commands get registry defaults (none collides) | USER (task) | CONFIRMED | +| Native terminal (merge of dev #96) | TERMINAL tab shows `_shellView` (AxyneTerminalView) in the output rect; selecting the tab (strip, menu, palette, `-selectBottomPanelTab:`) starts the shell when none runs; `_actionProcess` is the build/run process; while `_shellView` is first responder, `-performKeyEquivalent:` lets it take Cmd+C/V/A and every key without Command before the key map | USER (dev) | CONFIRMED | +| Key dispatch | `performKeyEquivalent:` feeds every key-down to the key map (debug context while a session runs); a matched enabled command runs and consumes the key; a disabled single-stroke command falls through to AppKit/menus; native keys (copy, undo, Cmd+] indent, Cmd+Q) are never taken | DELEGATED | ASSUMED | +| Chords | Cmd+K Cmd+O 폴더 열기 (Cmd+Shift+O alias), Cmd+K Cmd+R 단축키 참조, Cmd+K Cmd+S 키바인딩 (disabled); first stroke shows "(⌘K) 두 번째 키를 기다리는 중…" in the status bar (replaces the run state) for 1.5 s; a local key monitor (installed on first chord, removed in dealloc) feeds the second stroke from any control; a completed chord is always consumed; an unmatched second stroke is consumed and clears the hint | USER (D2) / DELEGATED | ASSUMED | +| Menu key equivalents | native item: first single-stroke binding of the effective key map (so chords show their alias); Axyne popup: key-map text of the primary binding (chords shown); refreshed when bindings change; Shift+/ shown as ⌘? | DELEGATED | ASSUMED | +| Customised or disabled legacy binding | the user's key replaces the command's keys, but its built-in ⌘ menu key (single ⌘ stroke of the registry/base-step default: ⌘N ⌘O ⌘S ⌘W ⌘F ⌘H ⌘⇧F ⌘P ⌘B ⌘R ⌘,) is re-added as an extra, non-primary binding so the menu shortcut keeps working as before the key map; not when the user now binds that key to another command (user wins, like the core key map) or names the command in a command-keyed binding; non-⌘ defaults (Run F5) are replaced | USER (review) / DELEGATED | ASSUMED | +| Command palette shortcut | Cmd+Shift+P from the registry (before: quick-file binding + Shift) | DELEGATED | ASSUMED | +| Palette command rows | built from the registry: commands flagged for the palette and implemented on macOS, macOS titles, keywords matched like the shared builder, key-map text; payload = palette id when the command has one (old activation kept, but a feature category that implements the command wins), else 1000 + command id (validated, then dispatched) | USER (task) / DELEGATED | ASSUMED | +| Shortcuts reference | every macOS command with a binding in the effective key map, grouped by menu (registry group title), all bindings ("⌘K ⌘O, ⇧⌘O"); replaces the menu scrape + legacy binding section | USER (task) / DELEGATED | ASSUMED | + +## Not done here / open items + +- `ui_macos.m` and every category were only syntax-checked against GNUstep + headers on Linux; nothing was compiled or run on macOS. +- Core bug: `AxyneCommandPlatform` defined in both `include/axyne/build_target.h` and + `include/axyne/commands.h` (different values). +- Toolbar labels/tooltips ("실행 F5", "⌘N") and the empty-state guide still use + their old fixed/legacy key text. diff --git a/docs/specs/ui-foundation-windows.md b/docs/specs/ui-foundation-windows.md new file mode 100644 index 0000000..f264baa --- /dev/null +++ b/docs/specs/ui-foundation-windows.md @@ -0,0 +1,116 @@ +# Base 2 (Windows half) — platform UI foundation on Windows + +Branch `feature/ui-foundation-win`. Refactor + wiring step so the later feature +categories can work in parallel without editing `ui_windows.c`. Existing +behaviour is preserved except where a decision below says otherwise. + +## Files + +| File | Owner | Purpose | +|---|---|---| +| `src/features/ui/platform/windows/ui_windows_internal.h` | base | `AxyneWindowState`, hook table, `axyne_win_*` helpers, id ranges | +| `src/features/ui/platform/windows/ui_windows.c` | base | main window, base commands (`axyne_win_base_command[_state]`), legacy dispatcher, menu API, helpers | +| `src/features/ui/platform/windows/ui_commands_windows.c` | base | feature table + hook fan-out, registry dispatch, key map, menu table, palette ">" list, shortcut reference | +| `src/features/ui/platform/windows/ui__windows.c` | feature category | one stub per category: file, edit, view, build, debug, terminal, status, tools, help, lsp_nav, diag, release | + +All files are in the WIN32 section of `src/features/ui/CMakeLists.txt`. + +## Internal API (summary; the header is authoritative) + +- **State**: `AxyneWindowState` (moved verbatim) plus `panel_tab`, + `panel_tabs_enabled`, `keymap`/`keymap_ready`, `debug_context`, + `status_text`, settings paths (`global_preferences_path` read / + `global_settings_write_path` write, same for the workspace) and one opaque + pointer per feature (`file_ui` … `release_ui`, `struct AxyneWinUi`). +- **Hook table** `AxyneWinFeature` (all members optional, run in + `AxyneWinFeatureId` order): `create`, `destroy`, `message` (any main-window + message, first wins), `command` / `command_state` (registry commands, first + wins, features before the base), `menu_populate` (slots: recent folders, + external tools), `timer`, `editor_notify` (SCNotification from the editor), + `document_activated` / `document_closed` / `document_saved`, `status_paint` + (1 = base paints no status text), `panel_paint` / `panel_layout` (per + `AxyneWinPanelTab`), `layout`. +- **Id ranges per feature**: control/menu ids `AXYNE_WIN_FEATURE_CONTROL_ID(f, n)` + (20000 + 256·f), timers `AXYNE_WIN_FEATURE_TIMER_ID(f, n)` (6000 + 16·f), + private messages `AXYNE_WM_FEATURE(f, n)` (WM_APP + 0x100 + 16·f). Registry + menu items use `AXYNE_WIN_COMMAND_MENU_ID(id)` (3000 + id). +- **Commands**: `axyne_win_command_state`, `axyne_win_command_enabled`, + `axyne_win_execute` (checks enabled), `axyne_win_dispatch` (no check), + `axyne_win_command_key_text`. +- **Key map**: `axyne_win_keymap_rebuild/destroy`, `axyne_win_handle_key`, + `axyne_win_palette_key`, `axyne_win_debugging`, `axyne_win_show_status_text`. +- **Panel**: `axyne_win_select_panel_tab`, `axyne_win_panel_tab`, + `axyne_win_panel_set_tab_enabled`, `axyne_win_panel_tab_enabled`, + `axyne_win_panel_body`. +- **Documents/workspace**: `axyne_win_active_document`, `axyne_win_empty_state`, + `axyne_win_capture_editor`, `axyne_win_open_document(path, line, column)`, + `axyne_win_goto`, `axyne_win_open_virtual_document`, `axyne_win_new_document`, + `axyne_win_save_active`, `axyne_win_save_document`, `axyne_win_close_document`, + `axyne_win_update_title`, `axyne_win_open_folder`. +- **Output/processes**: `axyne_win_output_append` (UTF-8), + `axyne_win_output_append_bytes` (ANSI process output), `axyne_win_output_clear`, + `axyne_win_process_busy`, `axyne_win_run_plan_step`. +- **Layout/paint**: `axyne_win_layout`, `axyne_win_invalidate`, + `axyne_win_sidebar_width`, `axyne_win_panel_height`, `axyne_win_apply_preferences`, + `axyne_win_color(AxyneWinColor)`, `axyne_win_fill`, `axyne_win_round_fill`, + `axyne_win_text`, `axyne_win_measure_text`, `axyne_win_wide`, `axyne_win_utf8`. +- **Menus**: `axyne_win_menu_create/add/separator/submenu/count/has_enabled/enable_at/track/destroy`, + `axyne_win_menu_add_recent_files`, `axyne_win_menu_bar_fill`. +- **Palette/shortcuts**: `axyne_win_palette_commands`, `axyne_win_show_shortcuts`. + +How a feature takes over a command: implement it in its `command_state` +(return 1 and fill enabled/checked) and `command` (return 1). Features run +before the base, so e.g. the Debug category can replace `debug.start`. +`tests/test_menu_consistency.c` checks that each `case AXYNE_COMMAND_*` of a +`*_command_state` function has a case in the matching `*_command` function. + +## Decisions + +| Decision | Value | Source | Status | +|---|---|---|---| +| Hook model | static table of 12 `AxyneWinFeature` descriptors (no runtime registration); first-wins for message/command/state/timer/paint hooks; features run before the base | DELEGATED | ASSUMED | +| Per-feature data | one opaque typed pointer per feature in the state, created lazily by the feature, released in its `destroy` hook | USER (task) | CONFIRMED | +| Bottom panel tabs | enum OUTPUT / PROBLEMS / TERMINAL / DEBUG_CONSOLE ("디버그 콘솔"); disabled tabs take no room in the strip; DEBUG_CONSOLE disabled until the Debug category enables it; OUTPUT cannot be disabled; hiding the selected tab selects OUTPUT | USER (task) / DELEGATED | ASSUMED | +| Panel tab focus | terminal → native shell grid (`shell_view`, dev #96; selecting the tab with focus starts the shell when none runs, as Ctrl+` / tab click did on dev), problems and debug console → main window (list keys), output → editor (as before) | DELEGATED | ASSUMED | +| Native terminal (merge of dev #96) | TERMINAL tab shows only `shell_view` (ConPTY grid); OUTPUT tab shows build/run/debugger text (`terminal_output`); `action_process` is the build/run process; 새 터미널 is enabled while no shell runs; 빌드 취소 terminates `action_process`; panel Stop stops the shell on the TERMINAL tab, else the action | USER (dev) | CONFIRMED | +| Menus | built from the registry in Figma order (frames 24:14189, 24:14297, 24:14400, 24:14516, 24:14603, 24:14695, 39:2402) plus existing items Figma omits (파일 이동, Git 패널, 출력 지우기, Runner 설정..., 일시 중지, 계속, 작업 영역 설정..., Git commands); titles from the registry, key text from the key map | USER (Figma) / DELEGATED | ASSUMED | +| Unimplemented items | present but disabled (no enabled entry that does nothing); a submenu whose items are all disabled is disabled, except 최근 항목 | USER (rule) / DELEGATED | ASSUMED | +| File > 환경 설정 submenu (Figma shows only the row) | 설정..., 작업 영역 설정..., settings.json 열기, 키바인딩 | DELEGATED | ASSUMED | +| File > 최근 항목 | feature slot for recent folders (File category), recent files, "최근 항목 없음" when both are empty, separator, 최근 항목 지우기 (disabled until the File category) | USER (gap report) / DELEGATED | ASSUMED | +| Tools > 새 터미널 | submenu (Figma): 새 터미널 (existing terminal), PowerShell, 명령 프롬프트, WSL, 기본 프로필 선택... (profiles disabled until the Terminal category) | USER (Figma) | ASSUMED | +| Tools > 외부 도구 | submenu with a feature slot and 외부 도구 구성… (disabled until the Tools category) | USER (Figma) | ASSUMED | +| Help > 릴리스 노트 | added after 업데이트 확인… (not in Figma, registry D21) | DELEGATED | ASSUMED | +| Radio items | checked radio items (빌드 구성, 테마, 출력/문제/터미널) are drawn with a dot, check items with ✓ | USER (Figma) | ASSUMED | +| Settings file (D3) | read `settings.json` else `preferences.json`, write `settings.json`, never delete the old file; same for `/.axyne/`; Tools item renamed "settings.json 열기"; it opens settings.json and creates it from the loaded profile when only preferences.json exists | USER (D3) | CONFIRMED | +| Keys of existing commands | unchanged: Run (디버깅하지 않고 실행) = F5, 디버깅 시작 has no key, `axyne_preferences_migrate_run_binding` is not called (the Debug category applies D1) | USER (task) | CONFIRMED | +| New default keys | registry defaults where they do not collide: Ctrl+K Ctrl+O (+ Ctrl+Shift+O) 폴더 열기, Ctrl+K Ctrl+R 단축키 참조, F9, Ctrl+, , Ctrl+Break 빌드 취소, F5/F10/F11/Shift+F11 in debug context; keys of unimplemented commands (Ctrl+Alt+B, Ctrl+Shift+N, Ctrl+Alt+S, Ctrl+Shift+W, Ctrl+K Ctrl+S, Ctrl+Shift+/ …) are inert until the command exists | USER (D2, task) | CONFIRMED | +| Disabled shortcut | the key goes to the focused control, except the 11 legacy-action shortcuts (consumed; the base action reports why it cannot run, as before) and the second stroke of a chord | DELEGATED (keeps old behaviour) | ASSUMED | +| Chords | 1.5 s timeout, status-bar hint "(Ctrl+K) 두 번째 키를 기다리는 중…" (cleared by a timer); unmatched second stroke consumed | USER (D2) | CONFIRMED | +| AltGr | right Alt with Ctrl+Alt is text input (never a shortcut); right Alt alone stays Alt (US layout Alt+Z still wraps) | USER (existing rule) | CONFIRMED | +| Debug context | the base debugger session or `state->debug_context` (set by the Debug category) | DELEGATED | ASSUMED | +| Text-field routing | palette field: only 파일 이동 / 명령 팔레트 shortcuts; terminal grid (`shell_view`): every key goes to the shell, the key map is skipped (message loop and `axyne_win_handle_key`); problems filter: Esc/Up/Down/Enter; commit message: Ctrl+Enter commits, Alt combinations and Ctrl+Shift+G/E, Ctrl+S reach the key map (all as before) | USER (existing) | CONFIRMED | +| Palette ">" | registry commands flagged PALETTE that are implemented on Windows (titles without "...", key text from the key map); rows of the 25 palette-table commands keep their palette ids, so 파일/줄/심볼 이동 switch the mode as before; all rows run through the registry (Git rows still explain a missing workspace / running Git command) | USER (task) / DELEGATED | ASSUMED | +| Palette titles | registry titles replace the old palette titles (e.g. "디버깅하지 않고 실행" for "실행", "열기" for "파일 열기") | DELEGATED | ASSUMED | +| Shortcut reference | one section per menu group with every bound command (primary and alias rows) from the effective key map; the separate "환경 설정 키 바인딩" section is gone (the key map already contains the user bindings); a chord is shown as chips Ctrl, K, Ctrl, O | DELEGATED | ASSUMED | +| Toolbar | buttons dispatch registry commands (feature overrides apply); labels "빌드 Ctrl+B", "실행 F5" unchanged | DELEGATED | ASSUMED | +| Palette/menu disabled run | commands run from the palette, menus or `axyne_win_execute` are ignored while disabled (e.g. palette 디버깅 시작 on an unsaved file no longer prints the debugger's error line) | DELEGATED | ASSUMED | + +## Core issue found (not fixed here; shared module) + +`include/axyne/commands.h` and `include/axyne/build_target.h` both define +`typedef enum AxyneCommandPlatform` with different values (commands: WINDOWS = 1, +MACOS = 2; build target: WINDOWS = 0, MACOS = 1, OTHER = 2), so no translation +unit can include both (`language.h`/`build_selector.h` pull in +`build_target.h`). `ui_windows_internal.h` works around it by renaming the +registry enum while including `commands.h`/`keymap.h` +(`AxyneCommandPlatform`, `AXYNE_WIN_COMMAND_PLATFORM`). The macOS UI will hit +the same clash. Suggested core fix: rename one of the enums (e.g. +`AxyneCommandPlatform` in commands.h/keymap.h). + +## Not verified + +- Nothing was run on Windows; the sources were compile-checked with zig/MinGW + headers only (CI uses MSVC /W4). Menus, chords, palette and dialogs were not + exercised interactively. +- `tests/test_editor_runtime_windows.c` (includes `ui_windows.c` and links + `axyne_ui`) was compile-checked, not linked or run. diff --git a/include/axyne/commands.h b/include/axyne/commands.h index 3ae6864..b3b9fee 100644 --- a/include/axyne/commands.h +++ b/include/axyne/commands.h @@ -20,17 +20,17 @@ extern "C" { * understood by axyne_key_sequence_parse (keymap.h): "Ctrl+Shift+S", * "Ctrl+K Ctrl+O" (two-stroke chord), "Cmd+Alt+Z", "F5". */ -typedef enum AxynePlatform { - AXYNE_PLATFORM_WINDOWS = 1, - AXYNE_PLATFORM_MACOS = 2 -} AxynePlatform; +typedef enum AxyneCommandPlatform { + AXYNE_COMMAND_PLATFORM_WINDOWS = 1, + AXYNE_COMMAND_PLATFORM_MACOS = 2 +} AxyneCommandPlatform; /* Platform mask for AxyneCommandInfo.platforms. */ -enum { AXYNE_PLATFORM_BOTH = AXYNE_PLATFORM_WINDOWS | AXYNE_PLATFORM_MACOS }; +enum { AXYNE_COMMAND_PLATFORM_BOTH = AXYNE_COMMAND_PLATFORM_WINDOWS | AXYNE_COMMAND_PLATFORM_MACOS }; /* The platform this translation unit was built for. Every non-Apple build - * (including the Linux test build) reports AXYNE_PLATFORM_WINDOWS. */ -AxynePlatform axyne_platform_current(void); + * (including the Linux test build) reports AXYNE_COMMAND_PLATFORM_WINDOWS. */ +AxyneCommandPlatform axyne_command_platform_current(void); /* Top-level menu an entry belongs to (CONTEXT = explorer context menu). */ typedef enum AxyneCommandGroup { @@ -217,7 +217,7 @@ typedef struct AxyneCommandInfo { const char *keywords; /* English palette keywords, space separated */ AxyneCommandGroup group; unsigned flags; /* AXYNE_COMMAND_FLAG_* */ - unsigned platforms; /* AXYNE_PLATFORM_* mask */ + unsigned platforms; /* AXYNE_COMMAND_PLATFORM_* mask */ /* Default key sequences (canonical text), [0] primary shown in menus, * [1] alias; NULL = none. */ const char *windows_keys[AXYNE_COMMAND_DEFAULT_KEYS]; @@ -240,12 +240,12 @@ AxyneCommandId axyne_command_id(const char *name); /* "" for an unknown id. */ const char *axyne_command_name(AxyneCommandId id); /* The title shown on `platform` ("" for an unknown id). */ -const char *axyne_command_title(AxyneCommandId id, AxynePlatform platform); +const char *axyne_command_title(AxyneCommandId id, AxyneCommandPlatform platform); /* Non-zero when the command exists on `platform`. */ -int axyne_command_available(AxyneCommandId id, AxynePlatform platform); +int axyne_command_available(AxyneCommandId id, AxyneCommandPlatform platform); /* Default key sequence `index` (0 primary, 1 alias) on `platform`, or NULL. */ const char *axyne_command_default_keys(AxyneCommandId id, - AxynePlatform platform, size_t index); + AxyneCommandPlatform platform, size_t index); /* Mapping from the palette command table and from the 11 legacy preference * actions; AXYNE_COMMAND_NONE when there is no mapping. */ AxyneCommandId axyne_command_from_palette(int palette_id); diff --git a/include/axyne/keymap.h b/include/axyne/keymap.h index 502dfc3..95fdafd 100644 --- a/include/axyne/keymap.h +++ b/include/axyne/keymap.h @@ -82,7 +82,7 @@ typedef enum AxyneKeyFormat { } AxyneKeyFormat; /* The menu style for a platform. */ -AxyneKeyFormat axyne_key_format_for(AxynePlatform platform); +AxyneKeyFormat axyne_key_format_for(AxyneCommandPlatform platform); /* Parses one stroke such as "Ctrl+Shift+S", "F5", "Alt+Up", "Cmd+,". * Modifier names are case-insensitive: Ctrl/Control, Shift, Alt/Option/Opt, @@ -145,7 +145,7 @@ typedef struct AxyneKeymap { AxyneKeymapEntry *entries; size_t count; size_t capacity; - AxynePlatform platform; + AxyneCommandPlatform platform; int altgr_exclusion; int has_pending; AxyneKeyStroke pending; @@ -155,7 +155,7 @@ typedef struct AxyneKeymap { /* Builds the default map of `platform` from the registry (commands that do * not exist on the platform are skipped). AltGr exclusion starts enabled on * Windows and disabled on macOS. Release with axyne_keymap_destroy. */ -AxyneStatus axyne_keymap_init(AxyneKeymap *map, AxynePlatform platform); +AxyneStatus axyne_keymap_init(AxyneKeymap *map, AxyneCommandPlatform platform); /* Frees the entries; the map can be initialised again. Safe on a zeroed map. */ void axyne_keymap_destroy(AxyneKeymap *map); @@ -228,7 +228,7 @@ AxyneStatus axyne_keymap_apply_preferences(AxyneKeymap *map, /* axyne_keymap_init followed by axyne_keymap_apply_preferences (NULL * preferences = defaults only). The map is initialised (and must be * destroyed) whenever init succeeded, even if applying reports an error. */ -AxyneStatus axyne_keymap_build(AxyneKeymap *map, AxynePlatform platform, +AxyneStatus axyne_keymap_build(AxyneKeymap *map, AxyneCommandPlatform platform, const struct AxynePreferences *preferences); /* Conversions between a stroke and the legacy preference binding form @@ -237,9 +237,9 @@ AxyneStatus axyne_keymap_build(AxyneKeymap *map, AxynePlatform platform, * Return 1 on success, 0 when the value cannot be represented (e.g. Cmd on * Windows, unknown key text, key text longer than key_capacity - 1). */ int axyne_keymap_stroke_from_legacy(unsigned int modifiers, const char *key, - AxynePlatform platform, + AxyneCommandPlatform platform, AxyneKeyStroke *stroke); -int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxynePlatform platform, +int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxyneCommandPlatform platform, unsigned int *modifiers, char *key, size_t key_capacity); diff --git a/include/axyne/preferences.h b/include/axyne/preferences.h index 01b07dd..562cdf5 100644 --- a/include/axyne/preferences.h +++ b/include/axyne/preferences.h @@ -409,6 +409,13 @@ char *axyne_preferences_read_path(const char *directory); char *axyne_preferences_write_path(const char *directory); char *axyne_preferences_workspace_directory(const char *workspace_root); void axyne_preferences_free_path(char *path); +/* Makes

/settings.json exist without losing anything (D3): when only the + * old preferences.json exists its bytes are copied verbatim (every override, + * present-field set and unknown member is kept; the old file stays). OK when + * settings.json exists afterwards; NOT_FOUND (from the read) when neither file + * exists, in which case nothing is written. */ +AxyneStatus axyne_preferences_adopt_legacy_file(const char *directory, + AxyneError *error); #ifdef __cplusplus } diff --git a/src/features/commands/commands.c b/src/features/commands/commands.c index a77003b..1efecf4 100644 --- a/src/features/commands/commands.c +++ b/src/features/commands/commands.c @@ -5,12 +5,12 @@ #include "axyne/palette.h" #include "axyne/preferences.h" -AxynePlatform axyne_platform_current(void) +AxyneCommandPlatform axyne_command_platform_current(void) { #ifdef __APPLE__ - return AXYNE_PLATFORM_MACOS; + return AXYNE_COMMAND_PLATFORM_MACOS; #else - return AXYNE_PLATFORM_WINDOWS; + return AXYNE_COMMAND_PLATFORM_WINDOWS; #endif } @@ -22,9 +22,9 @@ AxynePlatform axyne_platform_current(void) #define PAL AXYNE_COMMAND_FLAG_PALETTE #define NATIVE AXYNE_COMMAND_FLAG_NATIVE_KEY #define DBG AXYNE_COMMAND_FLAG_DEBUG_CONTEXT -#define BOTH AXYNE_PLATFORM_BOTH -#define WIN AXYNE_PLATFORM_WINDOWS -#define MAC AXYNE_PLATFORM_MACOS +#define BOTH AXYNE_COMMAND_PLATFORM_BOTH +#define WIN AXYNE_COMMAND_PLATFORM_WINDOWS +#define MAC AXYNE_COMMAND_PLATFORM_MACOS #define P(name) AXYNE_PALETTE_COMMAND_##name #define L(name) AXYNE_ACTION_##name #define NOL (-1) @@ -403,28 +403,28 @@ const char *axyne_command_name(AxyneCommandId id) return info != NULL ? info->name : ""; } -const char *axyne_command_title(AxyneCommandId id, AxynePlatform platform) +const char *axyne_command_title(AxyneCommandId id, AxyneCommandPlatform platform) { const AxyneCommandInfo *info = axyne_command_info(id); if (info == NULL) return ""; - if (platform == AXYNE_PLATFORM_MACOS && info->mac_title != NULL) + if (platform == AXYNE_COMMAND_PLATFORM_MACOS && info->mac_title != NULL) return info->mac_title; return info->title; } -int axyne_command_available(AxyneCommandId id, AxynePlatform platform) +int axyne_command_available(AxyneCommandId id, AxyneCommandPlatform platform) { const AxyneCommandInfo *info = axyne_command_info(id); return info != NULL && (info->platforms & (unsigned)platform) != 0; } const char *axyne_command_default_keys(AxyneCommandId id, - AxynePlatform platform, size_t index) + AxyneCommandPlatform platform, size_t index) { const AxyneCommandInfo *info = axyne_command_info(id); if (info == NULL || index >= AXYNE_COMMAND_DEFAULT_KEYS || (info->platforms & (unsigned)platform) == 0) return NULL; - return platform == AXYNE_PLATFORM_MACOS ? info->macos_keys[index] + return platform == AXYNE_COMMAND_PLATFORM_MACOS ? info->macos_keys[index] : info->windows_keys[index]; } diff --git a/src/features/keymap/keymap.c b/src/features/keymap/keymap.c index f3061eb..2fb17c4 100644 --- a/src/features/keymap/keymap.c +++ b/src/features/keymap/keymap.c @@ -167,9 +167,9 @@ AxyneStatus axyne_key_sequence_parse(const char *text, AxyneKeySequence *sequenc return AXYNE_STATUS_OK; } -AxyneKeyFormat axyne_key_format_for(AxynePlatform platform) +AxyneKeyFormat axyne_key_format_for(AxyneCommandPlatform platform) { - return platform == AXYNE_PLATFORM_MACOS ? AXYNE_KEY_FORMAT_MACOS + return platform == AXYNE_COMMAND_PLATFORM_MACOS ? AXYNE_KEY_FORMAT_MACOS : AXYNE_KEY_FORMAT_WINDOWS; } @@ -358,6 +358,20 @@ static int command_is_native(AxyneCommandId command) return info != NULL && (info->flags & AXYNE_COMMAND_FLAG_NATIVE_KEY) != 0; } +/* A registry default of a NATIVE_KEY command without Ctrl/Alt/Cmd (Tab, + * Shift+Tab): a text-editing key the focused control must always receive, so + * a user binding never takes it over. Other native keys (Ctrl+Z, Alt+F4 ...) + * yield to a user binding the user chose explicitly. */ +static int entry_is_hard_native(const AxyneKeymapEntry *entry) +{ + if (entry->user || !command_is_native(entry->command)) return 0; + for (size_t i = 0; i < entry->sequence.count; ++i) + if ((entry->sequence.strokes[i].mods & + (AXYNE_KEYMOD_CTRL | AXYNE_KEYMOD_ALT | AXYNE_KEYMOD_CMD)) != 0) + return 0; + return 1; +} + static int sequence_valid(const AxyneKeySequence *sequence) { if (sequence == NULL || sequence->count < 1 || @@ -389,15 +403,15 @@ static AxyneStatus keymap_append(AxyneKeymap *map, const AxyneKeySequence *seque return AXYNE_STATUS_OK; } -AxyneStatus axyne_keymap_init(AxyneKeymap *map, AxynePlatform platform) +AxyneStatus axyne_keymap_init(AxyneKeymap *map, AxyneCommandPlatform platform) { size_t count = axyne_command_count(); - if (map == NULL || (platform != AXYNE_PLATFORM_WINDOWS && - platform != AXYNE_PLATFORM_MACOS)) + if (map == NULL || (platform != AXYNE_COMMAND_PLATFORM_WINDOWS && + platform != AXYNE_COMMAND_PLATFORM_MACOS)) return AXYNE_STATUS_INVALID_ARGUMENT; memset(map, 0, sizeof(*map)); map->platform = platform; - map->altgr_exclusion = platform == AXYNE_PLATFORM_WINDOWS; + map->altgr_exclusion = platform == AXYNE_COMMAND_PLATFORM_WINDOWS; for (size_t i = 0; i < count; ++i) { const AxyneCommandInfo *info = axyne_command_at(i); for (size_t k = 0; k < AXYNE_COMMAND_DEFAULT_KEYS; ++k) { @@ -433,8 +447,19 @@ AxyneStatus axyne_keymap_set_command(AxyneKeymap *map, AxyneCommandId command, return AXYNE_STATUS_INVALID_ARGUMENT; for (size_t i = 0; i < count; ++i) if (!sequence_valid(&sequences[i])) return AXYNE_STATUS_INVALID_ARGUMENT; - for (size_t i = 0; i < map->count; ++i) - if (map->entries[i].command != command) map->entries[kept++] = map->entries[i]; + /* The command's old bindings go; so do other commands' registry defaults + * on the same keys in the same context class (a user binding wins over a + * default: Run on Ctrl+D takes Ctrl+D from 줄 복제), except hard-native + * keys. */ + for (size_t i = 0; i < map->count; ++i) { + const AxyneKeymapEntry *entry = &map->entries[i]; + int taken = 0; + if (entry->command != command && !entry->user && !entry_is_hard_native(entry) && + command_is_debug(entry->command) == command_is_debug(command)) + for (size_t k = 0; k < count && !taken; ++k) + taken = axyne_key_sequence_equal(&entry->sequence, &sequences[k]); + if (entry->command != command && !taken) map->entries[kept++] = *entry; + } map->count = kept; for (size_t i = 0; i < count; ++i) { int duplicate = 0; @@ -476,25 +501,37 @@ void axyne_keymap_set_altgr_exclusion(AxyneKeymap *map, int enabled) if (map != NULL) map->altgr_exclusion = enabled != 0; } -/* Best entry for `sequence`: a debug-context binding wins while debugging; - * debug-context bindings never match outside a debug session. Returns NULL - * when nothing matches. */ +/* Best entry for `sequence`: a debug-context binding wins while debugging + * (debug-context bindings never match outside a debug session); otherwise a + * hard-native default (Tab) wins, then a user binding, then a registry + * default. Within a class the first entry wins. Returns NULL when nothing + * matches. */ static const AxyneKeymapEntry *keymap_match(const AxyneKeymap *map, const AxyneKeySequence *sequence, unsigned int context) { - const AxyneKeymapEntry *plain = NULL; + const AxyneKeymapEntry *debug_user = NULL, *debug_default = NULL; + const AxyneKeymapEntry *hard = NULL, *user = NULL, *plain = NULL; int debugging = (context & AXYNE_KEYMAP_CONTEXT_DEBUGGING) != 0; for (size_t i = 0; i < map->count; ++i) { const AxyneKeymapEntry *entry = &map->entries[i]; if (!axyne_key_sequence_equal(&entry->sequence, sequence)) continue; if (command_is_debug(entry->command)) { - if (debugging) return entry; + if (!debugging) continue; + if (entry->user) { if (debug_user == NULL) debug_user = entry; } + else if (debug_default == NULL) debug_default = entry; + } else if (entry_is_hard_native(entry)) { + if (hard == NULL) hard = entry; + } else if (entry->user) { + if (user == NULL) user = entry; } else if (plain == NULL) { plain = entry; } } - return plain; + if (debug_user != NULL) return debug_user; + if (debug_default != NULL) return debug_default; + if (hard != NULL) return hard; + return user != NULL ? user : plain; } static int keymap_is_prefix(const AxyneKeymap *map, AxyneKeyStroke stroke, @@ -679,7 +716,7 @@ AxyneCommandId axyne_keymap_find_conflict(const AxyneKeymap *map, /* ---- preferences -------------------------------------------------------- */ int axyne_keymap_stroke_from_legacy(unsigned int modifiers, const char *key, - AxynePlatform platform, AxyneKeyStroke *stroke) + AxyneCommandPlatform platform, AxyneKeyStroke *stroke) { AxyneKeyStroke parsed; unsigned mods = 0; @@ -687,7 +724,7 @@ int axyne_keymap_stroke_from_legacy(unsigned int modifiers, const char *key, axyne_key_stroke_parse(key, &parsed) != AXYNE_STATUS_OK || parsed.mods != 0) return 0; if (modifiers & AXYNE_KEY_MODIFIER_COMMAND) - mods |= platform == AXYNE_PLATFORM_MACOS ? AXYNE_KEYMOD_CMD : AXYNE_KEYMOD_CTRL; + mods |= platform == AXYNE_COMMAND_PLATFORM_MACOS ? AXYNE_KEYMOD_CMD : AXYNE_KEYMOD_CTRL; if (modifiers & AXYNE_KEY_MODIFIER_CONTROL) mods |= AXYNE_KEYMOD_CTRL; if (modifiers & AXYNE_KEY_MODIFIER_SHIFT) mods |= AXYNE_KEYMOD_SHIFT; if (modifiers & AXYNE_KEY_MODIFIER_ALT) mods |= AXYNE_KEYMOD_ALT; @@ -696,7 +733,7 @@ int axyne_keymap_stroke_from_legacy(unsigned int modifiers, const char *key, return 1; } -int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxynePlatform platform, +int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxyneCommandPlatform platform, unsigned int *modifiers, char *key, size_t key_capacity) { AxyneKeyStroke bare; @@ -704,11 +741,11 @@ int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxynePlatform platform, char text[32]; if (stroke.key == AXYNE_KEY_NONE) return 0; if (stroke.mods & AXYNE_KEYMOD_CMD) { - if (platform != AXYNE_PLATFORM_MACOS) return 0; + if (platform != AXYNE_COMMAND_PLATFORM_MACOS) return 0; legacy |= AXYNE_KEY_MODIFIER_COMMAND; } if (stroke.mods & AXYNE_KEYMOD_CTRL) - legacy |= platform == AXYNE_PLATFORM_MACOS ? AXYNE_KEY_MODIFIER_CONTROL + legacy |= platform == AXYNE_COMMAND_PLATFORM_MACOS ? AXYNE_KEY_MODIFIER_CONTROL : AXYNE_KEY_MODIFIER_COMMAND; if (stroke.mods & AXYNE_KEYMOD_SHIFT) legacy |= AXYNE_KEY_MODIFIER_SHIFT; if (stroke.mods & AXYNE_KEYMOD_ALT) legacy |= AXYNE_KEY_MODIFIER_ALT; @@ -723,7 +760,7 @@ int axyne_keymap_stroke_to_legacy(AxyneKeyStroke stroke, AxynePlatform platform, } static int legacy_binding_equal(const AxyneKeyBinding *a, const AxyneKeyBinding *b, - AxynePlatform platform) + AxyneCommandPlatform platform) { AxyneKeyStroke x, y; if (a->enabled != b->enabled) return 0; @@ -785,7 +822,7 @@ AxyneStatus axyne_keymap_apply_preferences(AxyneKeymap *map, return result; } -AxyneStatus axyne_keymap_build(AxyneKeymap *map, AxynePlatform platform, +AxyneStatus axyne_keymap_build(AxyneKeymap *map, AxyneCommandPlatform platform, const struct AxynePreferences *preferences) { AxyneStatus status = axyne_keymap_init(map, platform); diff --git a/src/features/preferences/preferences.c b/src/features/preferences/preferences.c index 92d798f..b1dfc02 100644 --- a/src/features/preferences/preferences.c +++ b/src/features/preferences/preferences.c @@ -114,14 +114,14 @@ static const char *axyne_backend_name(AxyneDebuggerBackend backend) /* ---- legacy bindings ---------------------------------------------------- */ -static AxynePlatform axyne_build_platform(void) { return axyne_platform_current(); } +static AxyneCommandPlatform axyne_build_platform(void) { return axyne_command_platform_current(); } /* Run binding after the D1 migration: Ctrl+F5 on both platforms. On Windows * the primary modifier (COMMAND) already means Ctrl; on macOS it is Control. * The legacy default itself stays F5 until the UI adopts the new keys. */ static unsigned int axyne_migrated_run_modifiers(void) { - return axyne_build_platform() == AXYNE_PLATFORM_MACOS + return axyne_build_platform() == AXYNE_COMMAND_PLATFORM_MACOS ? AXYNE_KEY_MODIFIER_CONTROL : AXYNE_KEY_MODIFIER_COMMAND; } @@ -187,7 +187,7 @@ static int axyne_legacy_equal(const AxyneKeyBinding *a, const AxyneKeyBinding *b * this version does not know): "+". */ static int axyne_legacy_raw_text(const AxyneKeyBinding *binding, char *text, size_t capacity) { - int mac = axyne_build_platform() == AXYNE_PLATFORM_MACOS; + int mac = axyne_build_platform() == AXYNE_COMMAND_PLATFORM_MACOS; int written; if (binding->key[0] == '\0') return 0; written = snprintf(text, capacity, "%s%s%s%s%s", @@ -216,7 +216,7 @@ static int axyne_ascii_token_equal(const char *token, size_t length, const char static int axyne_legacy_from_raw_text(const char *text, unsigned int *modifiers, char *key, size_t capacity) { - int mac = axyne_build_platform() == AXYNE_PLATFORM_MACOS; + int mac = axyne_build_platform() == AXYNE_COMMAND_PLATFORM_MACOS; unsigned int bits = 0; const char *cursor = text; for (;;) { @@ -486,7 +486,7 @@ void axyne_preferences_defaults(AxynePreferences *preferences) preferences->files.auto_save_delay_ms = 1000; axyne_copy_text(preferences->build.build_directory, sizeof(preferences->build.build_directory), "build/${config}"); - preferences->debugger.backend = axyne_build_platform() == AXYNE_PLATFORM_MACOS + preferences->debugger.backend = axyne_build_platform() == AXYNE_COMMAND_PLATFORM_MACOS ? AXYNE_DEBUGGER_BACKEND_LLDB_MI : AXYNE_DEBUGGER_BACKEND_GDB; preferences->version = AXYNE_PREFERENCES_VERSION; axyne_preferences_mark_all(preferences); @@ -1506,6 +1506,39 @@ char *axyne_preferences_write_path(const char *directory) return axyne_join_path(directory, "settings.json"); } +AxyneStatus axyne_preferences_adopt_legacy_file(const char *directory, AxyneError *error) +{ + char *settings = axyne_join_path(directory, "settings.json"); + char *legacy = axyne_join_path(directory, "preferences.json"); + char *contents = NULL; + size_t length = 0; + AxyneStatus status; + if (settings == NULL || legacy == NULL) { + free(settings); + free(legacy); + if (error != NULL) { + error->code = directory == NULL ? AXYNE_STATUS_INVALID_ARGUMENT + : AXYNE_STATUS_OUT_OF_MEMORY; + (void)snprintf(error->message, sizeof(error->message), "%s", + "Unable to build the settings path"); + } + return directory == NULL ? AXYNE_STATUS_INVALID_ARGUMENT : AXYNE_STATUS_OUT_OF_MEMORY; + } + if (axyne_file_exists(settings)) { + status = AXYNE_STATUS_OK; + } else { + status = axyne_fs_read_file(legacy, &contents, &length, error); + /* The old file stays; its bytes (every override and unknown member) + * become settings.json. */ + if (status == AXYNE_STATUS_OK) + status = axyne_fs_write_file(settings, contents, length, error); + axyne_fs_free(contents); + } + free(settings); + free(legacy); + return status; +} + char *axyne_preferences_workspace_directory(const char *workspace_root) { return axyne_join_path(workspace_root, ".axyne"); diff --git a/src/features/ui/CMakeLists.txt b/src/features/ui/CMakeLists.txt index c2cf601..84b5658 100644 --- a/src/features/ui/CMakeLists.txt +++ b/src/features/ui/CMakeLists.txt @@ -1,9 +1,23 @@ if(WIN32) add_library(axyne_ui STATIC "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_commands_windows.c" "${CMAKE_CURRENT_LIST_DIR}/platform/windows/terminal_view_windows.c" "${CMAKE_CURRENT_LIST_DIR}/syntax.c" - "${CMAKE_CURRENT_LIST_DIR}/platform/windows/preferences_window_windows.c") + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/preferences_window_windows.c" + # One file per feature category (hooks: ui_windows_internal.h). + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_file_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_edit_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_view_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_build_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_debug_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_terminal_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_status_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_tools_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_help_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_lsp_nav_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_diag_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_release_windows.c") target_link_libraries(axyne_ui PRIVATE user32 gdi32 comdlg32 shell32 ole32 imm32) elseif(APPLE) add_library(axyne_ui STATIC @@ -12,8 +26,26 @@ elseif(APPLE) "${CMAKE_CURRENT_LIST_DIR}/syntax.c" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/preferences_window_macos.m" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/popup_menu_macos.m" - "${CMAKE_CURRENT_LIST_DIR}/platform/macos/git_panel_macos.m") + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/git_panel_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_commands_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_file_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_edit_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_view_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_build_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_debug_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_terminal_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_status_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_tools_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_help_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_lsp_nav_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_diag_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_release_macos.m") target_link_libraries(axyne_ui PRIVATE "-framework AppKit") + # The feature files only add categories to AxyneWorkspaceView; nothing + # references their symbols, so the app must load all Objective-C members + # (-ObjC). Not an INTERFACE option: test targets that -force_load axyne_ui + # would then load its members twice. Tests opt in themselves. + target_link_options(axyne PRIVATE "-ObjC") endif() target_include_directories(axyne_ui diff --git a/src/features/ui/platform/macos/popup_menu_macos.h b/src/features/ui/platform/macos/popup_menu_macos.h index 6c73953..c1f7175 100644 --- a/src/features/ui/platform/macos/popup_menu_macos.h +++ b/src/features/ui/platform/macos/popup_menu_macos.h @@ -16,6 +16,12 @@ typedef struct AxynePopupMenuColors { separator, check; /* 0xRRGGBB */ } AxynePopupMenuColors; +/* Key text the popup shows for an item instead of formatting its key + * equivalent (e.g. the chord "⌘K ⌘O"); "" shows none, nil (the default) + * falls back to the key equivalent. Stored on the item (copied). */ +void axyne_popup_set_key_text(NSMenuItem *item, NSString *text); +NSString *axyne_popup_key_text(NSMenuItem *item); + @class AxynePopupMenu; @protocol AxynePopupMenuDelegate diff --git a/src/features/ui/platform/macos/popup_menu_macos.m b/src/features/ui/platform/macos/popup_menu_macos.m index 7a31c44..87cef58 100644 --- a/src/features/ui/platform/macos/popup_menu_macos.m +++ b/src/features/ui/platform/macos/popup_menu_macos.m @@ -1,10 +1,27 @@ #import "popup_menu_macos.h" +#import #include #include #include "axyne/popup_menu_layout.h" +/* Key text attached to a menu item (associated object, copied). */ +static char axyne_popup_key_text_key; + +void axyne_popup_set_key_text(NSMenuItem *item, NSString *text) +{ + if (item == nil) return; + objc_setAssociatedObject(item, &axyne_popup_key_text_key, text, + OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +NSString *axyne_popup_key_text(NSMenuItem *item) +{ + if (item == nil) return nil; + return (NSString *)objc_getAssociatedObject(item, &axyne_popup_key_text_key); +} + static NSColor *axyne_popup_color(uint32_t rgb) { return [NSColor colorWithSRGBRed:(CGFloat)((rgb >> 16) & 0xff) / 255.0 @@ -140,6 +157,15 @@ - (instancetype)initWithMenu:(NSMenu *)menu colors:(const AxynePopupMenuColors * if ([item submenu] != nil) { CGFloat width = ceil([@"›" sizeWithAttributes:arrowAttributes].width); if (width > trailing) trailing = width; + } else if (axyne_popup_key_text(item) != nil) { + /* Registry items carry the key map's text, which can show + * a two-stroke chord AppKit's key equivalent cannot. */ + NSString *shown = axyne_popup_key_text(item); + if ([shown length] > 0) { + CGFloat width = ceil([shown sizeWithAttributes:accelAttributes].width); + if (width > trailing) trailing = width; + accel = shown; + } } else { NSString *equivalent = [item keyEquivalent]; if ([equivalent length] > 0) { diff --git a/src/features/ui/platform/macos/ui_build_macos.m b/src/features/ui/platform/macos/ui_build_macos.m new file mode 100644 index 0000000..d0f676b --- /dev/null +++ b/src/features/ui/platform/macos/ui_build_macos.m @@ -0,0 +1,55 @@ +/* Build feature category of the macOS workspace window: CMake, build configurations, targets, tasks.json (D10, D11). + * Owns _buildUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneBuildFeature) + +- (NSInteger)buildValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)buildPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)buildDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)buildDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)buildDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)buildEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)buildDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)buildDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)buildLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)buildLayout {} + +- (void)buildApplyPreferences {} + +- (void)buildTeardown +{ + [_buildUi release]; + _buildUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_commands_macos.m b/src/features/ui/platform/macos/ui_commands_macos.m new file mode 100644 index 0000000..685ca41 --- /dev/null +++ b/src/features/ui/platform/macos/ui_commands_macos.m @@ -0,0 +1,958 @@ +/* Command routing of the macOS workspace window: the menus built from the + * shared command registry (include/axyne/commands.h) in Figma order, the + * effective key map, menu validation and dispatch of registry commands. + * + * Every registry menu item uses -performCommand: with the AxyneCommandId as + * its tag. A command is routed to the first feature category that implements + * it (ValidateCommand hook, see ui_macos_internal.h) and otherwise to the + * existing base handler named by axyne_macos_base_action(). A command that + * nobody implements stays in the menus but disabled. */ +#include "ui_macos_internal.h" + +/* ---- menu layout ---------------------------------------------------------- */ + +enum { + AXYNE_MAC_MENU_END = 0, + AXYNE_MAC_MENU_ITEM, /* registry command */ + AXYNE_MAC_MENU_SEPARATOR, + AXYNE_MAC_MENU_SUBMENU, /* titled submenu; entries until SUBMENU_END */ + AXYNE_MAC_MENU_SUBMENU_END, + AXYNE_MAC_MENU_RECENT /* File > 최근 항목 (filled by -refreshRecentMenu) */ +}; + +typedef struct AxyneMacMenuEntry { + int kind; + AxyneCommandId command; + const char *title; /* SUBMENU / RECENT title */ +} AxyneMacMenuEntry; + +#define ITEM(id) { AXYNE_MAC_MENU_ITEM, AXYNE_COMMAND_##id, NULL } +#define SEP { AXYNE_MAC_MENU_SEPARATOR, AXYNE_COMMAND_NONE, NULL } +#define SUB(title) { AXYNE_MAC_MENU_SUBMENU, AXYNE_COMMAND_NONE, title } +#define END_SUB { AXYNE_MAC_MENU_SUBMENU_END, AXYNE_COMMAND_NONE, NULL } +#define END { AXYNE_MAC_MENU_END, AXYNE_COMMAND_NONE, NULL } + +/* Figma menu frames (file 24:14189, edit 24:14297, view 24:14400, build + * 24:14516, debug 24:14603, tools 24:14695, help 39:2402), plus the macOS + * entries Figma does not show (파일 이동, Git 패널, 일시 중지/계속, Git + * commands, Axyne 도움말, 설정 폴더 표시). Commands unavailable on macOS + * (Windows terminal profiles and debuggers) are skipped when building; + * separators never double up. */ +static const AxyneMacMenuEntry axyne_macos_file_menu[] = { + ITEM(FILE_NEW), ITEM(FILE_NEW_PROJECT), ITEM(FILE_OPEN), ITEM(FILE_OPEN_FOLDER), + SEP, + { AXYNE_MAC_MENU_RECENT, AXYNE_COMMAND_NONE, "최근 항목" }, + SEP, + ITEM(FILE_SAVE), ITEM(FILE_SAVE_AS), ITEM(FILE_SAVE_ALL), + SEP, + ITEM(FILE_AUTO_SAVE), + SUB("환경 설정"), + ITEM(TOOLS_SETTINGS), ITEM(TOOLS_WORKSPACE_SETTINGS), ITEM(TOOLS_OPEN_SETTINGS_JSON), + END_SUB, + SEP, + ITEM(FILE_CLOSE), ITEM(FILE_CLOSE_WINDOW), ITEM(FILE_EXIT), + END +}; + +static const AxyneMacMenuEntry axyne_macos_edit_menu[] = { + ITEM(EDIT_UNDO), ITEM(EDIT_REDO), + SEP, + ITEM(EDIT_CUT), ITEM(EDIT_COPY), ITEM(EDIT_PASTE), ITEM(EDIT_SELECT_ALL), + SEP, + ITEM(EDIT_FIND), ITEM(EDIT_REPLACE), ITEM(EDIT_FIND_IN_FILES), ITEM(VIEW_QUICK_OPEN), + ITEM(EDIT_GO_TO_LINE), + SEP, + ITEM(EDIT_SELECT_LINE), ITEM(EDIT_TOGGLE_LINE_COMMENT), ITEM(EDIT_TOGGLE_BLOCK_COMMENT), + ITEM(EDIT_DUPLICATE_LINE), ITEM(EDIT_MOVE_LINE_UP), ITEM(EDIT_MOVE_LINE_DOWN), + SEP, + ITEM(EDIT_INDENT), ITEM(EDIT_OUTDENT), + SEP, + ITEM(EDIT_GO_TO_DEFINITION), ITEM(EDIT_FIND_REFERENCES), ITEM(EDIT_GO_TO_SYMBOL), + END +}; + +static const AxyneMacMenuEntry axyne_macos_view_menu[] = { + ITEM(VIEW_COMMAND_PALETTE), + SEP, + ITEM(VIEW_EXPLORER), ITEM(VIEW_GIT_PANEL), ITEM(VIEW_OUTLINE), ITEM(VIEW_TOOLBAR), + ITEM(VIEW_STATUS_BAR), ITEM(VIEW_PANEL), + SEP, + ITEM(VIEW_OUTPUT), ITEM(VIEW_PROBLEMS), ITEM(VIEW_TERMINAL), + SEP, + ITEM(VIEW_ZOOM_IN), ITEM(VIEW_ZOOM_OUT), ITEM(VIEW_ZOOM_RESET), + SEP, + ITEM(VIEW_ALWAYS_SHOW_ACTIONS), ITEM(VIEW_WORD_WRAP), + SUB("테마"), + ITEM(VIEW_THEME_DARK), ITEM(VIEW_THEME_LIGHT), ITEM(VIEW_THEME_HIGH_CONTRAST), + ITEM(VIEW_THEME_CUSTOM), + SEP, + ITEM(VIEW_THEME_EDIT_JSON), + END_SUB, + SEP, + ITEM(VIEW_FULL_SCREEN), + END +}; + +static const AxyneMacMenuEntry axyne_macos_build_menu[] = { + ITEM(BUILD_BUILD), ITEM(BUILD_REBUILD), ITEM(BUILD_CLEAN), + SEP, + ITEM(BUILD_CMAKE_CONFIGURE), ITEM(BUILD_CMAKE_RECONFIGURE_CLEAN), + SEP, + SUB("빌드 구성"), + ITEM(BUILD_CONFIG_DEBUG), ITEM(BUILD_CONFIG_RELEASE), + ITEM(BUILD_CONFIG_REL_WITH_DEB_INFO), ITEM(BUILD_CONFIG_MIN_SIZE_REL), + SEP, + ITEM(BUILD_OPEN_CMAKE_PRESETS), + END_SUB, + ITEM(BUILD_SELECT_TARGET), + SEP, + ITEM(BUILD_RUN_TASK), ITEM(BUILD_EDIT_TASKS), + SEP, + ITEM(BUILD_CANCEL), + SEP, + ITEM(BUILD_CONFIGURE_RUNNER), + END +}; + +static const AxyneMacMenuEntry axyne_macos_debug_menu[] = { + ITEM(DEBUG_START), ITEM(DEBUG_RUN_WITHOUT_DEBUGGING), ITEM(DEBUG_STOP), ITEM(DEBUG_RESTART), + SEP, + ITEM(DEBUG_TOGGLE_BREAKPOINT), ITEM(DEBUG_CLEAR_BREAKPOINTS), + SEP, + ITEM(DEBUG_PAUSE), ITEM(DEBUG_CONTINUE), + ITEM(DEBUG_STEP_OVER), ITEM(DEBUG_STEP_INTO), ITEM(DEBUG_STEP_OUT), + SEP, + ITEM(DEBUG_RUN_ARGUMENTS), + SUB("외부 디버거 열기"), + ITEM(DEBUG_EXTERNAL_VISUAL_STUDIO), ITEM(DEBUG_EXTERNAL_WINDBG), + ITEM(DEBUG_EXTERNAL_X64DBG), ITEM(DEBUG_EXTERNAL_LLDB), + SEP, + ITEM(DEBUG_CONFIGURE_DEBUGGERS), + END_SUB, + END +}; + +static const AxyneMacMenuEntry axyne_macos_tools_menu[] = { + SUB("새 터미널"), + ITEM(TERMINAL_NEW), + SEP, + ITEM(TERMINAL_NEW_POWERSHELL), ITEM(TERMINAL_NEW_COMMAND_PROMPT), ITEM(TERMINAL_NEW_WSL), + ITEM(TERMINAL_NEW_ZSH), ITEM(TERMINAL_NEW_BASH), + SEP, + ITEM(TERMINAL_SELECT_DEFAULT_PROFILE), + END_SUB, + SUB("외부 도구"), + ITEM(TOOLS_CONFIGURE_EXTERNAL_TOOLS), + END_SUB, + SEP, + ITEM(TOOLS_SETTINGS), ITEM(TOOLS_WORKSPACE_SETTINGS), ITEM(TOOLS_OPEN_SETTINGS_JSON), + ITEM(TOOLS_OPEN_THEME_JSON), ITEM(TOOLS_KEYBINDINGS), + SEP, + ITEM(TOOLS_MEMORY_USAGE), ITEM(TOOLS_FILE_ASSOCIATIONS), + SEP, + ITEM(GIT_STATUS), ITEM(GIT_DIFF), ITEM(GIT_STAGE_ALL), ITEM(GIT_UNSTAGE_ALL), + SEP, + ITEM(GIT_COMMIT), ITEM(GIT_PUSH), ITEM(GIT_PULL), ITEM(GIT_LOG), + END +}; + +static const AxyneMacMenuEntry axyne_macos_help_menu[] = { + ITEM(HELP_GETTING_STARTED), ITEM(HELP_CONTENTS), ITEM(HELP_KEYBOARD_SHORTCUTS), + ITEM(HELP_OPEN_LOG_FOLDER), ITEM(HELP_SHOW_SETTINGS_FOLDER), + SEP, + ITEM(HELP_CHECK_FOR_UPDATES), ITEM(HELP_REPORT_ISSUE), ITEM(HELP_RELEASE_NOTES), + SEP, + ITEM(HELP_LICENSES), ITEM(HELP_ABOUT), + END +}; + +#undef ITEM +#undef SEP +#undef SUB +#undef END_SUB +#undef END + +/* Bar order: 파일, 편집, 보기, 빌드, 디버그, 도구, 도움말 (AXYNE_UI_MENU_COUNT). */ +static const AxyneMacMenuEntry *const axyne_macos_menus[] = { + axyne_macos_file_menu, axyne_macos_edit_menu, axyne_macos_view_menu, + axyne_macos_build_menu, axyne_macos_debug_menu, axyne_macos_tools_menu, + axyne_macos_help_menu +}; + +/* ---- base handlers -------------------------------------------------------- */ + +/* The handler a registry command had before the registry existed (the + * selectors the old hand-written menus used), or NULL when no base handler + * exists (the command waits for its feature category). `*tag` is the sender + * tag the handler reads (panel index, debugger command, LSP request kind); + * `*application` is set for NSApplication actions. */ +static SEL axyne_macos_base_action(AxyneCommandId command, NSInteger *tag, BOOL *application) +{ + *tag = 0; + *application = NO; + switch (command) { + case AXYNE_COMMAND_FILE_NEW: return @selector(newDocument:); + case AXYNE_COMMAND_FILE_OPEN: return @selector(openDocument:); + case AXYNE_COMMAND_FILE_OPEN_FOLDER: return @selector(openWorkspace:); + case AXYNE_COMMAND_FILE_SAVE: return @selector(saveDocument:); + case AXYNE_COMMAND_FILE_SAVE_AS: return @selector(saveDocumentAs:); + case AXYNE_COMMAND_FILE_CLOSE: return @selector(closeDocument:); + case AXYNE_COMMAND_FILE_EXIT: *application = YES; return @selector(terminate:); + case AXYNE_COMMAND_EDIT_UNDO: return @selector(undo:); + case AXYNE_COMMAND_EDIT_REDO: return @selector(redo:); + case AXYNE_COMMAND_EDIT_CUT: return @selector(editCut:); + case AXYNE_COMMAND_EDIT_COPY: return @selector(editCopy:); + case AXYNE_COMMAND_EDIT_PASTE: return @selector(editPaste:); + case AXYNE_COMMAND_EDIT_SELECT_ALL: return @selector(editSelectAll:); + case AXYNE_COMMAND_EDIT_FIND: return @selector(findInDocument:); + case AXYNE_COMMAND_EDIT_REPLACE: return @selector(replaceInDocument:); + case AXYNE_COMMAND_EDIT_FIND_IN_FILES: return @selector(searchWorkspace:); + case AXYNE_COMMAND_EDIT_GO_TO_LINE: return @selector(goToLine:); + case AXYNE_COMMAND_EDIT_SELECT_LINE: return @selector(selectLine:); + case AXYNE_COMMAND_EDIT_TOGGLE_LINE_COMMENT: return @selector(toggleLineComment:); + case AXYNE_COMMAND_EDIT_DUPLICATE_LINE: return @selector(duplicateLine:); + case AXYNE_COMMAND_EDIT_MOVE_LINE_UP: return @selector(moveLineUp:); + case AXYNE_COMMAND_EDIT_MOVE_LINE_DOWN: return @selector(moveLineDown:); + case AXYNE_COMMAND_EDIT_INDENT: return @selector(indentSelection:); + case AXYNE_COMMAND_EDIT_OUTDENT: return @selector(outdentSelection:); + case AXYNE_COMMAND_EDIT_GO_TO_DEFINITION: return @selector(navigateLspReferences:); + case AXYNE_COMMAND_EDIT_FIND_REFERENCES: *tag = 1; return @selector(navigateLspReferences:); + case AXYNE_COMMAND_EDIT_GO_TO_SYMBOL: return @selector(goToSymbol:); + case AXYNE_COMMAND_VIEW_COMMAND_PALETTE: return @selector(showCommandPalette:); + case AXYNE_COMMAND_VIEW_QUICK_OPEN: return @selector(quickFile:); + case AXYNE_COMMAND_VIEW_EXPLORER: return @selector(toggleExplorer:); + case AXYNE_COMMAND_VIEW_GIT_PANEL: return @selector(showGitPanel:); + case AXYNE_COMMAND_VIEW_PANEL: return @selector(togglePanel:); + case AXYNE_COMMAND_VIEW_OUTPUT: *tag = AXYNE_BOTTOM_PANEL_OUTPUT; return @selector(selectPanel:); + case AXYNE_COMMAND_VIEW_PROBLEMS: *tag = AXYNE_BOTTOM_PANEL_PROBLEMS; return @selector(selectPanel:); + case AXYNE_COMMAND_VIEW_TERMINAL: *tag = AXYNE_BOTTOM_PANEL_TERMINAL; return @selector(selectPanel:); + case AXYNE_COMMAND_VIEW_CLEAR_OUTPUT: return @selector(clearOutput:); + case AXYNE_COMMAND_VIEW_ZOOM_IN: return @selector(zoomInEditor:); + case AXYNE_COMMAND_VIEW_ZOOM_OUT: return @selector(zoomOutEditor:); + case AXYNE_COMMAND_VIEW_ZOOM_RESET: return @selector(zoomResetEditor:); + case AXYNE_COMMAND_VIEW_WORD_WRAP: return @selector(toggleWordWrap:); + case AXYNE_COMMAND_BUILD_BUILD: return @selector(buildDocument:); + case AXYNE_COMMAND_BUILD_CANCEL: return @selector(cancelBuild:); + case AXYNE_COMMAND_BUILD_CONFIGURE_RUNNER: return @selector(configureRunnerAction:); + case AXYNE_COMMAND_DEBUG_START: return @selector(startDebugger:); + case AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING: return @selector(runDocument:); + case AXYNE_COMMAND_DEBUG_STOP: return @selector(stopDebugger:); + case AXYNE_COMMAND_DEBUG_PAUSE: *tag = AXYNE_DEBUGGER_PAUSE; return @selector(debugCommand:); + case AXYNE_COMMAND_DEBUG_CONTINUE: *tag = AXYNE_DEBUGGER_CONTINUE; return @selector(debugCommand:); + case AXYNE_COMMAND_DEBUG_TOGGLE_BREAKPOINT: return @selector(toggleBreakpoint:); + case AXYNE_COMMAND_DEBUG_CLEAR_BREAKPOINTS: return @selector(clearBreakpoints:); + case AXYNE_COMMAND_DEBUG_STEP_OVER: *tag = AXYNE_DEBUGGER_STEP_OVER; return @selector(debugCommand:); + case AXYNE_COMMAND_DEBUG_STEP_INTO: *tag = AXYNE_DEBUGGER_STEP_INTO; return @selector(debugCommand:); + case AXYNE_COMMAND_DEBUG_STEP_OUT: *tag = AXYNE_DEBUGGER_STEP_OUT; return @selector(debugCommand:); + case AXYNE_COMMAND_TERMINAL_NEW: return @selector(startTerminal:); + case AXYNE_COMMAND_TOOLS_SETTINGS: return @selector(showGlobalPreferences:); + case AXYNE_COMMAND_TOOLS_WORKSPACE_SETTINGS: return @selector(showWorkspacePreferences:); + case AXYNE_COMMAND_TOOLS_OPEN_SETTINGS_JSON: return @selector(openPreferencesFile:); + case AXYNE_COMMAND_GIT_STATUS: return @selector(showGitStatus:); + case AXYNE_COMMAND_GIT_DIFF: return @selector(showGitDiff:); + case AXYNE_COMMAND_GIT_STAGE_ALL: return @selector(stageAllGitChanges:); + case AXYNE_COMMAND_GIT_UNSTAGE_ALL: return @selector(unstageAllGitChanges:); + case AXYNE_COMMAND_GIT_COMMIT: return @selector(commitGitChanges:); + case AXYNE_COMMAND_GIT_PUSH: return @selector(pushGitChanges:); + case AXYNE_COMMAND_GIT_PULL: return @selector(pullGitChanges:); + case AXYNE_COMMAND_GIT_LOG: return @selector(showGitLog:); + case AXYNE_COMMAND_HELP_KEYBOARD_SHORTCUTS: return @selector(showKeyboardShortcuts:); + case AXYNE_COMMAND_HELP_REPORT_ISSUE: return @selector(reportIssue:); + case AXYNE_COMMAND_HELP_ABOUT: *application = YES; return @selector(orderFrontStandardAboutPanel:); + case AXYNE_COMMAND_HELP_CONTENTS: return @selector(openHelp:); + case AXYNE_COMMAND_HELP_SHOW_SETTINGS_FOLDER: return @selector(showSettingsFolder:); + case AXYNE_COMMAND_EXPLORER_NEW_FILE: return @selector(newExplorerFile:); + case AXYNE_COMMAND_EXPLORER_NEW_FOLDER: return @selector(newExplorerFolder:); + case AXYNE_COMMAND_EXPLORER_RENAME: return @selector(renameExplorerItem:); + case AXYNE_COMMAND_EXPLORER_DELETE: return @selector(removeExplorerItem:); + default: return NULL; + } +} + +/* ---- key map ------------------------------------------------------------- */ + +/* Base step: the registry defaults replace the hand-written macOS keys + * except where an existing command's key would change. These commands keep + * the keys they had (the Debug category applies D1 later). */ +static void axyne_macos_keymap_keep_current_keys(AxyneKeymap *map) +{ + static const char *const start[] = { "Shift+F5" }; + static const char *const run[] = { "Cmd+R", "F5" }; + static const char *const stop[] = { "Cmd+Shift+." }; + (void)axyne_keymap_set_command_text(map, "debug.start", start, 1); + (void)axyne_keymap_set_command_text(map, "debug.runWithoutDebugging", run, 2); + (void)axyne_keymap_set_command_text(map, "debug.stop", stop, 1); + /* 계속 had no key: F5 stays Run even while debugging. */ + (void)axyne_keymap_set_command_text(map, "debug.continue", NULL, 0); +} + +/* The built-in menu keys (single ⌘ strokes) of the legacy-action commands + * before the user's bindings are applied: before the key map, ⌘N/⌘O/⌘S/⌘W/ + * ⌘F/⌘H/⌘B/⌘R were fixed menu key equivalents that kept working whatever + * the legacy preference binding said. */ +typedef struct AxyneMacMenuKeys { + AxyneCommandId command; + AxyneKeySequence keys[AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count; +} AxyneMacMenuKeys; + +static size_t axyne_macos_capture_menu_keys(const AxyneKeymap *map, AxyneMacMenuKeys *out) +{ + size_t used = 0; + for (int action = 0; action < (int)AXYNE_ACTION_COUNT; ++action) { + AxyneCommandId command = axyne_command_from_legacy_action(action); + AxyneKeySequence bound[AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count; + AxyneMacMenuKeys *slot = &out[used]; + if (command == AXYNE_COMMAND_NONE || + !axyne_command_available(command, axyne_command_platform_current())) + continue; + count = axyne_keymap_bindings(map, command, bound, AXYNE_COMMAND_DEFAULT_KEYS); + if (count > AXYNE_COMMAND_DEFAULT_KEYS) count = AXYNE_COMMAND_DEFAULT_KEYS; + memset(slot, 0, sizeof(*slot)); + slot->command = command; + for (size_t i = 0; i < count; ++i) + if (bound[i].count == 1 && (bound[i].strokes[0].mods & AXYNE_KEYMOD_CMD) != 0) + slot->keys[slot->count++] = bound[i]; + if (slot->count != 0) ++used; + } + return used; +} + +/* A custom or disabled legacy binding replaced the command's bindings; its + * built-in ⌘ menu key comes back as an extra (non-primary) binding, so the + * menu shortcut keeps working and the user's key stays the one shown. Not + * when the user now binds that key to another command (the user wins, as in + * the core key map) or names the command in a command-keyed binding (an + * explicit choice of all its keys). */ +static void axyne_macos_restore_menu_keys(AxyneKeymap *map, const AxynePreferences *preferences, + const AxyneMacMenuKeys *saved, size_t savedCount) +{ + for (size_t i = 0; i < savedCount; ++i) { + AxyneKeySequence merged[2 * AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count, added = 0; + AxyneCommandId command = saved[i].command; + if (axyne_preferences_find_command_binding(preferences, + axyne_command_name(command)) != NULL) + continue; + count = axyne_keymap_bindings(map, command, merged, AXYNE_COMMAND_DEFAULT_KEYS); + if (count > AXYNE_COMMAND_DEFAULT_KEYS) count = AXYNE_COMMAND_DEFAULT_KEYS; + for (size_t k = 0; k < saved[i].count; ++k) { + AxyneCommandId owner = axyne_keymap_lookup(map, &saved[i].keys[k], 0); + BOOL present = NO; + for (size_t j = 0; j < count && !present; ++j) + present = axyne_key_sequence_equal(&merged[j], &saved[i].keys[k]) != 0; + if (present || (owner != AXYNE_COMMAND_NONE && owner != command)) continue; + merged[count + added++] = saved[i].keys[k]; + } + if (added != 0) (void)axyne_keymap_set_command(map, command, merged, count + added); + } +} + +/* AppKit key equivalent of one stroke. Letters are lower case with an + * explicit modifier mask (Shift is never implied); Shift+/ is shown as ⌘? + * like the old Help menu. NO when AppKit cannot express the key. */ +static BOOL axyne_macos_key_equivalent(AxyneKeyStroke stroke, NSString **key, + NSEventModifierFlags *mask) +{ + unichar character = 0; + NSEventModifierFlags flags = 0; + if ((stroke.mods & AXYNE_KEYMOD_SHIFT) != 0) flags |= NSEventModifierFlagShift; + if ((stroke.mods & AXYNE_KEYMOD_CTRL) != 0) flags |= NSEventModifierFlagControl; + if ((stroke.mods & AXYNE_KEYMOD_ALT) != 0) flags |= NSEventModifierFlagOption; + if ((stroke.mods & AXYNE_KEYMOD_CMD) != 0) flags |= NSEventModifierFlagCommand; + if (stroke.key >= 'A' && stroke.key <= 'Z') character = (unichar)(stroke.key - 'A' + 'a'); + else if (stroke.key >= AXYNE_KEY_F1 && stroke.key <= AXYNE_KEY_F24) + character = (unichar)(NSF1FunctionKey + (stroke.key - AXYNE_KEY_F1)); + else if (stroke.key == '/' && (flags & NSEventModifierFlagShift) != 0) { + character = '?'; + flags &= ~NSEventModifierFlagShift; + } else if (stroke.key > ' ' && stroke.key < 0x7f) character = (unichar)stroke.key; + else { + switch (stroke.key) { + case ' ': character = ' '; break; + case AXYNE_KEY_UP: character = NSUpArrowFunctionKey; break; + case AXYNE_KEY_DOWN: character = NSDownArrowFunctionKey; break; + case AXYNE_KEY_LEFT: character = NSLeftArrowFunctionKey; break; + case AXYNE_KEY_RIGHT: character = NSRightArrowFunctionKey; break; + case AXYNE_KEY_HOME: character = NSHomeFunctionKey; break; + case AXYNE_KEY_END: character = NSEndFunctionKey; break; + case AXYNE_KEY_PAGE_UP: character = NSPageUpFunctionKey; break; + case AXYNE_KEY_PAGE_DOWN: character = NSPageDownFunctionKey; break; + case AXYNE_KEY_INSERT: character = NSInsertFunctionKey; break; + case AXYNE_KEY_DELETE: character = NSDeleteFunctionKey; break; + case AXYNE_KEY_BACKSPACE: character = 0x08; break; + case AXYNE_KEY_TAB: character = '\t'; break; + case AXYNE_KEY_ENTER: character = '\r'; break; + case AXYNE_KEY_ESCAPE: character = 0x1b; break; + case AXYNE_KEY_BREAK: character = NSPauseFunctionKey; break; + default: return NO; + } + } + *key = [NSString stringWithCharacters:&character length:1]; + *mask = flags; + return YES; +} + +/* ---- keyboard ------------------------------------------------------------- */ + +static uint64_t axyne_macos_uptime_ms(void) +{ + return (uint64_t)([[NSProcessInfo processInfo] systemUptime] * 1000.0); +} + +/* Key-down event -> key map stroke. charactersIgnoringModifiers keeps Shift + * applied; the key map folds shifted punctuation back to its base key. */ +static BOOL axyne_macos_stroke_from_event(NSEvent *event, AxyneKeyStroke *stroke) +{ + NSString *characters = [event charactersIgnoringModifiers]; + NSEventModifierFlags flags = [event modifierFlags]; + uint16_t key; + if ([characters length] == 0) return NO; + key = axyne_key_from_mac_character((unsigned int)[characters characterAtIndex:0]); + if (key == AXYNE_KEY_NONE) return NO; + stroke->key = key; + stroke->mods = 0; + if ((flags & NSEventModifierFlagShift) != 0) stroke->mods |= AXYNE_KEYMOD_SHIFT; + if ((flags & NSEventModifierFlagControl) != 0) stroke->mods |= AXYNE_KEYMOD_CTRL; + if ((flags & NSEventModifierFlagOption) != 0) stroke->mods |= AXYNE_KEYMOD_ALT; + if ((flags & NSEventModifierFlagCommand) != 0) stroke->mods |= AXYNE_KEYMOD_CMD; + return YES; +} + +/* ---- command palette rows -------------------------------------------------- */ + +typedef struct AxyneMacPaletteCandidate { + AxyneCommandId command; + int score; + size_t start, length; /* title highlight (0 length = keyword match) */ + size_t order; /* registry order breaks ties */ +} AxyneMacPaletteCandidate; + +static int axyne_macos_palette_compare(const void *left, const void *right) +{ + const AxyneMacPaletteCandidate *a = (const AxyneMacPaletteCandidate *)left; + const AxyneMacPaletteCandidate *b = (const AxyneMacPaletteCandidate *)right; + if (a->score != b->score) return a->score > b->score ? -1 : 1; + return a->order < b->order ? -1 : (a->order > b->order ? 1 : 0); +} + +@implementation AxyneWorkspaceView (AxyneCommandRouting) + +- (void)rebuildKeymap +{ + AxyneStatus status; + AxyneMacMenuKeys menuKeys[AXYNE_ACTION_COUNT]; + size_t menuKeyCount; + axyne_keymap_destroy(&_keymap); + memset(&_keymap, 0, sizeof(_keymap)); + _keymapReady = NO; + if (axyne_keymap_init(&_keymap, axyne_command_platform_current()) != AXYNE_STATUS_OK) { + axyne_keymap_destroy(&_keymap); + memset(&_keymap, 0, sizeof(_keymap)); + [self refreshMenuKeyEquivalents]; + [self syncChordHint]; + return; + } + _keymapReady = YES; + axyne_macos_keymap_keep_current_keys(&_keymap); + menuKeyCount = axyne_macos_capture_menu_keys(&_keymap, menuKeys); + /* User bindings (legacy actions and command-keyed entries) win; a + * malformed entry is skipped (the settings loader logged it). */ + status = axyne_keymap_apply_preferences(&_keymap, &_preferences); + (void)status; + axyne_macos_restore_menu_keys(&_keymap, &_preferences, menuKeys, menuKeyCount); + [self refreshMenuKeyEquivalents]; + [self syncChordHint]; /* a pending chord did not survive the rebuild */ +} + +/* Menu text of the command's primary binding ("" when unbound). */ +- (NSString *)keyTextForCommand:(AxyneCommandId)command +{ + char text[64]; + NSString *result; + if (!_keymapReady) return @""; + (void)axyne_keymap_display(&_keymap, command, text, sizeof(text)); + result = [NSString stringWithUTF8String:text]; + return result != nil ? result : @""; +} + +/* Native key equivalent (first single-stroke binding, so a chord's alias) + * and the Axyne popup's key text (primary binding, chords included). */ +- (void)applyKeyEquivalentToItem:(NSMenuItem *)item command:(AxyneCommandId)command +{ + AxyneKeySequence sequences[AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count = _keymapReady ? axyne_keymap_bindings(&_keymap, command, sequences, + AXYNE_COMMAND_DEFAULT_KEYS) : 0; + NSString *key = @""; + NSEventModifierFlags mask = 0; + if (count > AXYNE_COMMAND_DEFAULT_KEYS) count = AXYNE_COMMAND_DEFAULT_KEYS; + for (size_t i = 0; i < count; ++i) { + NSString *candidate = nil; + NSEventModifierFlags candidateMask = 0; + if (sequences[i].count != 1) continue; + if (!axyne_macos_key_equivalent(sequences[i].strokes[0], &candidate, &candidateMask)) + continue; + key = candidate; + mask = candidateMask; + break; + } + [item setKeyEquivalent:key]; + [item setKeyEquivalentModifierMask:mask]; + axyne_popup_set_key_text(item, [self keyTextForCommand:command]); +} + +static void axyne_macos_refresh_keys(AxyneWorkspaceView *view, NSMenu *menu) +{ + for (NSMenuItem *item in [menu itemArray]) { + if ([item submenu] != nil) axyne_macos_refresh_keys(view, [item submenu]); + else if ([item action] == @selector(performCommand:) && [item target] == view) + [view applyKeyEquivalentToItem:item command:(AxyneCommandId)[item tag]]; + } +} + +- (void)refreshMenuKeyEquivalents +{ + NSMenu *mainMenu = [NSApp mainMenu]; + if (mainMenu != nil) axyne_macos_refresh_keys(self, mainMenu); +} + +/* A menu item for a registry command: macOS title, -performCommand: with + * the command id as tag, key equivalent from the key map. Autoreleased. */ +- (NSMenuItem *)commandMenuItem:(AxyneCommandId)command +{ + NSString *title = [NSString stringWithUTF8String: + axyne_command_title(command, axyne_command_platform_current())]; + NSMenuItem *item = [[[NSMenuItem alloc] initWithTitle:title != nil ? title : @"" + action:@selector(performCommand:) keyEquivalent:@""] autorelease]; + [item setTarget:self]; + [item setTag:(NSInteger)command]; + [self applyKeyEquivalentToItem:item command:command]; + return item; +} + +/* Appends entries up to the matching SUBMENU_END / END; returns the index + * after it. Unavailable commands are skipped and separators are collapsed + * (none leading, trailing or doubled). */ +- (size_t)appendMenuEntries:(const AxyneMacMenuEntry *)entries from:(size_t)index + toMenu:(NSMenu *)menu +{ + BOOL pendingSeparator = NO; + while (entries[index].kind != AXYNE_MAC_MENU_END && + entries[index].kind != AXYNE_MAC_MENU_SUBMENU_END) { + const AxyneMacMenuEntry *entry = &entries[index++]; + NSMenuItem *item = nil; + if (entry->kind == AXYNE_MAC_MENU_SEPARATOR) { + pendingSeparator = [menu numberOfItems] > 0; + continue; + } + if (entry->kind == AXYNE_MAC_MENU_ITEM) { + if (!axyne_command_available(entry->command, axyne_command_platform_current())) continue; + item = [self commandMenuItem:entry->command]; + } else { + NSString *title = [NSString stringWithUTF8String:entry->title]; + NSMenu *submenu = [[NSMenu alloc] initWithTitle:title != nil ? title : @""]; + item = [[[NSMenuItem alloc] initWithTitle:title != nil ? title : @"" + action:NULL keyEquivalent:@""] autorelease]; + [item setSubmenu:submenu]; + if (entry->kind == AXYNE_MAC_MENU_SUBMENU) { + index = [self appendMenuEntries:entries from:index toMenu:submenu]; + if (entries[index].kind == AXYNE_MAC_MENU_SUBMENU_END) ++index; + } else { + [self setRecentMenu:submenu]; + } + [submenu release]; + } + if (pendingSeparator) [menu addItem:[NSMenuItem separatorItem]]; + pendingSeparator = NO; + [menu addItem:item]; + } + return index; +} + +/* The submenu of top-level bar position `index`, retained (+1). */ +- (NSMenu *)newMenuBarSubmenu:(NSUInteger)index +{ + NSMenu *menu = [[NSMenu alloc] initWithTitle:axyne_macos_menu_title(index)]; + if (index < sizeof(axyne_macos_menus) / sizeof(axyne_macos_menus[0])) + (void)[self appendMenuEntries:axyne_macos_menus[index] from:0 toMenu:menu]; + return menu; +} + +/* ---- validation and dispatch ---------------------------------------------- */ + +- (NSInteger)featureValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + NSInteger state; +#define AXYNE_MAC_HOOK(feature) \ + state = [self feature##ValidateCommand:command item:item]; \ + if (state != AXYNE_MAC_COMMAND_UNHANDLED) return state; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)commandImplemented:(AxyneCommandId)command +{ + NSInteger tag; + BOOL application; + if (!axyne_command_available(command, axyne_command_platform_current())) return NO; + if ([self featureValidateCommand:command item:nil] != AXYNE_MAC_COMMAND_UNHANDLED) + return YES; + return axyne_macos_base_action(command, &tag, &application) != NULL; +} + +- (BOOL)validateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + NSInteger state, tag; + BOOL application; + SEL action; + NSMenuItem *proxy; + BOOL enabled; + if (!axyne_command_available(command, axyne_command_platform_current())) return NO; + state = [self featureValidateCommand:command item:item]; + if (state != AXYNE_MAC_COMMAND_UNHANDLED) return state == AXYNE_MAC_COMMAND_ENABLED; + action = axyne_macos_base_action(command, &tag, &application); + if (action == NULL) { + [item setState:NSControlStateValueOff]; + return NO; /* not implemented yet: shown, disabled */ + } + if (application) return YES; + /* The base rules in -validateMenuItem: are keyed by the old selectors; + * evaluate them on a proxy item and copy back the check state. */ + proxy = [[NSMenuItem alloc] initWithTitle:@"" action:action keyEquivalent:@""]; + [proxy setTag:tag]; + enabled = [self validateMenuItem:proxy]; + [item setState:[proxy state]]; + [proxy release]; + return enabled; +} + +- (BOOL)dispatchCommand:(AxyneCommandId)command sender:(id)sender +{ + NSInteger tag; + BOOL application; + SEL action; + NSMenuItem *proxy; +#define AXYNE_MAC_HOOK(feature) \ + if ([self feature##PerformCommand:command sender:sender]) return YES; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK + action = axyne_macos_base_action(command, &tag, &application); + if (action == NULL) return NO; + /* Base handlers read the sender's tag (panel, debugger command, LSP + * request), so they get a proxy carrying it. */ + proxy = [[[NSMenuItem alloc] initWithTitle:@"" action:action keyEquivalent:@""] autorelease]; + [proxy setTag:tag]; + if (application) [NSApp sendAction:action to:NSApp from:proxy]; + else [self performSelector:action withObject:proxy]; + return YES; +} + +- (BOOL)runCommand:(AxyneCommandId)command +{ + if (![self validateCommand:command item:nil]) return NO; + return [self dispatchCommand:command sender:nil]; +} + +/* Action of every registry menu item (tag = AxyneCommandId). */ +- (void)performCommand:(id)sender +{ + (void)[self dispatchCommand:(AxyneCommandId)[sender tag] sender:sender]; +} + +/* ---- keyboard: key map dispatch and chords --------------------------------- */ + +- (unsigned int)keymapContext +{ + return axyne_debugger_is_active(&_debugger) ? AXYNE_KEYMAP_CONTEXT_DEBUGGING : 0u; +} + +/* Status-bar hint follows the key map: shown while a chord waits for its + * second stroke, cleared once it resolves or expires. */ +- (void)syncChordHint +{ + char text[128]; + uint64_t now = axyne_macos_uptime_ms(); + NSString *hint = nil; + if (_keymapReady && axyne_keymap_chord_hint(&_keymap, now, text, sizeof(text)) > 0) + hint = [NSString stringWithUTF8String:text]; + else if (_keymapReady) + axyne_keymap_cancel_pending(&_keymap); + if (hint == nil && _chordHint == nil) return; + if (hint != nil && _chordHint != nil && [hint isEqualToString:_chordHint]) return; + [_chordHint release]; + _chordHint = [hint copy]; + [self setNeedsDisplayInRect:NSMakeRect(0, NSHeight([self bounds]) - AXYNE_STATUS, + NSWidth([self bounds]), AXYNE_STATUS)]; +} + +/* One expiry check just after the chord timeout. The block holds only an + * assign-only box, never the view (dealloc clears the box's target). */ +- (void)scheduleChordExpiry +{ + AxyneDiscoveryBox *box; + if (_chordTimerBox != NULL) _chordTimerBox->target = nil; /* its block frees it */ + box = (AxyneDiscoveryBox *)calloc(1, sizeof(*box)); + _chordTimerBox = box; + if (box == NULL) return; + box->target = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(AXYNE_KEYMAP_CHORD_TIMEOUT_MS + 50) * (int64_t)NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + if (box->target != nil) { + AxyneWorkspaceView *view = box->target; + view->_chordTimerBox = NULL; + [view syncChordHint]; + } + free(box); + }); +} + +/* The second stroke of a chord may be a plain key that never becomes a key + * equivalent (and would be typed into the editor), so while a chord is + * pending a local monitor feeds every key-down of this window to the key map + * first. Installed on the first chord and kept until dealloc; it holds only + * an assign-only box. The box is allocated once and reused when AppKit + * returns no monitor (the next chord tries again); -teardownKeyboard frees + * it. */ +- (void)installChordMonitor +{ + AxyneDiscoveryBox *box; + if (_chordMonitor != nil) return; + box = _chordMonitorBox; + if (box == NULL) { + box = (AxyneDiscoveryBox *)calloc(1, sizeof(*box)); + if (box == NULL) return; + _chordMonitorBox = box; + } + box->target = self; + _chordMonitor = [[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown + handler:^NSEvent *(NSEvent *event) { + AxyneWorkspaceView *view = box->target; + return view != nil ? [view chordMonitorEvent:event] : event; + }] retain]; +} + +/* Applies a key map result; YES when the key is consumed. A completed chord + * is always consumed (its second stroke must not reach a menu), even when the + * command is disabled; a disabled single-stroke command falls through. */ +- (BOOL)applyKeymapResult:(AxyneKeymapResult)result command:(AxyneCommandId)command + completesChord:(BOOL)completesChord +{ + BOOL consumed = NO; + /* The command may close the window and release this view; keep it alive + * until the current event has been handled. */ + [[self retain] autorelease]; + switch (result) { + case AXYNE_KEYMAP_PENDING: + [self installChordMonitor]; + [self scheduleChordExpiry]; + consumed = YES; + break; + case AXYNE_KEYMAP_ABORTED: + consumed = YES; + break; + case AXYNE_KEYMAP_COMMAND: + [self syncChordHint]; + consumed = [self runCommand:command] || completesChord; + break; + case AXYNE_KEYMAP_NONE: + break; + } + [self syncChordHint]; + return consumed; +} + +- (BOOL)handleKeyEvent:(NSEvent *)event +{ + AxyneKeyStroke stroke; + AxyneCommandId command = AXYNE_COMMAND_NONE; + AxyneKeymapResult result; + uint64_t now = axyne_macos_uptime_ms(); + BOOL pending; + if (!_keymapReady || [event type] != NSEventTypeKeyDown) return NO; + if (!axyne_macos_stroke_from_event(event, &stroke)) return NO; + pending = axyne_keymap_pending(&_keymap, now, NULL) != 0; + result = axyne_keymap_feed(&_keymap, stroke, now, [self keymapContext], &command); + return [self applyKeymapResult:result command:command completesChord:pending]; +} + +- (NSEvent *)chordMonitorEvent:(NSEvent *)event +{ + AxyneKeyStroke stroke; + AxyneCommandId command = AXYNE_COMMAND_NONE; + AxyneKeymapResult result; + uint64_t now = axyne_macos_uptime_ms(); + /* The monitor holds only an assign-only box: a chord command that + * releases the view must not leave this method running on a freed + * object. */ + [[self retain] autorelease]; + if (!_keymapReady || [event window] != [self window] || + !axyne_keymap_pending(&_keymap, now, NULL)) { + if (_chordHint != nil) [self syncChordHint]; + return event; /* not in a chord: normal key handling */ + } + if (!axyne_macos_stroke_from_event(event, &stroke)) return event; + result = axyne_keymap_feed(&_keymap, stroke, now, [self keymapContext], &command); + if (result == AXYNE_KEYMAP_NONE) { [self syncChordHint]; return event; } + (void)[self applyKeymapResult:result command:command completesChord:YES]; + return nil; +} + +- (void)teardownKeyboard +{ + if (_chordMonitor != nil) { + [NSEvent removeMonitor:_chordMonitor]; + [_chordMonitor release]; + _chordMonitor = nil; + } + free(_chordMonitorBox); /* the monitor (and its block) is gone */ + _chordMonitorBox = NULL; + if (_chordTimerBox != NULL) { + _chordTimerBox->target = nil; /* the queued block frees the box */ + _chordTimerBox = NULL; + } + [_chordHint release]; + _chordHint = nil; +} + +/* ---- command palette and shortcuts reference -------------------------------- */ + +/* Palette payload of a registry command row: the palette id when the + * command has one (the controller and -paletteRunCommand: keep their + * behaviour for those, e.g. the mode switches of 파일 이동 / 줄로 이동 / + * 심볼로 이동), else AXYNE_MAC_PALETTE_REGISTRY_BASE + command id. */ +static int axyne_macos_palette_payload(const AxyneCommandInfo *info) +{ + return info->palette_id != 0 ? info->palette_id + : AXYNE_MAC_PALETTE_REGISTRY_BASE + (int)info->id; +} + +/* Replaces the controller's command-mode rows ("> ...") with the registry + * commands flagged for the palette that are implemented on macOS (by a + * feature category or a base handler), matched on the macOS title and the + * English keywords like the shared builder, with the key map's key text. */ +- (void)paletteRebuildCommandRows +{ + size_t total = axyne_command_count(), found = 0, keep, i; + const char *query; + AxyneMacPaletteCandidate *candidates; + AxynePaletteList list = { NULL, 0 }; + if (!_palette.active || _palette.mode != AXYNE_PALETTE_MODE_COMMAND) return; + query = _palette.input != NULL ? _palette.input + _palette.query_offset : ""; + candidates = (AxyneMacPaletteCandidate *)calloc(total, sizeof(*candidates)); + if (candidates == NULL) return; /* keep the controller's rows */ + for (i = 0; i < total; ++i) { + const AxyneCommandInfo *info = axyne_command_at(i); + AxynePaletteMatch title, keywords; + int titleHit, keywordHit; + if (info == NULL || (info->flags & AXYNE_COMMAND_FLAG_PALETTE) == 0) continue; + if (![self commandImplemented:info->id]) continue; + titleHit = axyne_palette_match(axyne_command_title(info->id, axyne_command_platform_current()), + query, &title); + keywordHit = query[0] != '\0' && axyne_palette_match(info->keywords, query, &keywords); + if (!titleHit && !keywordHit) continue; + candidates[found].command = info->id; + candidates[found].order = i; + if (titleHit && (!keywordHit || title.score >= keywords.score / 2)) { + candidates[found].score = title.score; + candidates[found].start = title.start; + candidates[found].length = title.length; + } else { + candidates[found].score = keywords.score / 2 > 0 ? keywords.score / 2 : 1; + } + ++found; + } + qsort(candidates, found, sizeof(*candidates), axyne_macos_palette_compare); + keep = found < AXYNE_PALETTE_MAX_ITEMS ? found : AXYNE_PALETTE_MAX_ITEMS; + if (keep > 0) { + list.items = (AxynePaletteItem *)calloc(keep, sizeof(*list.items)); + if (list.items == NULL) { free(candidates); return; } + } + for (i = 0; i < keep; ++i) { + const AxyneCommandInfo *info = axyne_command_info(candidates[i].command); + AxynePaletteItem *item = &list.items[i]; + const char *keys = [[self keyTextForCommand:info->id] UTF8String]; + item->label = strdup(axyne_command_title(info->id, axyne_command_platform_current())); + item->detail = strdup(keys != NULL ? keys : ""); + item->badge = strdup(""); + if (item->label == NULL || item->detail == NULL || item->badge == NULL) { + list.count = i + 1; /* free this partly filled row too */ + axyne_palette_list_destroy(&list); + free(candidates); + return; + } + item->badge_color = 0x8b919b; + item->kind = AXYNE_PALETTE_ITEM_COMMAND; + item->match_start = candidates[i].start; + item->match_len = candidates[i].length; + item->payload = axyne_macos_palette_payload(info); + item->payload2 = 0; + list.count = i + 1; + } + free(candidates); + axyne_palette_list_destroy(&_palette.list); + _palette.list = list; + _palette.message[0] = '\0'; + _palette.message_actionable = 0; + if (list.count == 0) + (void)snprintf(_palette.message, sizeof(_palette.message), "%s", "일치하는 명령 없음"); + _palette.selection = 0; + _palette.scroll = 0; +} + +/* Help > 키보드 단축키 참조 (Cmd+K Cmd+R): every macOS command with a + * binding in the effective key map, grouped by menu, all of its bindings. */ +- (void)showKeyboardShortcuts:(id)sender +{ + AxyneKeyFormat format = axyne_key_format_for(axyne_command_platform_current()); + AxyneShortcutSection sections[AXYNE_COMMAND_GROUP_COUNT]; + AxyneShortcutRow *rows; + char (*texts)[96]; + size_t total = axyne_command_count(), used = 0, sectionCount = 0; + (void)sender; + if (!_keymapReady) return; + rows = (AxyneShortcutRow *)calloc(total, sizeof(*rows)); + texts = (char (*)[96])calloc(total, sizeof(*texts)); + if (rows == NULL || texts == NULL) { free(rows); free(texts); return; } + memset(sections, 0, sizeof(sections)); + for (int group = 0; group < AXYNE_COMMAND_GROUP_COUNT; ++group) { + size_t first = used; + if (group == AXYNE_COMMAND_GROUP_CONTEXT) continue; + for (size_t i = 0; i < total; ++i) { + const AxyneCommandInfo *info = axyne_command_at(i); + AxyneKeySequence sequences[AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count, length = 0; + if (info == NULL || (int)info->group != group || + !axyne_command_available(info->id, axyne_command_platform_current())) continue; + count = axyne_keymap_bindings(&_keymap, info->id, sequences, + AXYNE_COMMAND_DEFAULT_KEYS); + if (count > AXYNE_COMMAND_DEFAULT_KEYS) count = AXYNE_COMMAND_DEFAULT_KEYS; + if (count == 0) continue; + texts[used][0] = '\0'; + for (size_t k = 0; k < count && length + 3 < sizeof(texts[used]); ++k) { + if (k != 0) { + (void)snprintf(texts[used] + length, sizeof(texts[used]) - length, ", "); + length += 2; + } + (void)axyne_key_sequence_format(&sequences[k], format, + texts[used] + length, sizeof(texts[used]) - length); + length = strlen(texts[used]); + } + rows[used].label = axyne_command_title(info->id, axyne_command_platform_current()); + rows[used].keys = texts[used]; + ++used; + } + if (used == first) continue; + sections[sectionCount].title = axyne_command_group_title((AxyneCommandGroup)group); + sections[sectionCount].rows = &rows[first]; + sections[sectionCount].row_count = used - first; + ++sectionCount; + } + axyne_shortcuts_dialog_show([self window], sections, sectionCount); + free(rows); + free(texts); +} + +@end diff --git a/src/features/ui/platform/macos/ui_debug_macos.m b/src/features/ui/platform/macos/ui_debug_macos.m new file mode 100644 index 0000000..b258b6b --- /dev/null +++ b/src/features/ui/platform/macos/ui_debug_macos.m @@ -0,0 +1,55 @@ +/* Debug feature category of the macOS workspace window: debugger, run configuration, external debuggers, debug console (D1, D12, D13). + * Owns _debugUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneDebugFeature) + +- (NSInteger)debugValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)debugPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)debugDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)debugDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)debugDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)debugEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)debugDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)debugDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)debugLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)debugLayout {} + +- (void)debugApplyPreferences {} + +- (void)debugTeardown +{ + [_debugUi release]; + _debugUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_diag_macos.m b/src/features/ui/platform/macos/ui_diag_macos.m new file mode 100644 index 0000000..05d5141 --- /dev/null +++ b/src/features/ui/platform/macos/ui_diag_macos.m @@ -0,0 +1,55 @@ +/* Diag feature category of the macOS workspace window: inline diagnostics and explorer badges (D17, D19). + * Owns _diagUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneDiagFeature) + +- (NSInteger)diagValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)diagPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)diagDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)diagDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)diagDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)diagEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)diagDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)diagDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)diagLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)diagLayout {} + +- (void)diagApplyPreferences {} + +- (void)diagTeardown +{ + [_diagUi release]; + _diagUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_edit_macos.m b/src/features/ui/platform/macos/ui_edit_macos.m new file mode 100644 index 0000000..2166a2d --- /dev/null +++ b/src/features/ui/platform/macos/ui_edit_macos.m @@ -0,0 +1,55 @@ +/* Edit feature category of the macOS workspace window: Edit menu additions: block comment, undo limit (D8). + * Owns _editUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneEditFeature) + +- (NSInteger)editValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)editPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)editDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)editDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)editDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)editEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)editDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)editDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)editLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)editLayout {} + +- (void)editApplyPreferences {} + +- (void)editTeardown +{ + [_editUi release]; + _editUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_file_macos.m b/src/features/ui/platform/macos/ui_file_macos.m new file mode 100644 index 0000000..822d453 --- /dev/null +++ b/src/features/ui/platform/macos/ui_file_macos.m @@ -0,0 +1,55 @@ +/* File feature category of the macOS workspace window: D4-D6: new project, save all, auto save, close window, recent files and folders. + * Owns _fileUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneFileFeature) + +- (NSInteger)fileValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)filePerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)fileDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)fileDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)fileDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)fileEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)fileDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)fileDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)fileLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)fileLayout {} + +- (void)fileApplyPreferences {} + +- (void)fileTeardown +{ + [_fileUi release]; + _fileUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_help_macos.m b/src/features/ui/platform/macos/ui_help_macos.m new file mode 100644 index 0000000..2c61b7b --- /dev/null +++ b/src/features/ui/platform/macos/ui_help_macos.m @@ -0,0 +1,55 @@ +/* Help feature category of the macOS workspace window: Help menu: getting started, log folder, updates, licenses (D22, D27, D28). + * Owns _helpUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneHelpFeature) + +- (NSInteger)helpValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)helpPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)helpDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)helpDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)helpDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)helpEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)helpDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)helpDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)helpLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)helpLayout {} + +- (void)helpApplyPreferences {} + +- (void)helpTeardown +{ + [_helpUi release]; + _helpUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_lsp_nav_macos.m b/src/features/ui/platform/macos/ui_lsp_nav_macos.m new file mode 100644 index 0000000..4dad94d --- /dev/null +++ b/src/features/ui/platform/macos/ui_lsp_nav_macos.m @@ -0,0 +1,55 @@ +/* LspNav feature category of the macOS workspace window: go to definition, references, workspace symbols (D20). + * Owns _lspNavUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneLspNavFeature) + +- (NSInteger)lspNavValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)lspNavPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)lspNavDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)lspNavDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)lspNavDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)lspNavEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)lspNavDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)lspNavDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)lspNavLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)lspNavLayout {} + +- (void)lspNavApplyPreferences {} + +- (void)lspNavTeardown +{ + [_lspNavUi release]; + _lspNavUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_macos.m b/src/features/ui/platform/macos/ui_macos.m index 923ba14..6f7ce4f 100644 --- a/src/features/ui/platform/macos/ui_macos.m +++ b/src/features/ui/platform/macos/ui_macos.m @@ -1,77 +1,4 @@ -#import -#import "terminal_view_macos.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "axyne/document.h" -#include "axyne/empty_state.h" -#include "axyne/search.h" -#include "axyne/explorer.h" -#include "axyne/outline.h" -#include "axyne/watcher.h" -#include "axyne/process.h" -#include "axyne/runner.h" -#include "axyne/debugger.h" -#include "axyne/preferences.h" -#include "axyne/git.h" -#include "axyne/lsp.h" -#include "axyne/problems.h" -#include "axyne/problems_feed.h" -#include "axyne/palette_controller.h" -#include "axyne/language.h" -#include "axyne/build_selector.h" -#include "axyne/ui_design.h" -#include "axyne/popup_menu_layout.h" -#include "popup_menu_macos.h" -#include "git_panel_macos.h" -#include "axyne/layout_metrics.h" -#include "axyne/syntax.h" -#include "axyne/completion.h" -#include "Scintilla.h" -#include "../../editor_document.h" -#include "../../editor_actions.h" -#include "../../debugger_actions.h" -#include "../../preferences_window.h" -#include "../../app_dialogs.h" -#include - -@interface NSObject (AxyneScintillaMessages) -- (NSView *)content; -- (NSInteger)message:(unsigned int)message wParam:(uintptr_t)wParam - lParam:(intptr_t)lParam; -@end - -/* These bands follow the 1440x900 Figma work area: menu bar 26px, toolbar - * 38px, tabs 34px, - * output panel 230px, and status bar 24px. The macOS titlebar remains owned - * by AppKit so its traffic-light controls stay native and accessible. */ -static const CGFloat AXYNE_MENU = AXYNE_UI_MENU; -static const CGFloat AXYNE_TOOLBAR = AXYNE_UI_TOOLBAR; -/* Every band below the in-window menu bar and toolbar starts here. */ -static const CGFloat AXYNE_CONTENT_TOP = AXYNE_UI_MENU + AXYNE_UI_TOOLBAR; -static const CGFloat AXYNE_TABS = AXYNE_UI_TABS; -static const CGFloat AXYNE_STATUS = AXYNE_UI_STATUS; - -/* Top-level main-menu entries that the in-window bar mirrors carry - * AXYNE_MACOS_MENU_TAG_BASE + their bar position as the NSMenuItem tag - * (the application menu keeps tag 0 and has no bar item). */ -enum { AXYNE_MACOS_MENU_TAG_BASE = 100 }; - -/* Title of the top-level menu at bar position index, from the table shared - * with the Windows adapter. */ -static NSString *axyne_macos_menu_label(NSUInteger index) -{ - const AxyneMenuTitle *title = axyne_ui_menu_title(index); - NSString *label = title != NULL ? [NSString stringWithUTF8String:title->label] : nil; - return label != nil ? label : @""; -} +#include "ui_macos_internal.h" /* Menu text at the Figma 12pt size. Only the font is set so AppKit keeps * choosing the colour (which also dims disabled entries) and draws the @@ -95,23 +22,7 @@ static void axyne_macos_style_menu(NSMenu *menu) AxyneWorkspaceView *view; BOOL workspace; } AxyneMacPreferencesContext; -typedef struct AxyneMacGitRun AxyneMacGitRun; -typedef struct AxyneMacGitCompletion AxyneMacGitCompletion; -typedef struct AxyneMacGitBatch AxyneMacGitBatch; - -/* Native buttons keep AppKit's actions and accessibility while drawing the - * flat, precisely centered Figma toolbar rather than an OS bezel. */ -@interface AxyneChromeButton : NSButton -@property(nonatomic, retain) NSColor *fillColor; -@property(nonatomic, retain) NSColor *labelColor; -@property(nonatomic, retain) NSColor *strokeColor; -@property(nonatomic, retain) NSAttributedString *richTitle; -@property(nonatomic, retain) NSAttributedString *trailingTitle; -@property(nonatomic) CGFloat cornerRadius; -@property(nonatomic) NSInteger chevronState; /* 0 none, 1 down, 2 up */ -@property(nonatomic) CGFloat contentInset; -@property(nonatomic) BOOL leadingAligned; -@end + @implementation AxyneChromeButton /* Vector chevron (identical geometry for down/up) drawn at the right edge. */ @@ -224,13 +135,6 @@ static size_t axyne_macos_successor_index(const AxyneDocumentSet *set, size_t cl return closing + 1 < set->count ? closing + 1 : closing - 1; } -static NSColor *axyne_color(CGFloat red, CGFloat green, CGFloat blue) -{ - return [NSColor colorWithCalibratedRed:red / 255.0 - green:green / 255.0 - blue:blue / 255.0 - alpha:1.0]; -} /* Each segment is @[text, font, color]; lets one native button show the * multi-color labels the Figma toolbar uses without extra subviews. */ @@ -261,13 +165,6 @@ static size_t axyne_macos_successor_index(const AxyneDocumentSet *set, size_t cl AXYNE_PAL_GAP = 3 }; -@protocol AxynePaletteOwner -- (void)paletteActivateRow:(size_t)row; -- (void)paletteChipClicked:(NSInteger)mode; -- (void)paletteDismiss; -/* YES when the point belongs to the toolbar search field (not an outside click). */ -- (BOOL)paletteClaimsPoint:(NSPoint)point; -@end static NSColor *axyne_palette_rgb(uint32_t rgb, CGFloat alpha) { @@ -764,13 +661,6 @@ - (void)drawRect:(NSRect)dirtyRect static const CGFloat AXYNE_PROBLEMS_FILTER_HEIGHT = 25; static const CGFloat AXYNE_PROBLEMS_FILTER_MIN = 120; -@class AxyneProblemsView; - -@protocol AxyneProblemsViewDelegate -- (void)problemsView:(AxyneProblemsView *)view activateRowAtIndex:(size_t)index; -- (void)problemsViewFilterChanged:(AxyneProblemsView *)view; -- (void)problemsViewWillHitTest:(AxyneProblemsView *)view; -@end static NSFont *axyne_problems_mono_font(CGFloat size) { @@ -1363,139 +1253,7 @@ - (void)drawRect:(NSRect)dirtyRect } @end -/* The deferred discovery block must not retain the workspace: a retained view - * would never deallocate when the main queue is not drained (the editor runtime - * test), leaking its Scintilla documents. The block holds only this box. */ -typedef struct AxyneDiscoveryBox { id target; } AxyneDiscoveryBox; - -@interface AxyneWorkspaceView : NSView { - NSView *_editorView; - AxyneEmptyEditorView *_emptyView; - NSImageView *_imagePreview; - BOOL _emptyShown; - BOOL _emptyGuideDirty; - NSBundle *_scintillaBundle; - NSString *_editorLoadError; - NSButton *_newButton; - NSButton *_openButton; - NSButton *_saveButton; - NSButton *_undoButton; - NSButton *_redoButton; - NSButton *_buildButton; - NSButton *_runButton; - NSButton *_targetButton; - NSButton *_searchButton; - NSButton *_outputTab; - NSButton *_problemsTab; - NSButton *_terminalTab; - NSButton *_clearOutput; - NSScrollView *_terminalScroll; - /* Problems panel: the model (LSP diagnostics + build output) and the - * list view that paints the rows built from it. */ - AxyneProblemsView *_problemsView; - AxyneProblemList _problems; - AxyneProblemCollapsed _problemsCollapsed; - AxyneProblemCounts _problemCounts; - AxyneBuildFeed _buildFeed; - BOOL _buildFeedActive; - NSInteger _panelMode; - NSInteger _activeMenuIndex; /* bar item whose popup is open, or -1 */ - NSInteger _hoverMenuIndex; /* bar item under the pointer, or -1 */ - NSTrackingArea *_menuTracking; - AxynePopupMenu *_popup; /* open Axyne popup (menu bar, build target, context), or nil */ - NSInteger _explorerFirstRow; - /* Outline (개요) section at the bottom of the explorer tab. Scanned only - * while that tab is shown; otherwise marked stale and rescanned when the - * tab appears. Edits rescan after a debounce (assign-only box). */ - AxyneOutline _outline; - AxyneDiscoveryBox *_outlineBox; - NSTimeInterval _outlineDue; /* reference-date seconds of the next rescan */ - BOOL _outlineStale; - CGFloat _tabScroll; - void *_lexillaModule; - void *(*_createLexer)(const char *name); - AxyneDocumentSet _documents; - AxyneExplorer _explorer; - AxyneWatcher *_watcher; - NSInteger _explorerSelection; - BOOL _hasExplorerSelection; - NSMenu *_recentMenu; - BOOL _loadingEditor; - AxyneRunnerConfig _terminalRunner; - AxyneRunnerConfig _actionRunner; - /* Build target selector. Lives in memory for the session; runtimes are - * discovered lazily (first editor file, click or build), never at startup. */ - AxyneBuildTarget _buildTarget; - AxyneRuntimeList _runtimes; - BOOL _runtimesDiscovered; - BOOL _runtimeDiscoveryScheduled; - BOOL _workspaceRefreshPending; /* one coalesced explorer reload is queued */ - BOOL _buildMenuOpen; /* chevron points up while the popup is open */ - AxyneDiscoveryBox *_discoveryBox; - AxyneDiscoveryBox *_refreshBox; /* assign-only target of the queued reload */ - AxyneDiscoveryBox *_problemsBox; /* assign-only target of the queued problems rebuild */ - /* Plan whose run step starts when its build step exits with 0. */ - AxyneLanguagePlan _pendingPlan; - BOOL _pendingRun; - AxyneDebugger _debugger; - AxyneProcess *_actionProcess; - AxyneProcess *_gitProcess; - AxyneMacGitRun *_gitRun; - /* A commit, push or pull is running on a worker thread. */ - BOOL _gitBatchBusy; - /* The running batch is a commit started from the Git panel. */ - BOOL _gitBatchFromPanel; - /* Left sidebar tab: 0 explorer, 1 Git. The Git panel is created the first - * time its tab is shown and freed with the view. */ - NSInteger _sidebarTab; - AxyneGitPanelView *_gitPanel; - NSTextView *_terminalOutput; - AxyneTerminalView *_shellView; - NSButton *_terminalStart; - NSButton *_terminalStop; - NSButton *_debugStart; - NSButton *_debugPause; - NSButton *_debugContinue; - NSButton *_debugNext; - NSButton *_debugBreakpoint; - int _activeAction; - int _lastExitCode; - BOOL _lastExitFailed; - BOOL _hasExitStatus; - AxynePreferences _globalPreferences; - AxynePreferences _preferences; - char *_globalPreferencesPath; - char *_workspacePreferencesPath; - unsigned char _workspaceBindingPresent[AXYNE_ACTION_COUNT]; - AxyneLspClient *_lsp; - NSString *_lspStatus; - BOOL _explorerHidden; /* View > Explorer; zero-initialised means visible */ - BOOL _panelHidden; /* View > Bottom Panel */ - CGFloat _sidebarSize; /* dragged explorer width; 0 = default, session only */ - CGFloat _panelSize; /* dragged bottom panel height; 0 = default */ - NSInteger _splitterDrag; /* 0 none, 1 explorer border, 2 panel border */ - CGFloat _splitterStart; /* pointer coordinate when the drag began */ - CGFloat _splitterStartSize; /* size when the drag began */ - NSTrackingArea *_sidebarSplitterTracking; - NSTrackingArea *_panelSplitterTracking; - AxynePaletteController _palette; - AxynePaletteOverlay *_paletteOverlay; - NSTextField *_paletteField; - NSTimer *_paletteTimer; -} -@end - -static NSColor *axyne_preference_color(uint32_t value) -{ - return axyne_color((CGFloat)((value >> 16) & 0xff), - (CGFloat)((value >> 8) & 0xff), - (CGFloat)(value & 0xff)); -} -static intptr_t axyne_editor_color(uint32_t rgb) -{ - return (intptr_t)(((rgb & 0xff) << 16) | (rgb & 0xff00) | ((rgb >> 16) & 0xff)); -} /* Subtle current-line tint: editor background nudged 8% toward the text. */ static uint32_t axyne_macos_caret_line_color(const AxyneThemePreferences *theme) @@ -1529,20 +1287,6 @@ static void axyne_macos_select_theme(AxyneThemePreferences *theme, } } -static BOOL axyne_macos_prefers_dark(NSView *view) -{ - NSAppearance *appearance = [view effectiveAppearance]; - NSAppearanceName match = [appearance bestMatchFromAppearancesWithNames: - @[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; - return [match isEqualToString:NSAppearanceNameDarkAqua]; -} - -/* Perceived luminance below mid-grey: scrollers use the dark appearance. */ -static BOOL axyne_macos_color_is_dark(uint32_t rgb) -{ - uint32_t red = (rgb >> 16) & 0xffu, green = (rgb >> 8) & 0xffu, blue = rgb & 0xffu; - return (red * 299u + green * 587u + blue * 114u) / 1000u < 128u; -} /* The NSScrollView inside ScintillaView, found without relying on a private * accessor: use `scrollView` when exposed, otherwise search the subviews. */ @@ -1556,36 +1300,19 @@ static BOOL axyne_macos_color_is_dark(uint32_t rgb) return nil; } -static BOOL axyne_macos_reference_surfaces(const AxyneThemePreferences *theme) -{ - return theme->background == 0x16171a && theme->panel == 0x1f2126 && - theme->toolbar == 0x1c1e22; -} -/* Popup menu colours: the Figma menu frames on the reference theme (the same - * values as the Windows popup), the theme's own colours otherwise. */ -static AxynePopupMenuColors axyne_macos_popup_colors(const AxyneThemePreferences *theme) +/* Settings files (D3): every save writes /settings.json; loads read + * settings.json, or the old preferences.json while no settings.json exists. + * The old file is never deleted. The stored paths are the write paths; + * read paths are resolved on every use because the first save creates + * settings.json. Results are malloc'd (free()). */ +static char *axyne_macos_copy_settings_path(char *path) { - AxynePopupMenuColors colors; - if (axyne_macos_reference_surfaces(theme)) { - colors.background = 0x202329; colors.border = 0x2b2e35; - colors.hover = 0x402d5c; colors.hoverText = 0xf4edf9; - colors.text = 0xd2d5db; colors.muted = 0x969ba5; - colors.disabled = 0x666c76; colors.separator = 0x2b2e35; - colors.check = 0xa667e8; - } else { - colors.background = theme->panel; colors.border = theme->border; - colors.hover = theme->border; colors.hoverText = theme->text; - colors.text = theme->text; colors.muted = theme->muted; - colors.disabled = theme->muted; colors.separator = theme->border; - colors.check = theme->accent; - } - return colors; -} - -static uint32_t axyne_macos_output_background(const AxyneThemePreferences *theme) -{ - return axyne_macos_reference_surfaces(theme) ? 0x1d1f23 : theme->background; + char *copy; + if (path == NULL) return NULL; + copy = strdup(path); + axyne_preferences_free_path(path); + return copy; } static char *axyne_macos_global_preferences_path(void) @@ -1594,275 +1321,34 @@ static uint32_t axyne_macos_output_background(const AxyneThemePreferences *theme NSApplicationSupportDirectory, NSUserDomainMask, YES); NSString *base = [directories count] != 0 ? [directories objectAtIndex:0] : nil; NSString *directory; - NSString *path; if (base == nil) return NULL; directory = [base stringByAppendingPathComponent:@"Axyne"]; - path = [directory stringByAppendingPathComponent:@"preferences.json"]; - return strdup([path UTF8String]); + return axyne_macos_copy_settings_path( + axyne_preferences_write_path([directory fileSystemRepresentation])); } static char *axyne_macos_workspace_preferences_path(const char *root) { - NSString *rootPath; NSString *directory; NSString *path; + char *directory; + char *path; if (root == NULL) return NULL; - rootPath = [NSString stringWithUTF8String:root]; - if (rootPath == nil) return NULL; - directory = [rootPath stringByAppendingPathComponent:@".axyne"]; - path = [directory stringByAppendingPathComponent:@"preferences.json"]; - return strdup([path UTF8String]); -} - -static BOOL axyne_macos_binding_matches(const AxynePreferences *preferences, - AxynePreferenceAction action, - NSString *key, NSEvent *event) -{ - const AxyneKeyBinding *binding = axyne_preferences_find_binding( - preferences, action); - unsigned int modifiers = 0; - NSString *expected; - if (binding == NULL || !binding->enabled) return NO; - if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) - modifiers |= AXYNE_KEY_MODIFIER_COMMAND; - if (([event modifierFlags] & NSEventModifierFlagControl) != 0) - modifiers |= AXYNE_KEY_MODIFIER_CONTROL; - if (([event modifierFlags] & NSEventModifierFlagShift) != 0) - modifiers |= AXYNE_KEY_MODIFIER_SHIFT; - if (([event modifierFlags] & NSEventModifierFlagOption) != 0) - modifiers |= AXYNE_KEY_MODIFIER_ALT; - if (binding->modifiers != modifiers) return NO; - expected = [[NSString stringWithUTF8String:binding->key] lowercaseString]; - if ([expected isEqualToString:@"f5"]) - return [event keyCode] == 96; - return [expected isEqualToString:[key lowercaseString]]; -} - -/* Cmd+Shift+P: the quick-file binding plus Shift opens the palette with ">". */ -static BOOL axyne_macos_palette_shift_matches(const AxynePreferences *preferences, - NSString *key, NSEvent *event) -{ - const AxyneKeyBinding *binding = axyne_preferences_find_binding( - preferences, AXYNE_ACTION_QUICK_FILE); - unsigned int modifiers = 0; - if (binding == NULL || !binding->enabled || (binding->modifiers & AXYNE_KEY_MODIFIER_SHIFT) != 0) - return NO; - if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) - modifiers |= AXYNE_KEY_MODIFIER_COMMAND; - if (([event modifierFlags] & NSEventModifierFlagControl) != 0) - modifiers |= AXYNE_KEY_MODIFIER_CONTROL; - if (([event modifierFlags] & NSEventModifierFlagShift) != 0) - modifiers |= AXYNE_KEY_MODIFIER_SHIFT; - if (([event modifierFlags] & NSEventModifierFlagOption) != 0) - modifiers |= AXYNE_KEY_MODIFIER_ALT; - if (modifiers != (binding->modifiers | AXYNE_KEY_MODIFIER_SHIFT)) return NO; - return [[[NSString stringWithUTF8String:binding->key] lowercaseString] - isEqualToString:[key lowercaseString]]; -} - -@interface AxyneWorkspaceView (AxyneActions) -- (void)newDocument:(id)sender; -- (void)openDocument:(id)sender; -- (void)saveDocument:(id)sender; -- (void)saveDocumentAs:(id)sender; -- (void)undo:(id)sender; -- (void)redo:(id)sender; -- (void)closeDocument:(id)sender; -- (void)closeDocumentAtIndex:(size_t)index; -- (void)openRecent:(id)sender; -- (void)openPath:(NSString *)path asPreview:(BOOL)preview; -- (BOOL)confirmCloseAll; -- (void)notification:(SCNotification *)notification; -- (void)setRecentMenu:(NSMenu *)menu; -- (void)refreshRecentMenu; -- (BOOL)captureEditor; -- (void)refreshActionControls; -- (BOOL)validateMenuItem:(NSMenuItem *)menuItem; -- (BOOL)loadActiveDocument; -- (BOOL)selectDocumentAtIndex:(size_t)index; -- (NSInteger)sendEditorMessage:(unsigned int)message wParam:(uintptr_t)wParam - lParam:(intptr_t)lParam; -- (void)loadScintillaView; -- (BOOL)requireEditorFor:(NSString *)action; -- (BOOL)isEmptyState; -- (void)updateEmptyState; -- (void)applyEmptyGuideTheme; -- (BOOL)editorReadyForSave; -- (AxyneExplorerNode *)selectedExplorerNode; -- (void)editCut:(id)sender; -- (void)editCopy:(id)sender; -- (void)editPaste:(id)sender; -- (void)editSelectAll:(id)sender; -- (void)findInDocument:(id)sender; -- (void)replaceInDocument:(id)sender; -- (void)searchWorkspace:(id)sender; -- (void)openHelp:(id)sender; -- (CGFloat)sidebarWidth; -- (CGFloat)panelHeight; -- (NSInteger)splitterAtPoint:(NSPoint)point; -- (NSRect)splitterRect:(NSInteger)which; -- (void)refreshSplitterTracking; -- (NSRect)menuBarItemRect:(NSUInteger)index; -- (NSInteger)menuBarIndexAtPoint:(NSPoint)point; -- (void)openMenuBarMenu:(NSUInteger)index; -- (void)openMenuBarMenu:(NSUInteger)index selectFirst:(BOOL)selectFirst; -- (BOOL)showPopupMenu:(NSMenu *)menu belowScreenRect:(NSRect)anchor gap:(CGFloat)gap - selectFirst:(BOOL)selectFirst; -- (void)closePopupMenu; -- (void)setMenuHover:(NSInteger)index; -- (BOOL)editorActionable; -- (BOOL)activeIsImage; -- (void)goToLine:(id)sender; -- (void)selectLine:(id)sender; -- (void)toggleLineComment:(id)sender; -- (void)duplicateLine:(id)sender; -- (void)moveLineUp:(id)sender; -- (void)moveLineDown:(id)sender; -- (void)indentSelection:(id)sender; -- (void)outdentSelection:(id)sender; -- (void)zoomInEditor:(id)sender; -- (void)zoomOutEditor:(id)sender; -- (void)zoomResetEditor:(id)sender; -- (void)toggleWordWrap:(id)sender; -- (void)toggleExplorer:(id)sender; -- (void)showGitPanel:(id)sender; -- (void)selectSidebarTab:(NSInteger)tab; -- (NSRect)sidebarTabRect:(NSInteger)tab; -- (NSInteger)sidebarTabAtPoint:(NSPoint)point; -- (AxyneGitPanelTheme)gitPanelTheme; -- (void)togglePanel:(id)sender; -- (void)cancelBuild:(id)sender; -- (void)stopDebugger:(id)sender; -- (void)clearBreakpoints:(id)sender; -- (BOOL)preferencesFileExists; -- (void)openPreferencesFile:(id)sender; -- (void)showKeyboardShortcuts:(id)sender; -- (void)reportIssue:(id)sender; -- (void)showSettingsFolder:(id)sender; -- (BOOL)confirmCloseDocumentAtIndex:(size_t)index; -- (void)findOrReplace:(BOOL)replace; -- (void)searchFolder:(BOOL)quickFile; -- (void)openWorkspace:(id)sender; -- (void)newExplorerFile:(id)sender; -- (void)newExplorerFolder:(id)sender; -- (void)renameExplorerItem:(id)sender; -- (void)removeExplorerItem:(id)sender; -- (void)workspaceEvent; -- (BOOL)refreshExplorer; -- (void)scrollTabsBy:(CGFloat)delta; -- (void)showWorkspaceError:(NSString *)prefix error:(AxyneError *)error; -- (void)showWorkspaceMessage:(NSString *)message; -- (BOOL)selectWorkspaceURL:(NSURL *)url; -- (NSInteger)explorerNodeAtPoint:(NSPoint)point; -- (BOOL)explorerPinnedAtPoint:(NSPoint)point; -- (NSUInteger)explorerPinnedRows:(size_t *)out; -- (NSInteger)explorerVisibleRows; -- (NSRect)tabFrameAtIndex:(size_t)index; -- (void)selectPanel:(id)sender; -- (void)clearOutput:(id)sender; -- (void)quickFile:(id)sender; -- (void)configureRunnerAction:(id)sender; -- (void)showBuildTargetMenu:(id)sender; -- (void)pickBuildTarget:(id)sender; -- (void)discoverRuntimes; -- (void)scheduleRuntimeDiscovery; -- (NSString *)buildTargetLabel; -- (void)clearPendingRun; -- (BOOL)startStep:(const AxyneLanguageStep *)step plan:(const AxyneLanguagePlan *)plan - action:(int)action clearOutput:(BOOL)clear; -- (void)startAction:(BOOL)run; -- (void)performExplorerOperation:(AxyneFileKind)kind; -- (NSString *)askForText:(NSString *)title label:(NSString *)label; -- (void)startTerminal:(id)sender; -- (void)stopTerminal:(id)sender; -- (void)terminalAppend:(const char *)bytes length:(size_t)length - stream:(AxyneProcessStream)stream; -- (void)terminalExited:(AxyneProcess *)process exitCode:(int)exitCode; -- (BOOL)configureRunner; -- (BOOL)applyRunnerValues:(const AxyneRunnerDialogValues *)values - error:(char *)message capacity:(size_t)capacity; -- (void)buildDocument:(id)sender; -- (void)runDocument:(id)sender; -- (void)startDebugger:(id)sender; -- (void)debugCommand:(id)sender; -- (void)toggleBreakpoint:(id)sender; -- (void)showGlobalPreferences:(id)sender; -- (void)showWorkspacePreferences:(id)sender; -- (void)applyPreferences; -- (void)applyEditorScrollers; -- (void)applySystemAppearance; -- (void)updateChromeTitles; -- (void)applyEditorLexer; -- (void)updateLineNumberMargin; -- (void)updateBraceHighlight; -- (void)autoIndentFromNotification:(SCNotification *)notification; -- (void)showCompletion; -- (BOOL)showPreferences:(BOOL)workspace; -- (BOOL)savePreferences:(AxynePreferences *)edited workspace:(BOOL)workspace; -- (void)showGitStatus:(id)sender; -- (void)showGitDiff:(id)sender; -- (void)stageAllGitChanges:(id)sender; -- (void)unstageAllGitChanges:(id)sender; -- (void)commitGitChanges:(id)sender; -- (void)pushGitChanges:(id)sender; -- (void)pullGitChanges:(id)sender; -- (void)showGitLog:(id)sender; -- (void)completeGitBatch:(AxyneMacGitBatch *)batch; -- (void)completeGitOperation:(AxyneMacGitCompletion *)completion - run:(AxyneMacGitRun *)run; -- (void)selectOutputPanel; -- (void)startGitOperationWithEmptyMessage:(const char *)emptyMessage - arguments:(const char *const *)arguments - count:(size_t)argumentCount; -- (BOOL)ensureLsp; -- (BOOL)openLspForActive; -- (void)syncLspActive; -- (void)navigateLspReferences:(id)sender; -- (void)setLspStatus:(NSString *)status; -- (void)applyProblemsTheme; -- (void)cancelProblemsRefresh; -- (void)refreshProblems; -- (void)scheduleProblemsRefresh; -- (void)flushProblemsRefresh; -- (const AxyneProblemCounts *)problemCounts; -- (void)applyLspDiagnostics:(const AxyneLspDiagnostic *)diagnostics - count:(size_t)count path:(const char *)path; -- (void)clearLspProblemsForPath:(const char *)path; -- (void)beginBuildProblemsInDirectory:(const char *)directory clear:(BOOL)clear; -- (void)beginBuildProblems; -- (void)feedBuildProblems:(const char *)bytes length:(size_t)length - stream:(AxyneProcessStream)stream; -- (void)finishBuildProblems; -- (void)showProblemAtPath:(NSString *)path line:(size_t)line column:(size_t)column - utf16:(BOOL)utf16; -- (void)moveCaretToProblemLine:(size_t)line column:(size_t)column utf16:(BOOL)utf16; -- (void)afterSaveAsFromPath:(const char *)oldPath; -- (BOOL)outlineVisible; -- (CGFloat)outlineHeight; -- (CGFloat)explorerTreeBottom; -- (BOOL)pointIsInOutline:(NSPoint)point; -- (NSInteger)outlineSymbolAtPoint:(NSPoint)point; -- (void)cancelOutlineRefresh; -- (void)queueOutlineRefreshAfter:(NSTimeInterval)delay; -- (void)scheduleOutlineRefresh; -- (void)outlineRefreshDue; -- (void)refreshOutline; -- (void)jumpToOutlineSymbol:(NSInteger)index; -- (void)drawOutlineAtTop:(CGFloat)top light:(BOOL)light reference:(BOOL)reference; -@end + directory = axyne_preferences_workspace_directory(root); + if (directory == NULL) return NULL; + path = axyne_preferences_write_path(directory); + axyne_preferences_free_path(directory); + return axyne_macos_copy_settings_path(path); +} -@interface AxyneWorkspaceView (AxynePalette) -- (void)openPaletteWithInput:(NSString *)input; -- (void)closePaletteRestoringFocus:(BOOL)restore; -- (void)layoutPalette; -- (void)drawPaletteFieldInRect:(NSRect)box; -- (void)paletteSyncInput; -- (void)paletteSetText:(NSString *)text; -- (void)paletteTick:(NSTimer *)timer; -- (void)paletteEnter; -- (void)paletteDismissWithoutFocus; -- (void)paletteFocusField; -- (void)paletteGotoLine:(size_t)line column:(size_t)column; -- (void)paletteRunCommand:(AxynePaletteCommandId)command; -@end +/* The file a load reads for the settings file `writePath` (D3 fallback). */ +static char *axyne_macos_preferences_read_path(const char *writePath) +{ + NSString *file; + if (writePath == NULL) return NULL; + file = [NSString stringWithUTF8String:writePath]; + if (file == nil) return NULL; + return axyne_macos_copy_settings_path(axyne_preferences_read_path( + [[file stringByDeletingLastPathComponent] fileSystemRepresentation])); +} static int axyne_macos_palette_document(void *user, char **path, char **text, size_t *length, size_t *lineCount); @@ -2174,12 +1660,15 @@ - (instancetype)initWithFrame:(NSRect)frame AxyneError preferenceError; AxyneStatus status; axyne_preferences_defaults(&_globalPreferences); + char *readPath; _globalPreferencesPath = axyne_macos_global_preferences_path(); - status = _globalPreferencesPath == NULL ? AXYNE_STATUS_NOT_FOUND : - axyne_preferences_load_global(_globalPreferencesPath, - &_globalPreferences, + readPath = axyne_macos_preferences_read_path(_globalPreferencesPath); + status = readPath == NULL ? AXYNE_STATUS_NOT_FOUND : + axyne_preferences_load_global(readPath, &_globalPreferences, &preferenceError); + free(readPath); _preferences = _globalPreferences; + [self rebuildKeymap]; if (status != AXYNE_STATUS_OK && status != AXYNE_STATUS_NOT_FOUND) [self showWorkspaceError:@"Unable to load preferences" error:&preferenceError]; } @@ -2232,11 +1721,21 @@ - (instancetype)initWithFrame:(NSRect)frame [self addSubview:_redoButton]; [self addSubview:_buildButton]; [self addSubview:_runButton]; [self addSubview:_targetButton]; [self addSubview:_searchButton]; + /* Bottom panel tab strip: one button per AxyneBottomPanelTab, tag = + * tab. The debug console tab exists from the start but stays hidden + * until the debug feature shows it. */ _outputTab = axyne_macos_toolbar_button(@"출력", self, @selector(selectPanel:)); _problemsTab = axyne_macos_toolbar_button(@"문제", self, @selector(selectPanel:)); _terminalTab = axyne_macos_toolbar_button(@"터미널", self, @selector(selectPanel:)); - [_outputTab setTag:0]; [_problemsTab setTag:1]; [_terminalTab setTag:2]; + _debugConsoleTab = axyne_macos_toolbar_button(@"디버그 콘솔", self, @selector(selectPanel:)); + [_outputTab setTag:AXYNE_BOTTOM_PANEL_OUTPUT]; + [_problemsTab setTag:AXYNE_BOTTOM_PANEL_PROBLEMS]; + [_terminalTab setTag:AXYNE_BOTTOM_PANEL_TERMINAL]; + [_debugConsoleTab setTag:AXYNE_BOTTOM_PANEL_DEBUG_CONSOLE]; + _panelTabHidden[AXYNE_BOTTOM_PANEL_DEBUG_CONSOLE] = YES; + [_debugConsoleTab setHidden:YES]; [self addSubview:_outputTab]; [self addSubview:_problemsTab]; [self addSubview:_terminalTab]; + [self addSubview:_debugConsoleTab]; _clearOutput = axyne_macos_toolbar_button(@"⊘", self, @selector(clearOutput:)); [_clearOutput setToolTip:@"출력 지우기"]; [self addSubview:_clearOutput]; @@ -2427,7 +1926,7 @@ - (void)refreshActionControls BOOL savedDocument = document != NULL && !document->is_untitled && document->path != NULL && !document->is_dirty; [_terminalStart setEnabled:![_shellView running]]; - [_terminalStop setEnabled:_panelMode == 2 ? [_shellView running] : terminalActive]; + [_terminalStop setEnabled:_panelMode == AXYNE_BOTTOM_PANEL_TERMINAL ? [_shellView running] : terminalActive]; /* A breakpoint is keyed by path and line, so any source file with a path * can carry one, saved or not. */ BOOL fileDocument = document != NULL && !document->is_image && @@ -2451,6 +1950,10 @@ - (void)refreshActionControls } - (void)quickFile:(id)sender { (void)sender; [self openPaletteWithInput:@""]; } +/* 보기 > 명령 팔레트 (the palette in command mode). */ +- (void)showCommandPalette:(id)sender { (void)sender; [self openPaletteWithInput:@">"]; } +/* 편집 > 심볼로 이동: the palette's symbol mode on the current file. */ +- (void)goToSymbol:(id)sender { (void)sender; [self openPaletteWithInput:@"@"]; } - (void)configureRunnerAction:(id)sender { (void)sender; (void)[self configureRunner]; [self refreshActionControls]; } /* ---- build target selector ------------------------------------------------ */ @@ -2575,22 +2078,75 @@ - (void)clearPendingRun memset(&_pendingPlan, 0, sizeof(_pendingPlan)); _pendingRun = NO; } -- (void)clearOutput:(id)sender { (void)sender; if (_panelMode == 2) { axyne_terminal_clear([_shellView terminal]); [_shellView setNeedsDisplay:YES]; } else [_terminalOutput setString:@""]; } +- (void)clearOutput:(id)sender { (void)sender; if (_panelMode == AXYNE_BOTTOM_PANEL_TERMINAL) { axyne_terminal_clear([_shellView terminal]); [_shellView setNeedsDisplay:YES]; } else [_terminalOutput setString:@""]; } - (void)selectPanel:(id)sender { - _panelMode = [sender tag]; + [self selectBottomPanelTab:(AxyneBottomPanelTab)[sender tag]]; +} + +/* ---- bottom panel tab model ----------------------------------------------- */ + +- (NSButton *)bottomPanelTabButton:(AxyneBottomPanelTab)tab +{ + switch (tab) { + case AXYNE_BOTTOM_PANEL_OUTPUT: return _outputTab; + case AXYNE_BOTTOM_PANEL_PROBLEMS: return _problemsTab; + case AXYNE_BOTTOM_PANEL_TERMINAL: return _terminalTab; + case AXYNE_BOTTOM_PANEL_DEBUG_CONSOLE: return _debugConsoleTab; + case AXYNE_BOTTOM_PANEL_TAB_COUNT: break; + } + return nil; +} + +- (BOOL)bottomPanelTabVisible:(AxyneBottomPanelTab)tab +{ + return (int)tab >= 0 && tab < AXYNE_BOTTOM_PANEL_TAB_COUNT && !_panelTabHidden[tab]; +} + +/* Shows or hides a tab in the strip. Hiding the selected tab selects Output. */ +- (void)setBottomPanelTab:(AxyneBottomPanelTab)tab visible:(BOOL)visible +{ + if ((int)tab < 0 || tab >= AXYNE_BOTTOM_PANEL_TAB_COUNT) return; + if (tab == AXYNE_BOTTOM_PANEL_OUTPUT) visible = YES; /* always present */ + _panelTabHidden[tab] = !visible; + if (!visible && _panelMode == (NSInteger)tab) _panelMode = AXYNE_BOTTOM_PANEL_OUTPUT; + [self setNeedsLayout:YES]; [self setNeedsDisplay:YES]; +} + +/* Selects a visible tab and reveals the panel. */ +- (void)selectBottomPanelTab:(AxyneBottomPanelTab)tab +{ + if (![self bottomPanelTabVisible:tab]) return; + _panelMode = tab; _panelHidden = NO; - [_problemsView setHidden:_panelMode != 1]; + [_problemsView setHidden:_panelMode != AXYNE_BOTTOM_PANEL_PROBLEMS]; [self setNeedsLayout:YES]; [self setNeedsDisplay:YES]; /* The list takes the keyboard (Up/Down/Enter) when its tab is chosen. */ - if (_panelMode == 1 && [self window] != nil) + if (_panelMode == AXYNE_BOTTOM_PANEL_PROBLEMS && [self window] != nil) [[self window] makeFirstResponder:_problemsView]; - if (_panelMode == 2) [self startTerminal:nil]; + if (_panelMode == AXYNE_BOTTOM_PANEL_TERMINAL) [self startTerminal:nil]; +} + +/* Width of a tab button: its title plus padding; the Figma sizes for the + * three base tabs (출력 38, 터미널 50, 문제 38 until it shows a count). */ +- (CGFloat)bottomPanelTabWidth:(AxyneBottomPanelTab)tab +{ + NSButton *button = [self bottomPanelTabButton:tab]; + NSFont *font = [button font] != nil ? [button font] : [NSFont systemFontOfSize:11]; + if (tab == AXYNE_BOTTOM_PANEL_OUTPUT) return 38; + if (tab == AXYNE_BOTTOM_PANEL_TERMINAL) return 50; + if (tab == AXYNE_BOTTOM_PANEL_PROBLEMS && [[button title] isEqualToString:@"문제"]) return 38; + return MAX(38, ceil([[button title] sizeWithAttributes: + @{NSFontAttributeName:font}].width) + 16); } - (BOOL)validateMenuItem:(NSMenuItem *)menuItem { SEL action = [menuItem action]; + /* Registry entries: feature categories first, then the base rules below + * (evaluated for the command's old selector). */ + if (action == @selector(performCommand:)) + return [self validateCommand:(AxyneCommandId)[menuItem tag] item:menuItem]; /* Empty state: only the hidden placeholder exists, so there is nothing * to save, close, build, search or edit. */ BOOL emptyState = [self isEmptyState]; @@ -2949,7 +2505,7 @@ - (void)applyPreferences for (NSButton *button in @[_newButton, _openButton, _saveButton, _undoButton, _redoButton, _buildButton, _runButton, _targetButton, _searchButton, - _outputTab, _problemsTab, _terminalTab, + _outputTab, _problemsTab, _terminalTab, _debugConsoleTab, _terminalStart, _terminalStop, _clearOutput]) { [(AxyneChromeButton *)button setLabelColor:axyne_preference_color(_preferences.theme.muted)]; [(AxyneChromeButton *)button setFillColor:nil]; @@ -3039,6 +2595,9 @@ - (void)applyPreferences [self updateBraceHighlight]; [self applyEditorLexer]; } +#define AXYNE_MAC_HOOK(feature) [self feature##ApplyPreferences]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK [self setNeedsLayout:YES]; /* the Git panel follows the theme in -layout */ [self setNeedsDisplay:YES]; } @@ -3253,6 +2812,12 @@ - (BOOL)loadActiveDocument [self refreshActionControls]; [self refreshProblems]; /* the active file's problems come first */ [self refreshOutline]; + { + AxyneDocument *activated = [self isEmptyState] ? NULL : doc; +#define AXYNE_MAC_HOOK(feature) [self feature##DocumentActivated:activated]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK + } /* In the empty state and behind an image preview the editor stays hidden * and keeps no focus. */ if ([self window] != nil && ![self isEmptyState] && ![self imagePreviewShown]) @@ -3318,6 +2883,9 @@ - (void)notification:(SCNotification *)notification [self setNeedsDisplay:YES]; [self updateWindowTitle]; } +#define AXYNE_MAC_HOOK(feature) [self feature##EditorNotification:notification]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK [self refreshActionControls]; } @@ -3359,6 +2927,7 @@ - (BOOL)saveActiveToPath:(NSString *)path (void)[self sendEditorMessage:SCI_SETSAVEPOINT wParam:0 lParam:0]; [self afterSaveAsFromPath:oldPath]; free(oldPath); + [self notifyDocumentSaved]; [self refreshOutline]; /* Save As may change the file type */ [self setNeedsDisplay:YES]; [self updateWindowTitle]; @@ -3390,6 +2959,7 @@ - (BOOL)saveActive return NO; } (void)[self sendEditorMessage:SCI_SETSAVEPOINT wParam:0 lParam:0]; + [self notifyDocumentSaved]; [self refreshOutline]; /* Save As may change the file type */ [self setNeedsDisplay:YES]; [self updateWindowTitle]; @@ -3518,6 +3088,7 @@ - (void)openPath:(NSString *)path asPreview:(BOOL)preview return; } if (replaced) { + [self notifyDocumentWillClose:&evicted]; if (_lsp != NULL) (void)axyne_lsp_did_close(_lsp, &evicted, NULL); [self clearLspProblemsForPath:evicted.path]; if (evicted.owns_native_editor_document) @@ -3589,8 +3160,14 @@ - (BOOL)selectWorkspaceURL:(NSURL *)url _workspacePreferencesPath = axyne_macos_workspace_preferences_path(path); if (_workspacePreferencesPath != NULL) { AxynePreferences workspacePreferences; - status = axyne_preferences_load(_workspacePreferencesPath, - &workspacePreferences, &error); + char *readPath = axyne_macos_preferences_read_path(_workspacePreferencesPath); + status = readPath == NULL ? AXYNE_STATUS_OUT_OF_MEMORY : + axyne_preferences_load(readPath, &workspacePreferences, &error); + if (readPath == NULL) { + error.code = status; + (void)snprintf(error.message, sizeof(error.message), "Out of memory"); + } + free(readPath); if (status == AXYNE_STATUS_OK) { /* Keep the file's binding mask separate from the normalized * snapshot used to calculate effective workspace preferences. */ @@ -3605,6 +3182,7 @@ - (BOOL)selectWorkspaceURL:(NSURL *)url [self applyPreferences]; } else [self applyPreferences]; } + [self rebuildKeymap]; /* workspace keybindings */ [_gitPanel workspaceChanged]; [self setNeedsDisplay:YES]; return YES; @@ -4288,7 +3866,7 @@ - (NSRect)menuBarItemRect:(NSUInteger)index NSDictionary *attributes = @{NSFontAttributeName:[NSFont systemFontOfSize:12]}; CGFloat x = AXYNE_UI_MENU_INSET; for (NSUInteger i = 0; i < AXYNE_UI_MENU_COUNT; ++i) { - CGFloat width = ceil([axyne_macos_menu_label(i) sizeWithAttributes:attributes].width) + + CGFloat width = ceil([axyne_macos_menu_title(i) sizeWithAttributes:attributes].width) + 2 * AXYNE_UI_MENU_PAD; if (i == index) return NSMakeRect(x, 2, width, AXYNE_MENU - 4); x += width + AXYNE_UI_MENU_GAP; @@ -4541,7 +4119,7 @@ - (void)zoomResetEditor:(id)sender } /* Session-only: the effective preference is flipped in memory and is not - * written back to preferences.json. */ + * written back to settings.json. */ - (void)toggleWordWrap:(id)sender { (void)sender; @@ -4712,6 +4290,7 @@ - (void)gitPanel:(AxyneGitPanelView *)panel openDiffTitle:(NSString *)title return; } if (replaced) { + [self notifyDocumentWillClose:&evicted]; if (_lsp != NULL) (void)axyne_lsp_did_close(_lsp, &evicted, NULL); [self clearLspProblemsForPath:evicted.path]; if (evicted.owns_native_editor_document) @@ -4768,117 +4347,65 @@ - (void)clearBreakpoints:(id)sender stream:AXYNE_PROCESS_STDERR]; } -- (BOOL)preferencesFileExists -{ - NSString *path; - if (_globalPreferencesPath == NULL) return NO; - path = [NSString stringWithUTF8String:_globalPreferencesPath]; - return path != nil && [[NSFileManager defaultManager] fileExistsAtPath:path]; -} - -/* Opens the existing global preferences document (preferences.json) as a tab. */ -- (void)openPreferencesFile:(id)sender -{ - (void)sender; - if (![self preferencesFileExists]) return; - [self openPath:[NSString stringWithUTF8String:_globalPreferencesPath]]; -} - -static NSString *axyne_macos_shortcut_text(NSString *key, NSEventModifierFlags flags) +/* The global settings file a load reads: settings.json, or the old + * preferences.json while no settings.json exists (D3); nil when neither + * exists. */ +- (NSString *)existingGlobalPreferencesPath { - NSMutableString *text = [NSMutableString string]; - unichar character; - if ([key length] == 0) return nil; - character = [key characterAtIndex:0]; - /* AppKit treats an upper-case letter equivalent as implying Shift. */ - if (![key isEqualToString:[key lowercaseString]]) flags |= NSEventModifierFlagShift; - if ((flags & NSEventModifierFlagControl) != 0) [text appendString:@"⌃"]; - if ((flags & NSEventModifierFlagOption) != 0) [text appendString:@"⌥"]; - if ((flags & NSEventModifierFlagShift) != 0) [text appendString:@"⇧"]; - if ((flags & NSEventModifierFlagCommand) != 0) [text appendString:@"⌘"]; - if (character >= NSF1FunctionKey && character <= NSF12FunctionKey) - [text appendFormat:@"F%d", (int)(character - NSF1FunctionKey) + 1]; - else if (character == NSUpArrowFunctionKey) [text appendString:@"↑"]; - else if (character == NSDownArrowFunctionKey) [text appendString:@"↓"]; - else [text appendString:[key uppercaseString]]; - return text; + char *readPath = axyne_macos_preferences_read_path(_globalPreferencesPath); + NSString *path = readPath != NULL ? [NSString stringWithUTF8String:readPath] : nil; + free(readPath); + return path != nil && [[NSFileManager defaultManager] fileExistsAtPath:path] ? path : nil; } -/* Appends @[title, shortcut] for every menu item that carries a shortcut. */ -static void axyne_macos_collect_shortcuts(NSMenu *menu, NSMutableArray *out) +- (BOOL)preferencesFileExists { - for (NSMenuItem *item in [menu itemArray]) { - NSString *shortcut; - if ([item isSeparatorItem]) continue; - if ([item submenu] != nil) { axyne_macos_collect_shortcuts([item submenu], out); continue; } - shortcut = axyne_macos_shortcut_text([item keyEquivalent], - [item keyEquivalentModifierMask]); - if (shortcut == nil) continue; - [out addObject:@[[item title], shortcut]]; - } + return [self existingGlobalPreferencesPath] != nil; } -/* Shows sections (@[title, rows]; row = @[label, shortcut]) in the keyboard - * shortcuts dialog. The C strings are borrowed from the arrays for the call. */ -static void axyne_macos_show_shortcut_sections(NSWindow *owner, NSArray *sections) +/* "settings.json 열기" needs the file written from now on (D3). When only the + * old preferences.json exists its bytes are copied verbatim, so a workspace + * keeps every override (never an empty profile); when no file exists at all, + * settings.json is created from the effective profile (a new workspace file + * overrides nothing). Same rule as the Windows adapter. */ +- (BOOL)ensureSettingsFile:(BOOL)workspace { - size_t count = [sections count]; - AxyneShortcutSection *converted = (AxyneShortcutSection *)calloc( - count != 0 ? count : 1, sizeof(*converted)); - if (converted == NULL) return; - for (size_t i = 0; i < count; ++i) { - NSArray *section = [sections objectAtIndex:i]; - NSArray *rows = [section objectAtIndex:1]; - AxyneShortcutRow *convertedRows = (AxyneShortcutRow *)calloc( - [rows count] != 0 ? [rows count] : 1, sizeof(*convertedRows)); - if (convertedRows == NULL) { - for (size_t j = 0; j < i; ++j) free((void *)converted[j].rows); - free(converted); - return; - } - for (NSUInteger row = 0; row < [rows count]; ++row) { - convertedRows[row].label = [[[rows objectAtIndex:row] objectAtIndex:0] UTF8String]; - convertedRows[row].keys = [[[rows objectAtIndex:row] objectAtIndex:1] UTF8String]; + const char *path = workspace ? _workspacePreferencesPath : _globalPreferencesPath; + NSString *file; + AxyneError error; + AxyneStatus status; + if (path == NULL) return NO; + if (access(path, F_OK) == 0) return YES; + file = [NSString stringWithUTF8String:path]; + if (file == nil) return NO; + memset(&error, 0, sizeof(error)); + status = axyne_preferences_adopt_legacy_file( + [[file stringByDeletingLastPathComponent] fileSystemRepresentation], &error); + if (status == AXYNE_STATUS_NOT_FOUND) { + AxynePreferences created = workspace ? _preferences : _globalPreferences; + memset(&error, 0, sizeof(error)); + if (workspace) { + created.present_fields = 0; + memset(created.binding_present, 0, sizeof(created.binding_present)); } - converted[i].title = [[section objectAtIndex:0] UTF8String]; - converted[i].rows = convertedRows; - converted[i].row_count = [rows count]; + status = workspace ? axyne_preferences_save_workspace(&created, path, &error) + : axyne_preferences_save_global(&created, path, &error); } - axyne_shortcuts_dialog_show(owner, converted, count); - for (size_t i = 0; i < count; ++i) free((void *)converted[i].rows); - free(converted); + if (status != AXYNE_STATUS_OK) { + [self showWorkspaceError:@"Unable to create settings file" error:&error]; + return NO; + } + return YES; } -/* Lists the shortcuts the menus actually carry, plus the effective - * Preferences bindings (which may differ from the menu defaults). */ -- (void)showKeyboardShortcuts:(id)sender +/* Tools > settings.json 열기: opens the global settings.json, copied from the + * old preferences.json first when only that exists (disabled while neither + * exists). */ +- (void)openPreferencesFile:(id)sender { - NSMutableArray *sections = [NSMutableArray array]; - NSMutableArray *bindings = [NSMutableArray array]; (void)sender; - for (NSMenuItem *top in [[NSApp mainMenu] itemArray]) { - NSMutableArray *rows = [NSMutableArray array]; - if ([top submenu] == nil) continue; - axyne_macos_collect_shortcuts([top submenu], rows); - if ([rows count] != 0) [sections addObject:@[[top title], rows]]; - } - for (size_t i = 0; i < _preferences.binding_count; ++i) { - const AxyneKeyBinding *binding = &_preferences.bindings[i]; - NSMutableString *keys = [NSMutableString string]; - NSString *key; - NSString *title; - if (!binding->enabled || binding->key[0] == '\0') continue; - if ((binding->modifiers & AXYNE_KEY_MODIFIER_CONTROL) != 0) [keys appendString:@"⌃"]; - if ((binding->modifiers & AXYNE_KEY_MODIFIER_ALT) != 0) [keys appendString:@"⌥"]; - if ((binding->modifiers & AXYNE_KEY_MODIFIER_SHIFT) != 0) [keys appendString:@"⇧"]; - if ((binding->modifiers & AXYNE_KEY_MODIFIER_COMMAND) != 0) [keys appendString:@"⌘"]; - key = [NSString stringWithUTF8String:binding->key]; - title = [NSString stringWithUTF8String:axyne_dialogs_action_title((int)binding->action)]; - [keys appendString:key != nil ? [key uppercaseString] : @""]; - [bindings addObject:@[title != nil ? title : @"", keys]]; - } - if ([bindings count] != 0) [sections addObject:@[@"환경 설정 키 바인딩", bindings]]; - axyne_macos_show_shortcut_sections([self window], sections); + if (_globalPreferencesPath == NULL || ![self ensureSettingsFile:NO]) return; + [self openPath:[NSString stringWithUTF8String:_globalPreferencesPath]]; } - (void)reportIssue:(id)sender @@ -4947,6 +4474,9 @@ - (void)setRecentMenu:(NSMenu *)menu [self refreshRecentMenu]; } +/* File > 최근 항목: recent files, then the recent folders group and 최근 + * 항목 지우기 (D6). Until the file feature persists folders the folder group + * shows its empty placeholder and the clear entry stays disabled. */ - (void)refreshRecentMenu { if (_recentMenu == nil) return; @@ -4957,8 +4487,6 @@ - (void)refreshRecentMenu [empty setEnabled:NO]; [_recentMenu addItem:empty]; [empty release]; - axyne_macos_style_menu(_recentMenu); - return; } for (size_t i = 0; i < _documents.recent_count; ++i) { NSString *path = [NSString stringWithUTF8String:_documents.recent_paths[i]]; @@ -4970,6 +4498,16 @@ - (void)refreshRecentMenu [_recentMenu addItem:item]; [item release]; } + [_recentMenu addItem:[NSMenuItem separatorItem]]; + { + NSMenuItem *folders = [[NSMenuItem alloc] initWithTitle:@"최근 폴더 없음" + action:nil keyEquivalent:@""]; + [folders setEnabled:NO]; + [_recentMenu addItem:folders]; + [folders release]; + } + [_recentMenu addItem:[NSMenuItem separatorItem]]; + [_recentMenu addItem:[self commandMenuItem:AXYNE_COMMAND_FILE_CLEAR_RECENT]]; axyne_macos_style_menu(_recentMenu); } @@ -5025,6 +4563,7 @@ - (void)closeDocumentAtIndex:(size_t)index if (![self selectDocumentAtIndex:successor]) return; } AxyneDocument *doc = &_documents.documents[index]; + [self notifyDocumentWillClose:doc]; if (_lsp != NULL) (void)axyne_lsp_did_close(_lsp, doc, NULL); [self clearLspProblemsForPath:doc->path]; if (doc->owns_native_editor_document) @@ -5355,22 +4894,15 @@ - (void)rightMouseDown:(NSEvent *)event _explorerSelection = row; _hasExplorerSelection = YES; } + /* Explorer context menu from the registry (탐색기 group). */ NSMenu *menu = [[[NSMenu alloc] initWithTitle:@"Workspace"] autorelease]; - NSMenuItem *workspace = [menu addItemWithTitle:@"Open Workspace Folder..." - action:@selector(openWorkspace:) keyEquivalent:@""]; - [workspace setTarget:self]; + [menu addItem:[self commandMenuItem:AXYNE_COMMAND_FILE_OPEN_FOLDER]]; [menu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *file = [menu addItemWithTitle:@"New File" - action:@selector(newExplorerFile:) keyEquivalent:@""]; - NSMenuItem *folder = [menu addItemWithTitle:@"New Folder" - action:@selector(newExplorerFolder:) keyEquivalent:@""]; - [file setTarget:self]; [folder setTarget:self]; + [menu addItem:[self commandMenuItem:AXYNE_COMMAND_EXPLORER_NEW_FILE]]; + [menu addItem:[self commandMenuItem:AXYNE_COMMAND_EXPLORER_NEW_FOLDER]]; if (row != NSNotFound) { - NSMenuItem *rename = [menu addItemWithTitle:@"Rename" - action:@selector(renameExplorerItem:) keyEquivalent:@""]; - NSMenuItem *remove = [menu addItemWithTitle:@"Delete" - action:@selector(removeExplorerItem:) keyEquivalent:@""]; - [rename setTarget:self]; [remove setTarget:self]; + [menu addItem:[self commandMenuItem:AXYNE_COMMAND_EXPLORER_RENAME]]; + [menu addItem:[self commandMenuItem:AXYNE_COMMAND_EXPLORER_DELETE]]; } /* The popup's top-left corner sits at the click point. */ NSRect click = [[self window] convertRectToScreen: @@ -5379,21 +4911,16 @@ - (void)rightMouseDown:(NSEvent *)event [self setNeedsDisplay:YES]; } +/* Every key equivalent goes through the effective key map first (see + * -handleKeyEvent: in ui_commands_macos.m); unmatched keys and disabled + * commands fall through to AppKit and the menus. */ - (BOOL)performKeyEquivalent:(NSEvent *)event { - if ([[self window] firstResponder] == _shellView && [_shellView performKeyEquivalent:event]) return YES; - NSString *key = [event charactersIgnoringModifiers]; - if (axyne_macos_palette_shift_matches(&_preferences, key, event)) { [self openPaletteWithInput:@">"]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_SEARCH_WORKSPACE, key, event)) { [self searchFolder:NO]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_FIND, key, event)) { [self findOrReplace:NO]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_REPLACE, key, event)) { [self findOrReplace:YES]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_QUICK_FILE, key, event)) { [self openPaletteWithInput:@""]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_NEW, key, event)) { [self newDocument:nil]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_OPEN, key, event)) { [self openDocument:nil]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_SAVE, key, event)) { [self saveDocument:nil]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_CLOSE, key, event)) { [self closeDocument:nil]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_BUILD, key, event)) { [self buildDocument:nil]; return YES; } - if (axyne_macos_binding_matches(&_preferences, AXYNE_ACTION_RUN, key, event)) { [self runDocument:nil]; return YES; } + /* The focused terminal grid takes Cmd+C/V/A and every key without + * Command (direct shell input) before the key map. */ + if ([[self window] firstResponder] == _shellView && [_shellView performKeyEquivalent:event]) + return YES; + if ([self handleKeyEvent:event]) return YES; return [super performKeyEquivalent:event]; } @@ -5431,29 +4958,34 @@ - (BOOL)savePreferences:(AxynePreferences *)edited workspace:(BOOL)workspace sizeof(_workspaceBindingPresent)); if (!workspace) { _globalPreferences = *edited; - if (_workspacePreferencesPath != NULL) { + char *workspaceReadPath = axyne_macos_preferences_read_path(_workspacePreferencesPath); + if (workspaceReadPath != NULL) { AxynePreferences workspacePreferences; AxyneStatus workspaceStatus = axyne_preferences_load_workspace( - _workspacePreferencesPath, &workspacePreferences, &error); + workspaceReadPath, &workspacePreferences, &error); + free(workspaceReadPath); if (workspaceStatus == AXYNE_STATUS_OK) axyne_preferences_apply_workspace(&_preferences, &workspacePreferences); else if (workspaceStatus != AXYNE_STATUS_NOT_FOUND) [self showWorkspaceError:@"Unable to reload workspace preferences" error:&error]; } } + [self rebuildKeymap]; [self applyPreferences]; return YES; } /* Opens the Figma preferences window. The "settings.json 열기" link closes it - * and opens the profile's file as an editor document; a profile that was - * never saved is created first so there is something to open. */ + * and opens the profile's settings.json as an editor document; it is made + * by -ensureSettingsFile: first (copy of the old preferences.json, or the + * profile when neither file exists, D3). */ - (BOOL)showPreferences:(BOOL)workspace { AxynePreferences initial = workspace ? _preferences : _globalPreferences; AxyneMacPreferencesContext context = {self, workspace}; AxynePreferencesWindowHooks hooks = {&context, axyne_macos_preferences_save_hook}; const char *path = workspace ? _workspacePreferencesPath : _globalPreferencesPath; + char *readPath; AxyneError error; if (path == NULL) { [self showWorkspaceMessage:@"The preference path is unavailable."]; return NO; } if (workspace) { @@ -5461,22 +4993,20 @@ - (BOOL)showPreferences:(BOOL)workspace AxynePreferences stored; memset(initial.binding_present, 0, sizeof(initial.binding_present)); initial.present_fields = 0; - if (axyne_preferences_load(path, &stored, &error) == AXYNE_STATUS_OK) { + readPath = axyne_macos_preferences_read_path(path); + if (readPath != NULL && + axyne_preferences_load(readPath, &stored, &error) == AXYNE_STATUS_OK) { initial.present_fields = stored.present_fields; memcpy(initial.binding_present, stored.binding_present, sizeof(initial.binding_present)); } + free(readPath); } if (!axyne_preferences_window_show([self window], workspace ? 1 : 0, &initial, &hooks)) return YES; - if (access(path, F_OK) != 0) { - AxynePreferences created = workspace ? _preferences : _globalPreferences; - AxyneStatus status; - if (workspace) { created.present_fields = 0; memset(created.binding_present, 0, sizeof(created.binding_present)); } - status = workspace ? axyne_preferences_save_workspace(&created, path, &error) - : axyne_preferences_save_global(&created, path, &error); - if (status != AXYNE_STATUS_OK) { [self showWorkspaceError:@"Unable to create settings file" error:&error]; return NO; } - } + /* The window's save hook may have replaced the profile paths. */ + path = workspace ? _workspacePreferencesPath : _globalPreferencesPath; + if (path == NULL || ![self ensureSettingsFile:workspace]) return NO; [self openPath:[NSString stringWithUTF8String:path]]; return YES; } @@ -5657,7 +5187,7 @@ - (void)startGitOperationWithEmptyMessage:(const char *)empty_message - (void)selectOutputPanel { - _panelMode = 0; + _panelMode = AXYNE_BOTTOM_PANEL_OUTPUT; /* Output must be visible even if View > Bottom Panel hid the panel. */ _panelHidden = NO; [self setNeedsLayout:YES]; @@ -6168,8 +5698,12 @@ - (void)toggleBreakpoint:(id)sender - (void)startTerminal:(id)sender { (void)sender; - _panelMode = 2; _panelHidden = NO; - [self setNeedsLayout:YES]; [self setNeedsDisplay:YES]; + /* Show the terminal even if the panel was hidden or on another tab; + * start the native shell when none runs. */ + _panelMode = AXYNE_BOTTOM_PANEL_TERMINAL; + _panelHidden = NO; + [self setNeedsLayout:YES]; + [self setNeedsDisplay:YES]; if ([_shellView running]) { [[self window] makeFirstResponder:_shellView]; return; } AxyneProcessSpec processSpec = {0}; AxyneError error = {0}; AxyneStatus status = axyne_runner_process_spec(&_terminalRunner, NULL, NULL, NULL, &processSpec, &error); @@ -6185,7 +5719,7 @@ - (void)startTerminal:(id)sender } - (void)stopTerminal:(id)sender { (void)sender; - if (_panelMode == 2) [_shellView stop]; + if (_panelMode == AXYNE_BOTTOM_PANEL_TERMINAL) [_shellView stop]; else if (_actionProcess) axyne_process_terminate(_actionProcess, NULL); [self refreshActionControls]; } @@ -6775,26 +6309,29 @@ - (void)layout NSInteger visibleRows = MAX(1, (NSInteger)((statusTop - outlineHeight - editorTop - AXYNE_UI_EXPLORER_HEADER) / AXYNE_UI_ROW)); _explorerFirstRow = MIN(_explorerFirstRow, MAX(0, (NSInteger)_explorer.count - visibleRows)); - BOOL terminal = _panelMode == 2; + /* Output tab: build/run/debugger text; Terminal tab: the native shell + * grid in the same rect. */ + BOOL terminal = _panelMode == AXYNE_BOTTOM_PANEL_TERMINAL; [_terminalScroll setFrame:NSMakeRect([self sidebarWidth] + 16, bottomTop + 32, MAX(0, width - [self sidebarWidth] - 24), MAX(0, [self panelHeight] - 32))]; - [_terminalScroll setHidden:_panelMode != 0]; + [_terminalScroll setHidden:_panelMode != AXYNE_BOTTOM_PANEL_OUTPUT]; [_shellView setFrame:[_terminalScroll frame]]; [_shellView setHidden:!terminal]; [_problemsView setFrame:NSMakeRect([self sidebarWidth], bottomTop + 32, MAX(0, width - [self sidebarWidth]), MAX(0, [self panelHeight] - 32))]; - [_problemsView setHidden:_panelMode != 1]; - [_outputTab setFrame:NSMakeRect([self sidebarWidth] + 8, bottomTop, 38, 32)]; + [_problemsView setHidden:_panelMode != AXYNE_BOTTOM_PANEL_PROBLEMS]; { - /* The "문제 N" label grows with the count; the terminal tab follows. */ - CGFloat problemsWidth = 38; - if (![[_problemsTab title] isEqualToString:@"문제"]) - problemsWidth = MAX(38, ceil([[_problemsTab title] sizeWithAttributes: - @{NSFontAttributeName:[_problemsTab font] != nil ? [_problemsTab font] - : [NSFont systemFontOfSize:11]}].width) + 16); - [_problemsTab setFrame:NSMakeRect([self sidebarWidth] + 48, bottomTop, - problemsWidth, 32)]; - [_terminalTab setFrame:NSMakeRect([self sidebarWidth] + 50 + problemsWidth, - bottomTop, 50, 32)]; + /* Tab strip from the tab model: visible tabs left to right, 2px + * apart (the "문제 N" label grows with the count and pushes the + * following tabs). Hidden tabs keep their last frame. */ + CGFloat tabX = [self sidebarWidth] + 8; + for (NSInteger tab = 0; tab < AXYNE_BOTTOM_PANEL_TAB_COUNT; ++tab) { + NSButton *button = [self bottomPanelTabButton:(AxyneBottomPanelTab)tab]; + CGFloat tabWidth; + if (![self bottomPanelTabVisible:(AxyneBottomPanelTab)tab]) continue; + tabWidth = [self bottomPanelTabWidth:(AxyneBottomPanelTab)tab]; + [button setFrame:NSMakeRect(tabX, bottomTop, tabWidth, 32)]; + tabX += tabWidth + 2; + } } [_terminalStart setFrame:NSMakeRect(width - 100, bottomTop + 2, 28, 28)]; [_clearOutput setFrame:NSMakeRect(width - 68, bottomTop + 2, 28, 28)]; @@ -6821,7 +6358,7 @@ - (void)layout CGFloat searchWidth = width - 8 - searchLeft; [_searchButton setHidden:searchWidth < 120]; [_searchButton setFrame:NSMakeRect(searchLeft, AXYNE_MENU + 6, MAX(0, searchWidth), 26)]; - for (NSButton *button in @[_outputTab, _problemsTab, _terminalTab]) { + for (NSButton *button in @[_outputTab, _problemsTab, _terminalTab, _debugConsoleTab]) { [(AxyneChromeButton *)button setLabelColor:axyne_preference_color( [button tag] == _panelMode ? _preferences.theme.text : _preferences.theme.muted)]; [button setNeedsDisplay:YES]; @@ -6835,13 +6372,32 @@ - (void)layout } /* A hidden bottom panel takes every panel control with it; the per-mode * visibility set above still applies when it is shown. */ - for (NSView *panelView in @[_outputTab, _problemsTab, _terminalTab, - _terminalStart, _clearOutput, _terminalStop]) + for (NSView *panelView in @[_terminalStart, _clearOutput, _terminalStop]) [panelView setHidden:_panelHidden]; - if (_panelHidden) { - [_terminalScroll setHidden:YES]; [_shellView setHidden:YES]; - [_problemsView setHidden:YES]; + for (NSInteger tab = 0; tab < AXYNE_BOTTOM_PANEL_TAB_COUNT; ++tab) + [[self bottomPanelTabButton:(AxyneBottomPanelTab)tab] setHidden:_panelHidden || + ![self bottomPanelTabVisible:(AxyneBottomPanelTab)tab]]; + { + /* Feature categories lay out their own panel content; a category + * that shows its own view for the selected tab replaces the default + * output, terminal grid and problems views. */ + NSRect content = _panelHidden ? NSZeroRect : + NSMakeRect([self sidebarWidth], bottomTop + 32, + MAX(0, width - [self sidebarWidth]), MAX(0, [self panelHeight] - 32)); + AxyneBottomPanelTab selectedTab = (AxyneBottomPanelTab)_panelMode; + BOOL ownedTab = NO; +#define AXYNE_MAC_HOOK(feature) \ + if ([self feature##LayoutBottomPanelInRect:content tab:selectedTab]) ownedTab = YES; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK + if (_panelHidden || ownedTab || selectedTab == AXYNE_BOTTOM_PANEL_DEBUG_CONSOLE) { + [_terminalScroll setHidden:YES]; [_shellView setHidden:YES]; + [_problemsView setHidden:YES]; + } } +#define AXYNE_MAC_HOOK(feature) [self feature##Layout]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK [self layoutPalette]; [self refreshSplitterTracking]; } @@ -6992,7 +6548,7 @@ - (void)drawRect:(NSRect)dirtyRect [menuBackground setFill]; NSRectFill(NSMakeRect(0, 0, width, AXYNE_MENU)); [menuBorder setFill]; NSRectFill(NSMakeRect(0, AXYNE_MENU - 1, width, 1)); for (NSUInteger menuIndex = 0; menuIndex < AXYNE_UI_MENU_COUNT; ++menuIndex) { - NSString *menuLabel = axyne_macos_menu_label(menuIndex); + NSString *menuLabel = axyne_macos_menu_title(menuIndex); NSRect itemRect = [self menuBarItemRect:menuIndex]; BOOL lit = (NSInteger)menuIndex == _activeMenuIndex || (NSInteger)menuIndex == _hoverMenuIndex; @@ -7032,14 +6588,25 @@ - (void)drawRect:(NSRect)dirtyRect [[NSBezierPath bezierPathWithRoundedRect:[_searchButton frame] xRadius:4 yRadius:4] stroke]; } if (!_panelHidden) { - NSButton *selected = _panelMode == 0 ? _outputTab : (_panelMode == 1 ? _problemsTab : _terminalTab); + NSButton *selected = [self bottomPanelTabButton:(AxyneBottomPanelTab)_panelMode]; + NSButton *lastTab = _outputTab; + AxyneMacChromeColors chrome = [self chromeColors]; + for (NSInteger tab = 0; tab < AXYNE_BOTTOM_PANEL_TAB_COUNT; ++tab) + if ([self bottomPanelTabVisible:(AxyneBottomPanelTab)tab]) + lastTab = [self bottomPanelTabButton:(AxyneBottomPanelTab)tab]; [axyne_preference_color(reference ? 0xa66bf0 : _preferences.theme.accent) setFill]; - NSRectFill(NSMakeRect(NSMinX([selected frame]), bottomTop + 29, NSWidth([selected frame]), 3)); + if (selected != nil) + NSRectFill(NSMakeRect(NSMinX([selected frame]), bottomTop + 29, NSWidth([selected frame]), 3)); NSString *shell = _terminalRunner.executable == NULL ? @"" : [[NSString stringWithUTF8String:_terminalRunner.executable] lastPathComponent]; [self drawLabel:shell at:NSMakePoint(MAX([self sidebarWidth] + 148, - NSMaxX([_terminalTab frame]) + 10), bottomTop + 10) + NSMaxX([lastTab frame]) + 10), bottomTop + 10) size:11 color:muted family:@"SF Pro Text"]; +#define AXYNE_MAC_HOOK(feature) \ + [self feature##DrawBottomPanelStripInRect:NSMakeRect([self sidebarWidth], bottomTop, \ + MAX(0, width - [self sidebarWidth]), 32) colors:&chrome]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK } if (_palette.active) [self drawPaletteFieldInRect:[_searchButton frame]]; @@ -7129,11 +6696,37 @@ - (void)drawRect:(NSRect)dirtyRect [NSGraphicsContext restoreGraphicsState]; [self drawOutlineAtTop:treeBottom light:light reference:reference]; } + { + AxyneMacChromeColors chrome = [self chromeColors]; + NSRect statusRect = NSMakeRect(0, statusTop, width, AXYNE_STATUS); + BOOL statusDrawn = NO; +#define AXYNE_MAC_HOOK(feature) \ + if (!statusDrawn) statusDrawn = [self feature##DrawStatusBarInRect:statusRect colors:&chrome]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK + if (!statusDrawn) [self drawDefaultStatusAtTop:statusTop width:width text:text muted:muted]; + } + if (_editorView == nil) + [self drawLabel:@"Required Scintilla framework failed to load" + at:NSMakePoint([self sidebarWidth] + 24, editorTop + 24) size:12 color:muted family:@"Menlo"]; +} + +/* The status bar text shown when no feature category draws the segments: + * run state on the left, line/column, indentation and encoding on the right. */ +- (void)drawDefaultStatusAtTop:(CGFloat)statusTop width:(CGFloat)width + text:(NSColor *)text muted:(NSColor *)muted +{ NSString *status = _lastExitFailed ? [NSString stringWithFormat:@"✗ 실행 실패 (%d)", _lastExitCode] : (_activeAction != 0 ? @"● 실행 중" : (_hasExitStatus ? @"✓ 실행 완료" : @"준비")); - [self drawLabel:status at:NSMakePoint(12, statusTop + 5) size:11 - color:_lastExitFailed ? axyne_preference_color(0xe5a445) : - (_hasExitStatus ? axyne_preference_color(0xa3c98a) : muted) family:@"SF Pro Text"]; + if (_chordHint != nil) { + /* A pending Cmd+K chord replaces the run state until it resolves. */ + [self drawLabel:_chordHint at:NSMakePoint(12, statusTop + 5) size:11 + color:text family:@"SF Pro Text"]; + } else { + [self drawLabel:status at:NSMakePoint(12, statusTop + 5) size:11 + color:_lastExitFailed ? axyne_preference_color(0xe5a445) : + (_hasExitStatus ? axyne_preference_color(0xa3c98a) : muted) family:@"SF Pro Text"]; + } if (![self isEmptyState]) { /* No editor position in the empty state, so the whole editing * summary (line/column, indent style, encoding) stays blank. */ @@ -7146,12 +6739,48 @@ - (void)drawRect:(NSRect)dirtyRect [self drawLabel:editing at:NSMakePoint(MAX(180, width - 320), statusTop + 5) size:11 color:text family:@"SF Pro Text"]; } - if (_editorView == nil) - [self drawLabel:@"Required Scintilla framework failed to load" - at:NSMakePoint([self sidebarWidth] + 24, editorTop + 24) size:12 color:muted family:@"Menlo"]; +} + +- (AxyneMacChromeColors)chromeColors +{ + AxyneMacChromeColors colors; + const AxyneThemePreferences *theme = &_preferences.theme; + BOOL reference = axyne_macos_reference_surfaces(theme); + colors.background = axyne_preference_color(theme->background); + colors.panel = axyne_preference_color(theme->panel); + colors.toolbar = axyne_preference_color(theme->toolbar); + colors.border = axyne_preference_color(theme->border); + colors.text = axyne_preference_color(theme->text); + colors.muted = axyne_preference_color(theme->muted); + colors.accent = axyne_preference_color(reference ? 0xa66bf0 : theme->accent); + colors.tabBackground = axyne_preference_color(reference ? 0x17191c : theme->toolbar); + colors.output = axyne_preference_color(axyne_macos_output_background(theme)); + colors.statusBar = colors.toolbar; + colors.light = theme->preset == AXYNE_THEME_LIGHT || + (theme->preset == AXYNE_THEME_SYSTEM && !axyne_macos_prefers_dark(self)); + colors.reference = reference; + return colors; +} + +- (void)notifyDocumentSaved +{ + AxyneDocument *document = [self activeDocument]; +#define AXYNE_MAC_HOOK(feature) [self feature##DocumentSaved:document]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK +} + +- (void)notifyDocumentWillClose:(AxyneDocument *)document +{ +#define AXYNE_MAC_HOOK(feature) [self feature##DocumentWillClose:document]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK } - (void)dealloc { +#define AXYNE_MAC_HOOK(feature) [self feature##Teardown]; + AXYNE_MAC_FEATURES(AXYNE_MAC_HOOK) +#undef AXYNE_MAC_HOOK if (_popup != nil) { [_popup setDelegate:nil]; [_popup close]; @@ -7239,6 +6868,7 @@ - (void)dealloc [_buildButton release]; [_runButton release]; [_targetButton release]; [_searchButton release]; [_outputTab release]; [_problemsTab release]; [_terminalTab release]; + [_debugConsoleTab release]; [_clearOutput release]; [_problemsView setDelegate:nil]; [_problemsView removeFromSuperview]; @@ -7265,6 +6895,8 @@ - (void)dealloc [_editorLoadError release]; free(_globalPreferencesPath); free(_workspacePreferencesPath); + [self teardownKeyboard]; + axyne_keymap_destroy(&_keymap); [super dealloc]; } @@ -7321,6 +6953,7 @@ - (void)openPaletteWithInput:(NSString *)input (const char *const *)paths, count, [initial UTF8String]); free(paths); if (status != AXYNE_STATUS_OK) return; + [self paletteRebuildCommandRows]; /* "> " rows come from the registry */ _paletteOverlay = [[AxynePaletteOverlay alloc] initWithFrame:[self bounds]]; [_paletteOverlay setController:&_palette]; @@ -7419,6 +7052,7 @@ - (void)paletteSyncInput if (!_palette.active) return; const char *text = [[_paletteField stringValue] UTF8String]; (void)axyne_palette_ctl_set_input(&_palette, text != NULL ? text : ""); + [self paletteRebuildCommandRows]; [_paletteOverlay setNeedsDisplay:YES]; } @@ -7567,6 +7201,19 @@ - (void)paletteRunCommand:(AxynePaletteCommandId)command { AxyneDocument *document = [self activeDocument]; BOOL gitReady = _explorer.root != NULL && _gitProcess == NULL && !_gitBatchBusy; + AxyneCommandId registry; + /* Registry rows without a palette id, and palette commands a feature + * category now implements, run through the command routing. */ + if ((int)command >= AXYNE_MAC_PALETTE_REGISTRY_BASE) { + (void)[self runCommand:(AxyneCommandId)((int)command - AXYNE_MAC_PALETTE_REGISTRY_BASE)]; + return; + } + registry = axyne_command_from_palette((int)command); + if (registry != AXYNE_COMMAND_NONE && + [self featureValidateCommand:registry item:nil] != AXYNE_MAC_COMMAND_UNHANDLED) { + (void)[self runCommand:registry]; + return; + } switch (command) { case AXYNE_PALETTE_COMMAND_NEW_FILE: [self newDocument:nil]; break; case AXYNE_PALETTE_COMMAND_OPEN_FILE: [self openDocument:nil]; break; @@ -7600,10 +7247,11 @@ - (void)paletteRunCommand:(AxynePaletteCommandId)command case AXYNE_PALETTE_COMMAND_PANEL_OUTPUT: case AXYNE_PALETTE_COMMAND_PANEL_PROBLEMS: case AXYNE_PALETTE_COMMAND_PANEL_TERMINAL: - _panelMode = command == AXYNE_PALETTE_COMMAND_PANEL_OUTPUT ? 0 : - (command == AXYNE_PALETTE_COMMAND_PANEL_PROBLEMS ? 1 : 2); + _panelMode = command == AXYNE_PALETTE_COMMAND_PANEL_OUTPUT ? AXYNE_BOTTOM_PANEL_OUTPUT : + (command == AXYNE_PALETTE_COMMAND_PANEL_PROBLEMS ? AXYNE_BOTTOM_PANEL_PROBLEMS + : AXYNE_BOTTOM_PANEL_TERMINAL); _panelHidden = NO; - if (_panelMode == 2) [self startTerminal:nil]; + if (_panelMode == AXYNE_BOTTOM_PANEL_TERMINAL) [self startTerminal:nil]; [self setNeedsLayout:YES]; [self setNeedsDisplay:YES]; break; @@ -7701,32 +7349,6 @@ - (void)dealloc @end -/* Function keys must be given to AppKit as the private-use Unicode - * characters (NSF5FunctionKey, ...) with an empty modifier mask. */ -static void axyne_set_function_key(NSMenuItem *item, unichar key, - NSEventModifierFlags modifiers) -{ - [item setKeyEquivalent:[NSString stringWithCharacters:&key length:1]]; - [item setKeyEquivalentModifierMask:modifiers]; -} - -static NSString *axyne_macos_special_key(unichar key) -{ - return [NSString stringWithCharacters:&key length:1]; -} - -/* Adds a menu item bound to the workspace view with an explicit modifier - * mask (lower-case key equivalents, so Shift is never implied). */ -static NSMenuItem *axyne_macos_add_item(NSMenu *menu, NSString *title, SEL action, - id target, NSString *key, - NSEventModifierFlags modifiers) -{ - NSMenuItem *item = [menu addItemWithTitle:title action:action keyEquivalent:key]; - [item setKeyEquivalentModifierMask:modifiers]; - [item setTarget:target]; - return item; -} - static void axyne_install_menu(NSApplication *application, AxyneWorkspaceView *workspace) { @@ -7745,250 +7367,17 @@ static void axyne_install_menu(NSApplication *application, keyEquivalent:@"q"]; [appItem setSubmenu:appMenu]; [mainMenu addItem:appItem]; - NSMenuItem *fileItem = [[NSMenuItem alloc] initWithTitle:axyne_macos_menu_label(0) - action:nil keyEquivalent:@""]; - NSMenu *fileMenu = [[NSMenu alloc] initWithTitle:axyne_macos_menu_label(0)]; - NSMenuItem *newItem = [fileMenu addItemWithTitle:@"새 파일" - action:@selector(newDocument:) keyEquivalent:@"n"]; - [newItem setTarget:workspace]; - NSMenuItem *openItem = [fileMenu addItemWithTitle:@"열기…" - action:@selector(openDocument:) keyEquivalent:@"o"]; - [openItem setTarget:workspace]; - NSMenuItem *saveItem = [fileMenu addItemWithTitle:@"저장" - action:@selector(saveDocument:) keyEquivalent:@"s"]; - [saveItem setTarget:workspace]; - NSMenuItem *saveAsItem = [fileMenu addItemWithTitle:@"다른 이름으로 저장…" - action:@selector(saveDocumentAs:) keyEquivalent:@"S"]; - [saveAsItem setTarget:workspace]; - NSMenuItem *closeItem = [fileMenu addItemWithTitle:@"닫기" - action:@selector(closeDocument:) keyEquivalent:@"w"]; - [closeItem setTarget:workspace]; - [fileMenu addItem:[NSMenuItem separatorItem]]; - /* Command+Shift+O (an upper-case equivalent implies Shift, as with - * Save As); the empty-state shortcut guide shows the same chord. */ - NSMenuItem *workspaceItem = [fileMenu addItemWithTitle:@"폴더 열기…" - action:@selector(openWorkspace:) keyEquivalent:@"O"]; - [workspaceItem setTarget:workspace]; - NSMenuItem *workspacePreferences = [fileMenu addItemWithTitle:@"작업 영역 설정…" - action:@selector(showWorkspacePreferences:) keyEquivalent:@""]; - [workspacePreferences setTarget:workspace]; - [fileMenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *gitStatus = [fileMenu addItemWithTitle:@"Git 상태" - action:@selector(showGitStatus:) keyEquivalent:@""]; - NSMenuItem *gitDiff = [fileMenu addItemWithTitle:@"Git 변경 사항" - action:@selector(showGitDiff:) keyEquivalent:@""]; - NSMenuItem *gitStage = [fileMenu addItemWithTitle:@"모두 스테이지" - action:@selector(stageAllGitChanges:) keyEquivalent:@""]; - NSMenuItem *gitUnstage = [fileMenu addItemWithTitle:@"모두 스테이지 해제" - action:@selector(unstageAllGitChanges:) keyEquivalent:@""]; - [gitStatus setTarget:workspace]; [gitDiff setTarget:workspace]; - [gitStage setTarget:workspace]; [gitUnstage setTarget:workspace]; - [fileMenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *gitCommit = [fileMenu addItemWithTitle:@"Git 커밋…" - action:@selector(commitGitChanges:) keyEquivalent:@""]; - NSMenuItem *gitPush = [fileMenu addItemWithTitle:@"Git 푸시" - action:@selector(pushGitChanges:) keyEquivalent:@""]; - NSMenuItem *gitPull = [fileMenu addItemWithTitle:@"Git 풀" - action:@selector(pullGitChanges:) keyEquivalent:@""]; - NSMenuItem *gitLog = [fileMenu addItemWithTitle:@"Git 기록 보기" - action:@selector(showGitLog:) keyEquivalent:@""]; - [gitCommit setTarget:workspace]; [gitPush setTarget:workspace]; - [gitPull setTarget:workspace]; [gitLog setTarget:workspace]; - [fileMenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *recentItem = [[NSMenuItem alloc] initWithTitle:@"최근 항목" - action:nil keyEquivalent:@""]; - NSMenu *recentMenu = [[NSMenu alloc] initWithTitle:@"최근 항목"]; - [recentItem setSubmenu:recentMenu]; - [fileMenu addItem:recentItem]; - [workspace setRecentMenu:recentMenu]; - [fileItem setSubmenu:fileMenu]; - [fileItem setTag:AXYNE_MACOS_MENU_TAG_BASE]; - [mainMenu addItem:fileItem]; - [fileItem release]; [recentItem release]; - [recentMenu release]; [fileMenu release]; - /* Bar order after File: 편집, 보기, 빌드, 디버그, 도구, 도움말. */ - for (NSUInteger menuIndex = 1; menuIndex < AXYNE_UI_MENU_COUNT; ++menuIndex) { - NSString *title = axyne_macos_menu_label(menuIndex); + /* Bar order: 파일, 편집, 보기, 빌드, 디버그, 도구, 도움말. Every entry is a + * registry command (ui_commands_macos.m); its key equivalent comes from + * the effective key map and is refreshed when the bindings change. */ + for (NSUInteger menuIndex = 0; menuIndex < AXYNE_UI_MENU_COUNT; ++menuIndex) { + NSString *title = axyne_macos_menu_title(menuIndex); NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:nil keyEquivalent:@""]; - NSMenu *submenu = [[NSMenu alloc] initWithTitle:title]; - if (menuIndex == 1) { - NSMenuItem *undo = [submenu addItemWithTitle:@"실행 취소" - action:@selector(undo:) keyEquivalent:@"z"]; - NSMenuItem *redo = [submenu addItemWithTitle:@"다시 실행" - action:@selector(redo:) keyEquivalent:@"Z"]; - [redo setKeyEquivalentModifierMask:NSEventModifierFlagCommand | - NSEventModifierFlagShift]; - [submenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *cut = [submenu addItemWithTitle:@"잘라내기" - action:@selector(editCut:) keyEquivalent:@"x"]; - NSMenuItem *copy = [submenu addItemWithTitle:@"복사" - action:@selector(editCopy:) keyEquivalent:@"c"]; - NSMenuItem *paste = [submenu addItemWithTitle:@"붙여넣기" - action:@selector(editPaste:) keyEquivalent:@"v"]; - NSMenuItem *selectAll = [submenu addItemWithTitle:@"모두 선택" - action:@selector(editSelectAll:) keyEquivalent:@"a"]; - [submenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *find = [submenu addItemWithTitle:@"찾기…" - action:@selector(findInDocument:) keyEquivalent:@"f"]; - NSMenuItem *replace = [submenu addItemWithTitle:@"바꾸기…" - action:@selector(replaceInDocument:) keyEquivalent:@"h"]; - NSMenuItem *findInWorkspace = [submenu addItemWithTitle:@"파일에서 찾기…" - action:@selector(searchWorkspace:) keyEquivalent:@""]; - NSMenuItem *quickOpen = [submenu addItemWithTitle:@"파일 이동…" - action:@selector(quickFile:) keyEquivalent:@""]; - NSArray *editItems = @[undo, redo, cut, copy, paste, selectAll, - find, replace, findInWorkspace, quickOpen]; - for (NSMenuItem *editItem in editItems) [editItem setTarget:workspace]; - axyne_macos_add_item(submenu, @"줄로 이동…", @selector(goToLine:), - workspace, @"l", NSEventModifierFlagCommand); - axyne_macos_add_item(submenu, @"줄 선택", @selector(selectLine:), - workspace, @"", 0); - [submenu addItem:[NSMenuItem separatorItem]]; - axyne_macos_add_item(submenu, @"줄 주석 토글", - @selector(toggleLineComment:), workspace, @"/", - NSEventModifierFlagCommand); - axyne_macos_add_item(submenu, @"줄 복제", @selector(duplicateLine:), - workspace, @"d", NSEventModifierFlagCommand | NSEventModifierFlagShift); - axyne_macos_add_item(submenu, @"줄 위로 이동", @selector(moveLineUp:), - workspace, axyne_macos_special_key(NSUpArrowFunctionKey), - NSEventModifierFlagOption); - axyne_macos_add_item(submenu, @"줄 아래로 이동", @selector(moveLineDown:), - workspace, axyne_macos_special_key(NSDownArrowFunctionKey), - NSEventModifierFlagOption); - [submenu addItem:[NSMenuItem separatorItem]]; - axyne_macos_add_item(submenu, @"들여쓰기", @selector(indentSelection:), - workspace, @"]", NSEventModifierFlagCommand); - axyne_macos_add_item(submenu, @"내어쓰기", @selector(outdentSelection:), - workspace, @"[", NSEventModifierFlagCommand); - } else if (menuIndex == 3) { - NSMenuItem *build = [submenu addItemWithTitle:@"빌드" - action:@selector(buildDocument:) keyEquivalent:@"b"]; - NSMenuItem *run = [submenu addItemWithTitle:@"실행" - action:@selector(runDocument:) keyEquivalent:@"r"]; - NSMenuItem *configure = [submenu addItemWithTitle:@"Runner 설정…" - action:@selector(configureRunnerAction:) keyEquivalent:@""]; - [build setTarget:workspace]; [run setTarget:workspace]; - [configure setTarget:workspace]; - [submenu addItem:[NSMenuItem separatorItem]]; - axyne_macos_add_item(submenu, @"빌드 취소", @selector(cancelBuild:), - workspace, @".", NSEventModifierFlagCommand); - } else if (menuIndex == 4) { - NSMenuItem *start = [submenu addItemWithTitle:@"디버깅 시작" - action:@selector(startDebugger:) keyEquivalent:@""]; - /* F5 is the default Run binding in preferences, so Start Debugger - * takes Shift+F5 to keep both shortcuts reachable. */ - axyne_set_function_key(start, NSF5FunctionKey, NSEventModifierFlagShift); - [start setTarget:workspace]; - axyne_macos_add_item(submenu, @"중지", @selector(stopDebugger:), - workspace, @".", NSEventModifierFlagCommand | NSEventModifierFlagShift); - [submenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *pause = [submenu addItemWithTitle:@"일시 중지" - action:@selector(debugCommand:) keyEquivalent:@""]; - axyne_set_function_key(pause, NSF6FunctionKey, 0); - NSMenuItem *resume = [submenu addItemWithTitle:@"계속" - action:@selector(debugCommand:) keyEquivalent:@""]; - [resume setTag:AXYNE_DEBUGGER_CONTINUE]; [resume setTarget:workspace]; - [pause setTarget:workspace]; [pause setTag:AXYNE_DEBUGGER_PAUSE]; - [submenu addItem:[NSMenuItem separatorItem]]; - /* debugCommand: carries AxyneDebuggerCommand in the item tag. */ - NSMenuItem *next = [submenu addItemWithTitle:@"프로시저 단위 실행" - action:@selector(debugCommand:) keyEquivalent:@""]; - axyne_set_function_key(next, NSF10FunctionKey, 0); - [next setTarget:workspace]; [next setTag:AXYNE_DEBUGGER_STEP_OVER]; - NSMenuItem *into = [submenu addItemWithTitle:@"한 단계씩 코드 실행" - action:@selector(debugCommand:) keyEquivalent:@""]; - axyne_set_function_key(into, NSF11FunctionKey, 0); - [into setTarget:workspace]; [into setTag:AXYNE_DEBUGGER_STEP_INTO]; - NSMenuItem *out = [submenu addItemWithTitle:@"프로시저 나가기" - action:@selector(debugCommand:) keyEquivalent:@""]; - axyne_set_function_key(out, NSF11FunctionKey, NSEventModifierFlagShift); - [out setTarget:workspace]; [out setTag:AXYNE_DEBUGGER_STEP_OUT]; - [submenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *toggle = [submenu addItemWithTitle:@"중단점 토글" - action:@selector(toggleBreakpoint:) keyEquivalent:@""]; - axyne_set_function_key(toggle, NSF9FunctionKey, 0); - [toggle setTarget:workspace]; - NSMenuItem *clearBreakpoints = [submenu addItemWithTitle:@"모든 중단점 삭제" - action:@selector(clearBreakpoints:) keyEquivalent:@""]; - axyne_set_function_key(clearBreakpoints, NSF9FunctionKey, - NSEventModifierFlagCommand | NSEventModifierFlagShift); - [clearBreakpoints setTarget:workspace]; - } else if (menuIndex == 2) { - axyne_macos_add_item(submenu, @"탐색기", @selector(toggleExplorer:), - workspace, @"e", NSEventModifierFlagCommand | NSEventModifierFlagShift); - axyne_macos_add_item(submenu, @"Git 패널", @selector(showGitPanel:), - workspace, @"", 0); - axyne_macos_add_item(submenu, @"하단 패널", @selector(togglePanel:), - workspace, @"j", NSEventModifierFlagCommand); - [submenu addItem:[NSMenuItem separatorItem]]; - NSArray *panels = @[@"출력", @"문제", @"터미널"]; - NSArray *panelKeys = @[@"u", @"m", @"`"]; - NSEventModifierFlags panelMasks[3] = { - NSEventModifierFlagCommand | NSEventModifierFlagShift, - NSEventModifierFlagCommand | NSEventModifierFlagShift, - NSEventModifierFlagControl }; - for (NSInteger i = 0; i < (NSInteger)[panels count]; ++i) { - NSMenuItem *panelItem = axyne_macos_add_item(submenu, - panels[(NSUInteger)i], @selector(selectPanel:), workspace, - panelKeys[(NSUInteger)i], panelMasks[i]); - [panelItem setTag:i]; - } - [submenu addItem:[NSMenuItem separatorItem]]; - axyne_macos_add_item(submenu, @"확대", @selector(zoomInEditor:), - workspace, @"=", NSEventModifierFlagCommand); - axyne_macos_add_item(submenu, @"축소", @selector(zoomOutEditor:), - workspace, @"-", NSEventModifierFlagCommand); - axyne_macos_add_item(submenu, @"기본 크기", @selector(zoomResetEditor:), - workspace, @"0", NSEventModifierFlagCommand); - [submenu addItem:[NSMenuItem separatorItem]]; - /* Option+Z types a character, so Word Wrap takes Command+Option. */ - axyne_macos_add_item(submenu, @"자동 줄 바꿈", @selector(toggleWordWrap:), - workspace, @"z", NSEventModifierFlagCommand | NSEventModifierFlagOption); - } else if (menuIndex == 5) { - /* Figma Tools lists the terminal and settings; the application - * menu's Preferences is not reachable from the in-window bar. */ - axyne_macos_add_item(submenu, @"새 터미널", @selector(startTerminal:), - workspace, @"", 0); - [submenu addItem:[NSMenuItem separatorItem]]; - axyne_macos_add_item(submenu, @"설정…", @selector(showGlobalPreferences:), - workspace, @"", 0); - axyne_macos_add_item(submenu, @"작업 영역 설정…", - @selector(showWorkspacePreferences:), workspace, @"", 0); - axyne_macos_add_item(submenu, @"preferences.json 열기", - @selector(openPreferencesFile:), workspace, @"", 0); - [submenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *definition = [submenu addItemWithTitle:@"정의로 이동" - action:@selector(navigateLspReferences:) keyEquivalent:@"d"]; - NSMenuItem *references = [submenu addItemWithTitle:@"참조 찾기" - action:@selector(navigateLspReferences:) keyEquivalent:@"r"]; - [definition setTarget:workspace]; - /* Cmd+Option+D is the system Dock hide/show shortcut, so Go to Definition adds Shift. */ - [definition setKeyEquivalentModifierMask:NSEventModifierFlagCommand | NSEventModifierFlagOption | NSEventModifierFlagShift]; - [references setTarget:workspace]; - /* Cmd+R belongs to Build > Run, so References adds Shift. */ - [references setKeyEquivalentModifierMask:NSEventModifierFlagCommand | NSEventModifierFlagOption | NSEventModifierFlagShift]; - [references setTag:1]; - } else if (menuIndex == 6) { - NSMenuItem *help = [submenu addItemWithTitle:@"Axyne 도움말" - action:@selector(openHelp:) keyEquivalent:@"?"]; - NSMenuItem *folder = [submenu addItemWithTitle:@"설정 폴더 표시" - action:@selector(showSettingsFolder:) keyEquivalent:@""]; - [help setTarget:workspace]; [folder setTarget:workspace]; - axyne_macos_add_item(submenu, @"키보드 단축키 참조", - @selector(showKeyboardShortcuts:), workspace, @"", 0); - axyne_macos_add_item(submenu, @"문제 보고…", @selector(reportIssue:), - workspace, @"", 0); - [submenu addItem:[NSMenuItem separatorItem]]; - /* Figma Help ends with "Axyne 정보"; the application menu's copy - * is not reachable from the in-window bar. */ - NSMenuItem *about = [submenu addItemWithTitle:@"Axyne 정보" - action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""]; - [about setTarget:application]; - [application setHelpMenu:submenu]; - } + NSMenu *submenu = [workspace newMenuBarSubmenu:menuIndex]; [item setSubmenu:submenu]; [item setTag:(NSInteger)(AXYNE_MACOS_MENU_TAG_BASE + menuIndex)]; + if (menuIndex == AXYNE_UI_MENU_COUNT - 1) [application setHelpMenu:submenu]; [submenu release]; [mainMenu addItem:item]; [item release]; @@ -7999,6 +7388,7 @@ static void axyne_install_menu(NSApplication *application, [mainMenu release]; [appMenu release]; [appItem release]; + [workspace refreshMenuKeyEquivalents]; } int axyne_ui_run(const char *app_name) diff --git a/src/features/ui/platform/macos/ui_macos_internal.h b/src/features/ui/platform/macos/ui_macos_internal.h new file mode 100644 index 0000000..01f5911 --- /dev/null +++ b/src/features/ui/platform/macos/ui_macos_internal.h @@ -0,0 +1,884 @@ +#ifndef AXYNE_UI_MACOS_INTERNAL_H +#define AXYNE_UI_MACOS_INTERNAL_H + +/* Internal interface of the macOS workspace window, shared by ui_macos.m and + * the per-feature category files (ui__macos.m). Objective-C with + * MANUAL retain/release. Everything here is private to the macOS adapter. */ + +#import +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "axyne/commands.h" +#include "axyne/keymap.h" +#include "axyne/document.h" +#include "axyne/empty_state.h" +#include "axyne/search.h" +#include "axyne/explorer.h" +#include "axyne/outline.h" +#include "axyne/watcher.h" +#include "axyne/process.h" +#include "axyne/runner.h" +#include "axyne/debugger.h" +#include "axyne/preferences.h" +#include "axyne/git.h" +#include "axyne/lsp.h" +#include "axyne/problems.h" +#include "axyne/problems_feed.h" +#include "axyne/palette_controller.h" +#include "axyne/language.h" +#include "axyne/build_selector.h" +#include "axyne/ui_design.h" +#include "axyne/popup_menu_layout.h" +#include "popup_menu_macos.h" +#include "git_panel_macos.h" +#import "terminal_view_macos.h" +#include "axyne/layout_metrics.h" +#include "axyne/syntax.h" +#include "axyne/completion.h" +#include "Scintilla.h" +#include "../../editor_document.h" +#include "../../editor_actions.h" +#include "../../debugger_actions.h" +#include "../../preferences_window.h" +#include "../../app_dialogs.h" +#include + +@interface NSObject (AxyneScintillaMessages) +- (NSView *)content; +- (NSInteger)message:(unsigned int)message wParam:(uintptr_t)wParam + lParam:(intptr_t)lParam; +@end + +/* These bands follow the 1440x900 Figma work area: menu bar 26px, toolbar + * 38px, tabs 34px, + * output panel 230px, and status bar 24px. The macOS titlebar remains owned + * by AppKit so its traffic-light controls stay native and accessible. */ +static const CGFloat AXYNE_MENU = AXYNE_UI_MENU; +static const CGFloat AXYNE_TOOLBAR = AXYNE_UI_TOOLBAR; +/* Every band below the in-window menu bar and toolbar starts here. */ +static const CGFloat AXYNE_CONTENT_TOP = AXYNE_UI_MENU + AXYNE_UI_TOOLBAR; +static const CGFloat AXYNE_TABS = AXYNE_UI_TABS; +static const CGFloat AXYNE_STATUS = AXYNE_UI_STATUS; + +/* Top-level main-menu entries that the in-window bar mirrors carry + * AXYNE_MACOS_MENU_TAG_BASE + their bar position as the NSMenuItem tag + * (the application menu keeps tag 0 and has no bar item). */ +enum { AXYNE_MACOS_MENU_TAG_BASE = 100 }; + +/* Title of the top-level menu at bar position index, from the table shared + * with the Windows adapter. */ +static inline NSString *axyne_macos_menu_title(NSUInteger index) +{ + const AxyneMenuTitle *title = axyne_ui_menu_title(index); + NSString *label = title != NULL ? [NSString stringWithUTF8String:title->label] : nil; + return label != nil ? label : @""; +} + +@class AxyneWorkspaceView; +@class AxyneEmptyEditorView; +@class AxynePaletteOverlay; +typedef struct AxyneMacGitRun AxyneMacGitRun; +typedef struct AxyneMacGitCompletion AxyneMacGitCompletion; +typedef struct AxyneMacGitBatch AxyneMacGitBatch; + +/* Native buttons keep AppKit's actions and accessibility while drawing the + * flat, precisely centered Figma toolbar rather than an OS bezel. */ +@interface AxyneChromeButton : NSButton +@property(nonatomic, retain) NSColor *fillColor; +@property(nonatomic, retain) NSColor *labelColor; +@property(nonatomic, retain) NSColor *strokeColor; +@property(nonatomic, retain) NSAttributedString *richTitle; +@property(nonatomic, retain) NSAttributedString *trailingTitle; +@property(nonatomic) CGFloat cornerRadius; +@property(nonatomic) NSInteger chevronState; /* 0 none, 1 down, 2 up */ +@property(nonatomic) CGFloat contentInset; +@property(nonatomic) BOOL leadingAligned; +@end + +static inline NSColor *axyne_color(CGFloat red, CGFloat green, CGFloat blue) +{ + return [NSColor colorWithCalibratedRed:red / 255.0 + green:green / 255.0 + blue:blue / 255.0 + alpha:1.0]; +} + +@protocol AxynePaletteOwner +- (void)paletteActivateRow:(size_t)row; +- (void)paletteChipClicked:(NSInteger)mode; +- (void)paletteDismiss; +/* YES when the point belongs to the toolbar search field (not an outside click). */ +- (BOOL)paletteClaimsPoint:(NSPoint)point; +@end + +@class AxyneProblemsView; + +@protocol AxyneProblemsViewDelegate +- (void)problemsView:(AxyneProblemsView *)view activateRowAtIndex:(size_t)index; +- (void)problemsViewFilterChanged:(AxyneProblemsView *)view; +- (void)problemsViewWillHitTest:(AxyneProblemsView *)view; +@end + +/* ---- shared chrome model -------------------------------------------------- */ + +/* Bottom panel tabs in strip order. The strip shows every visible tab (see + * -setBottomPanelTab:visible:); DEBUG_CONSOLE stays hidden until the debug + * feature enables it. _panelMode holds the selected tab. */ +typedef enum AxyneBottomPanelTab { + AXYNE_BOTTOM_PANEL_OUTPUT = 0, + AXYNE_BOTTOM_PANEL_PROBLEMS = 1, + AXYNE_BOTTOM_PANEL_TERMINAL = 2, + AXYNE_BOTTOM_PANEL_DEBUG_CONSOLE = 3, + AXYNE_BOTTOM_PANEL_TAB_COUNT +} AxyneBottomPanelTab; + +/* Command palette rows built from the registry carry this base plus the + * AxyneCommandId as payload when the command has no palette id. */ +enum { AXYNE_MAC_PALETTE_REGISTRY_BASE = 1000 }; + +/* Result of a feature category's validate hook. */ +typedef enum AxyneMacCommandState { + AXYNE_MAC_COMMAND_UNHANDLED = -1, /* not implemented by this category */ + AXYNE_MAC_COMMAND_DISABLED = 0, /* implemented, currently disabled */ + AXYNE_MAC_COMMAND_ENABLED = 1 +} AxyneMacCommandState; + +/* Resolved chrome colours of the active theme (-chromeColors). The colours + * are autoreleased: use them inside the current call (drawing hooks). */ +typedef struct AxyneMacChromeColors { + NSColor *background, *panel, *toolbar, *border, *text, *muted, *accent; + NSColor *tabBackground; /* editor tab row and bottom panel tab strip */ + NSColor *output; /* bottom panel content surface */ + NSColor *statusBar; /* status bar band */ + BOOL light; /* light preset, or System in a light appearance */ + BOOL reference; /* the default dark (Figma reference) surfaces */ +} AxyneMacChromeColors; + +/* The deferred discovery block must not retain the workspace: a retained view + * would never deallocate when the main queue is not drained (the editor runtime + * test), leaking its Scintilla documents. The block holds only this box. */ +typedef struct AxyneDiscoveryBox { id target; } AxyneDiscoveryBox; + +@interface AxyneWorkspaceView : NSView { + NSView *_editorView; + AxyneEmptyEditorView *_emptyView; + NSImageView *_imagePreview; + BOOL _emptyShown; + BOOL _emptyGuideDirty; + NSBundle *_scintillaBundle; + NSString *_editorLoadError; + NSButton *_newButton; + NSButton *_openButton; + NSButton *_saveButton; + NSButton *_undoButton; + NSButton *_redoButton; + NSButton *_buildButton; + NSButton *_runButton; + NSButton *_targetButton; + NSButton *_searchButton; + NSButton *_outputTab; + NSButton *_problemsTab; + NSButton *_terminalTab; + NSButton *_debugConsoleTab; + /* Bottom panel tab model: hidden tabs (zero = shown) indexed by + * AxyneBottomPanelTab; DEBUG_CONSOLE starts hidden. */ + BOOL _panelTabHidden[AXYNE_BOTTOM_PANEL_TAB_COUNT]; + /* Effective key map (registry defaults, base-step key overrides, user + * bindings); rebuilt whenever the bindings change. */ + AxyneKeymap _keymap; + BOOL _keymapReady; + /* Two-stroke chords (Cmd+K ...): status-bar hint while the second + * stroke is awaited, a local key monitor that catches that stroke in + * any control of the window, and the expiry check (assign-only boxes). */ + NSString *_chordHint; + id _chordMonitor; + AxyneDiscoveryBox *_chordMonitorBox; + AxyneDiscoveryBox *_chordTimerBox; + NSButton *_clearOutput; + NSScrollView *_terminalScroll; + /* Problems panel: the model (LSP diagnostics + build output) and the + * list view that paints the rows built from it. */ + AxyneProblemsView *_problemsView; + AxyneProblemList _problems; + AxyneProblemCollapsed _problemsCollapsed; + AxyneProblemCounts _problemCounts; + AxyneBuildFeed _buildFeed; + BOOL _buildFeedActive; + NSInteger _panelMode; + NSInteger _activeMenuIndex; /* bar item whose popup is open, or -1 */ + NSInteger _hoverMenuIndex; /* bar item under the pointer, or -1 */ + NSTrackingArea *_menuTracking; + AxynePopupMenu *_popup; /* open Axyne popup (menu bar, build target, context), or nil */ + NSInteger _explorerFirstRow; + /* Outline (개요) section at the bottom of the explorer tab. Scanned only + * while that tab is shown; otherwise marked stale and rescanned when the + * tab appears. Edits rescan after a debounce (assign-only box). */ + AxyneOutline _outline; + AxyneDiscoveryBox *_outlineBox; + NSTimeInterval _outlineDue; /* reference-date seconds of the next rescan */ + BOOL _outlineStale; + CGFloat _tabScroll; + void *_lexillaModule; + void *(*_createLexer)(const char *name); + AxyneDocumentSet _documents; + AxyneExplorer _explorer; + AxyneWatcher *_watcher; + NSInteger _explorerSelection; + BOOL _hasExplorerSelection; + NSMenu *_recentMenu; + BOOL _loadingEditor; + AxyneRunnerConfig _terminalRunner; + AxyneRunnerConfig _actionRunner; + /* Build target selector. Lives in memory for the session; runtimes are + * discovered lazily (first editor file, click or build), never at startup. */ + AxyneBuildTarget _buildTarget; + AxyneRuntimeList _runtimes; + BOOL _runtimesDiscovered; + BOOL _runtimeDiscoveryScheduled; + BOOL _workspaceRefreshPending; /* one coalesced explorer reload is queued */ + BOOL _buildMenuOpen; /* chevron points up while the popup is open */ + AxyneDiscoveryBox *_discoveryBox; + AxyneDiscoveryBox *_refreshBox; /* assign-only target of the queued reload */ + AxyneDiscoveryBox *_problemsBox; /* assign-only target of the queued problems rebuild */ + /* Plan whose run step starts when its build step exits with 0. */ + AxyneLanguagePlan _pendingPlan; + BOOL _pendingRun; + AxyneDebugger _debugger; + /* Build/run process (Output tab); the interactive shell is _shellView. */ + AxyneProcess *_actionProcess; + AxyneProcess *_gitProcess; + AxyneMacGitRun *_gitRun; + /* A commit, push or pull is running on a worker thread. */ + BOOL _gitBatchBusy; + /* The running batch is a commit started from the Git panel. */ + BOOL _gitBatchFromPanel; + /* Left sidebar tab: 0 explorer, 1 Git. The Git panel is created the first + * time its tab is shown and freed with the view. */ + NSInteger _sidebarTab; + AxyneGitPanelView *_gitPanel; + NSTextView *_terminalOutput; + AxyneTerminalView *_shellView; /* native terminal grid (Terminal tab) */ + NSButton *_terminalStart; + NSButton *_terminalStop; + NSButton *_debugStart; + NSButton *_debugPause; + NSButton *_debugContinue; + NSButton *_debugNext; + NSButton *_debugBreakpoint; + int _activeAction; + int _lastExitCode; + BOOL _lastExitFailed; + BOOL _hasExitStatus; + AxynePreferences _globalPreferences; + AxynePreferences _preferences; + char *_globalPreferencesPath; + char *_workspacePreferencesPath; + unsigned char _workspaceBindingPresent[AXYNE_ACTION_COUNT]; + AxyneLspClient *_lsp; + NSString *_lspStatus; + BOOL _explorerHidden; /* View > Explorer; zero-initialised means visible */ + BOOL _panelHidden; /* View > Bottom Panel */ + CGFloat _sidebarSize; /* dragged explorer width; 0 = default, session only */ + CGFloat _panelSize; /* dragged bottom panel height; 0 = default */ + NSInteger _splitterDrag; /* 0 none, 1 explorer border, 2 panel border */ + CGFloat _splitterStart; /* pointer coordinate when the drag began */ + CGFloat _splitterStartSize; /* size when the drag began */ + NSTrackingArea *_sidebarSplitterTracking; + NSTrackingArea *_panelSplitterTracking; + AxynePaletteController _palette; + AxynePaletteOverlay *_paletteOverlay; + NSTextField *_paletteField; + NSTimer *_paletteTimer; + /* Feature category objects (ui__macos.m). Each starts nil, is + * created lazily by its category and released by its Teardown hook; the + * object's class is private to the category file. */ + id _fileUi; + id _editUi; + id _viewUi; + id _buildUi; + id _debugUi; + id _terminalUi; + id _statusUi; + id _toolsUi; + id _helpUi; + id _lspNavUi; + id _diagUi; + id _releaseUi; +} +@end + +static inline NSColor *axyne_preference_color(uint32_t value) +{ + return axyne_color((CGFloat)((value >> 16) & 0xff), + (CGFloat)((value >> 8) & 0xff), + (CGFloat)(value & 0xff)); +} + +static inline intptr_t axyne_editor_color(uint32_t rgb) +{ + return (intptr_t)(((rgb & 0xff) << 16) | (rgb & 0xff00) | ((rgb >> 16) & 0xff)); +} + +static inline BOOL axyne_macos_prefers_dark(NSView *view) +{ + NSAppearance *appearance = [view effectiveAppearance]; + NSAppearanceName match = [appearance bestMatchFromAppearancesWithNames: + @[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; + return [match isEqualToString:NSAppearanceNameDarkAqua]; +} + +/* Perceived luminance below mid-grey: scrollers use the dark appearance. */ +static inline BOOL axyne_macos_color_is_dark(uint32_t rgb) +{ + uint32_t red = (rgb >> 16) & 0xffu, green = (rgb >> 8) & 0xffu, blue = rgb & 0xffu; + return (red * 299u + green * 587u + blue * 114u) / 1000u < 128u; +} + +static inline BOOL axyne_macos_reference_surfaces(const AxyneThemePreferences *theme) +{ + return theme->background == 0x16171a && theme->panel == 0x1f2126 && + theme->toolbar == 0x1c1e22; +} + +/* Popup menu colours: the Figma menu frames on the reference theme (the same + * values as the Windows popup), the theme's own colours otherwise. */ +static inline AxynePopupMenuColors axyne_macos_popup_colors(const AxyneThemePreferences *theme) +{ + AxynePopupMenuColors colors; + if (axyne_macos_reference_surfaces(theme)) { + colors.background = 0x202329; colors.border = 0x2b2e35; + colors.hover = 0x402d5c; colors.hoverText = 0xf4edf9; + colors.text = 0xd2d5db; colors.muted = 0x969ba5; + colors.disabled = 0x666c76; colors.separator = 0x2b2e35; + colors.check = 0xa667e8; + } else { + colors.background = theme->panel; colors.border = theme->border; + colors.hover = theme->border; colors.hoverText = theme->text; + colors.text = theme->text; colors.muted = theme->muted; + colors.disabled = theme->muted; colors.separator = theme->border; + colors.check = theme->accent; + } + return colors; +} + +static inline uint32_t axyne_macos_output_background(const AxyneThemePreferences *theme) +{ + return axyne_macos_reference_surfaces(theme) ? 0x1d1f23 : theme->background; +} + +/* Methods implemented in ui_macos.m (@implementation AxyneWorkspaceView). */ +@interface AxyneWorkspaceView (AxyneActions) +- (AxyneDocument *)activeDocument; +- (BOOL)imagePreviewShown; +- (AxyneLanguageId)activeLanguage; +- (NSResponder *)externalTextResponder; +- (BOOL)captureEditorSnapshot; +- (void)updateWindowTitle; +- (BOOL)saveActive; +- (BOOL)saveActiveToPath:(NSString *)path; +- (void)openPath:(NSString *)path; +- (NSString *)label:(NSString *)label fittingWidth:(CGFloat)width + size:(CGFloat)size family:(NSString *)family; +- (void)drawLabel:(NSString *)label at:(NSPoint)point + size:(CGFloat)size color:(NSColor *)color family:(NSString *)family; +- (void)drawFileBadge:(const char *)name inRect:(NSRect)rect tab:(BOOL)tab; +- (void)startGitBatch:(int)kind message:(char *)message stageAll:(int)stageAll; +- (void)drawDefaultStatusAtTop:(CGFloat)statusTop width:(CGFloat)width + text:(NSColor *)text muted:(NSColor *)muted; +- (void)notifyDocumentSaved; +- (void)notifyDocumentWillClose:(AxyneDocument *)document; +- (void)newDocument:(id)sender; +- (void)openDocument:(id)sender; +- (void)saveDocument:(id)sender; +- (void)saveDocumentAs:(id)sender; +- (void)undo:(id)sender; +- (void)redo:(id)sender; +- (void)closeDocument:(id)sender; +- (void)closeDocumentAtIndex:(size_t)index; +- (void)openRecent:(id)sender; +- (void)openPath:(NSString *)path asPreview:(BOOL)preview; +- (BOOL)confirmCloseAll; +- (void)notification:(SCNotification *)notification; +- (void)setRecentMenu:(NSMenu *)menu; +- (void)refreshRecentMenu; +- (BOOL)captureEditor; +- (void)refreshActionControls; +- (BOOL)validateMenuItem:(NSMenuItem *)menuItem; +- (BOOL)loadActiveDocument; +- (BOOL)selectDocumentAtIndex:(size_t)index; +- (NSInteger)sendEditorMessage:(unsigned int)message wParam:(uintptr_t)wParam + lParam:(intptr_t)lParam; +- (void)loadScintillaView; +- (BOOL)requireEditorFor:(NSString *)action; +- (BOOL)isEmptyState; +- (void)updateEmptyState; +- (void)applyEmptyGuideTheme; +- (BOOL)editorReadyForSave; +- (AxyneExplorerNode *)selectedExplorerNode; +- (void)editCut:(id)sender; +- (void)editCopy:(id)sender; +- (void)editPaste:(id)sender; +- (void)editSelectAll:(id)sender; +- (void)findInDocument:(id)sender; +- (void)replaceInDocument:(id)sender; +- (void)searchWorkspace:(id)sender; +- (void)openHelp:(id)sender; +- (CGFloat)sidebarWidth; +- (CGFloat)panelHeight; +- (NSInteger)splitterAtPoint:(NSPoint)point; +- (NSRect)splitterRect:(NSInteger)which; +- (void)refreshSplitterTracking; +- (NSRect)menuBarItemRect:(NSUInteger)index; +- (NSInteger)menuBarIndexAtPoint:(NSPoint)point; +- (void)openMenuBarMenu:(NSUInteger)index; +- (void)openMenuBarMenu:(NSUInteger)index selectFirst:(BOOL)selectFirst; +- (BOOL)showPopupMenu:(NSMenu *)menu belowScreenRect:(NSRect)anchor gap:(CGFloat)gap + selectFirst:(BOOL)selectFirst; +- (void)closePopupMenu; +- (void)setMenuHover:(NSInteger)index; +- (BOOL)editorActionable; +- (BOOL)activeIsImage; +- (void)goToLine:(id)sender; +- (void)selectLine:(id)sender; +- (void)toggleLineComment:(id)sender; +- (void)duplicateLine:(id)sender; +- (void)moveLineUp:(id)sender; +- (void)moveLineDown:(id)sender; +- (void)indentSelection:(id)sender; +- (void)outdentSelection:(id)sender; +- (void)zoomInEditor:(id)sender; +- (void)zoomOutEditor:(id)sender; +- (void)zoomResetEditor:(id)sender; +- (void)toggleWordWrap:(id)sender; +- (void)toggleExplorer:(id)sender; +- (void)showGitPanel:(id)sender; +- (void)selectSidebarTab:(NSInteger)tab; +- (NSRect)sidebarTabRect:(NSInteger)tab; +- (NSInteger)sidebarTabAtPoint:(NSPoint)point; +- (AxyneGitPanelTheme)gitPanelTheme; +- (void)togglePanel:(id)sender; +- (void)cancelBuild:(id)sender; +- (void)stopDebugger:(id)sender; +- (void)clearBreakpoints:(id)sender; +- (BOOL)preferencesFileExists; +- (NSString *)existingGlobalPreferencesPath; +- (BOOL)ensureSettingsFile:(BOOL)workspace; +- (void)openPreferencesFile:(id)sender; +- (void)showKeyboardShortcuts:(id)sender; +- (void)reportIssue:(id)sender; +- (void)showSettingsFolder:(id)sender; +- (BOOL)confirmCloseDocumentAtIndex:(size_t)index; +- (void)findOrReplace:(BOOL)replace; +- (void)searchFolder:(BOOL)quickFile; +- (void)openWorkspace:(id)sender; +- (void)newExplorerFile:(id)sender; +- (void)newExplorerFolder:(id)sender; +- (void)renameExplorerItem:(id)sender; +- (void)removeExplorerItem:(id)sender; +- (void)workspaceEvent; +- (BOOL)refreshExplorer; +- (void)scrollTabsBy:(CGFloat)delta; +- (void)showWorkspaceError:(NSString *)prefix error:(AxyneError *)error; +- (void)showWorkspaceMessage:(NSString *)message; +- (BOOL)selectWorkspaceURL:(NSURL *)url; +- (NSInteger)explorerNodeAtPoint:(NSPoint)point; +- (BOOL)explorerPinnedAtPoint:(NSPoint)point; +- (NSUInteger)explorerPinnedRows:(size_t *)out; +- (NSInteger)explorerVisibleRows; +- (NSRect)tabFrameAtIndex:(size_t)index; +- (void)selectPanel:(id)sender; +- (void)clearOutput:(id)sender; +- (void)quickFile:(id)sender; +- (void)showCommandPalette:(id)sender; +- (void)goToSymbol:(id)sender; +- (void)configureRunnerAction:(id)sender; +- (void)showBuildTargetMenu:(id)sender; +- (void)pickBuildTarget:(id)sender; +- (void)discoverRuntimes; +- (void)scheduleRuntimeDiscovery; +- (NSString *)buildTargetLabel; +- (void)clearPendingRun; +- (BOOL)startStep:(const AxyneLanguageStep *)step plan:(const AxyneLanguagePlan *)plan + action:(int)action clearOutput:(BOOL)clear; +- (void)startAction:(BOOL)run; +- (void)performExplorerOperation:(AxyneFileKind)kind; +- (NSString *)askForText:(NSString *)title label:(NSString *)label; +- (void)startTerminal:(id)sender; +- (void)stopTerminal:(id)sender; +- (void)terminalAppend:(const char *)bytes length:(size_t)length + stream:(AxyneProcessStream)stream; +- (void)terminalExited:(AxyneProcess *)process exitCode:(int)exitCode; +- (BOOL)configureRunner; +- (BOOL)applyRunnerValues:(const AxyneRunnerDialogValues *)values + error:(char *)message capacity:(size_t)capacity; +- (void)buildDocument:(id)sender; +- (void)runDocument:(id)sender; +- (void)startDebugger:(id)sender; +- (void)debugCommand:(id)sender; +- (void)toggleBreakpoint:(id)sender; +- (void)showGlobalPreferences:(id)sender; +- (void)showWorkspacePreferences:(id)sender; +- (void)applyPreferences; +- (void)applyEditorScrollers; +- (void)applySystemAppearance; +- (void)updateChromeTitles; +- (void)applyEditorLexer; +- (void)updateLineNumberMargin; +- (void)updateBraceHighlight; +- (void)autoIndentFromNotification:(SCNotification *)notification; +- (void)showCompletion; +- (BOOL)showPreferences:(BOOL)workspace; +- (BOOL)savePreferences:(AxynePreferences *)edited workspace:(BOOL)workspace; +- (void)showGitStatus:(id)sender; +- (void)showGitDiff:(id)sender; +- (void)stageAllGitChanges:(id)sender; +- (void)unstageAllGitChanges:(id)sender; +- (void)commitGitChanges:(id)sender; +- (void)pushGitChanges:(id)sender; +- (void)pullGitChanges:(id)sender; +- (void)showGitLog:(id)sender; +- (void)completeGitBatch:(AxyneMacGitBatch *)batch; +- (void)completeGitOperation:(AxyneMacGitCompletion *)completion + run:(AxyneMacGitRun *)run; +- (void)selectOutputPanel; +- (void)startGitOperationWithEmptyMessage:(const char *)emptyMessage + arguments:(const char *const *)arguments + count:(size_t)argumentCount; +- (BOOL)ensureLsp; +- (BOOL)openLspForActive; +- (void)syncLspActive; +- (void)navigateLspReferences:(id)sender; +- (void)setLspStatus:(NSString *)status; +- (void)applyProblemsTheme; +- (void)cancelProblemsRefresh; +- (void)refreshProblems; +- (void)scheduleProblemsRefresh; +- (void)flushProblemsRefresh; +- (const AxyneProblemCounts *)problemCounts; +- (void)applyLspDiagnostics:(const AxyneLspDiagnostic *)diagnostics + count:(size_t)count path:(const char *)path; +- (void)clearLspProblemsForPath:(const char *)path; +- (void)beginBuildProblemsInDirectory:(const char *)directory clear:(BOOL)clear; +- (void)beginBuildProblems; +- (void)feedBuildProblems:(const char *)bytes length:(size_t)length + stream:(AxyneProcessStream)stream; +- (void)finishBuildProblems; +- (void)showProblemAtPath:(NSString *)path line:(size_t)line column:(size_t)column + utf16:(BOOL)utf16; +- (void)moveCaretToProblemLine:(size_t)line column:(size_t)column utf16:(BOOL)utf16; +- (void)afterSaveAsFromPath:(const char *)oldPath; +- (BOOL)outlineVisible; +- (CGFloat)outlineHeight; +- (CGFloat)explorerTreeBottom; +- (BOOL)pointIsInOutline:(NSPoint)point; +- (NSInteger)outlineSymbolAtPoint:(NSPoint)point; +- (void)cancelOutlineRefresh; +- (void)queueOutlineRefreshAfter:(NSTimeInterval)delay; +- (void)scheduleOutlineRefresh; +- (void)outlineRefreshDue; +- (void)refreshOutline; +- (void)jumpToOutlineSymbol:(NSInteger)index; +- (void)drawOutlineAtTop:(CGFloat)top light:(BOOL)light reference:(BOOL)reference; +@end + +@interface AxyneWorkspaceView (AxynePalette) +- (void)openPaletteWithInput:(NSString *)input; +- (void)closePaletteRestoringFocus:(BOOL)restore; +- (void)layoutPalette; +- (void)drawPaletteFieldInRect:(NSRect)box; +- (void)paletteSyncInput; +- (void)paletteSetText:(NSString *)text; +- (void)paletteTick:(NSTimer *)timer; +- (void)paletteEnter; +- (void)paletteDismissWithoutFocus; +- (void)paletteFocusField; +- (void)paletteGotoLine:(size_t)line column:(size_t)column; +- (void)paletteRunCommand:(AxynePaletteCommandId)command; +@end + +/* Base services for the feature categories (implemented in ui_macos.m). */ +@interface AxyneWorkspaceView (AxyneFoundation) +/* Colours of the active theme for drawing hooks. */ +- (AxyneMacChromeColors)chromeColors; +/* Bottom panel tab model. The strip lists the visible tabs in enum order; + * the selected tab is _panelMode. Hiding the selected tab selects Output + * (always visible); selecting reveals a hidden panel. */ +- (NSButton *)bottomPanelTabButton:(AxyneBottomPanelTab)tab; +- (BOOL)bottomPanelTabVisible:(AxyneBottomPanelTab)tab; +- (void)setBottomPanelTab:(AxyneBottomPanelTab)tab visible:(BOOL)visible; +- (void)selectBottomPanelTab:(AxyneBottomPanelTab)tab; +- (CGFloat)bottomPanelTabWidth:(AxyneBottomPanelTab)tab; +@end + +/* Command routing (ui_commands_macos.m): registry menus, key map, + * validation and dispatch. */ +@interface AxyneWorkspaceView (AxyneCommandRouting) +/* Rebuilds _keymap from the registry and _preferences and refreshes the + * menu key equivalents. */ +- (void)rebuildKeymap; +/* Primary binding of a command as menu text ("⇧⌘S", "⌘K ⌘O"; "" unbound). */ +- (NSString *)keyTextForCommand:(AxyneCommandId)command; +- (void)applyKeyEquivalentToItem:(NSMenuItem *)item command:(AxyneCommandId)command; +- (void)refreshMenuKeyEquivalents; +/* Autoreleased menu item for a registry command (-performCommand:). */ +- (NSMenuItem *)commandMenuItem:(AxyneCommandId)command; +/* Retained submenu of top-level bar position `index`. */ +- (NSMenu *)newMenuBarSubmenu:(NSUInteger)index; +/* First feature category's validate result, or AXYNE_MAC_COMMAND_UNHANDLED. */ +- (NSInteger)featureValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +/* YES when a feature category or a base handler implements the command. */ +- (BOOL)commandImplemented:(AxyneCommandId)command; +/* Enabled state (item may be nil; its check state is updated). */ +- (BOOL)validateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +/* Runs the command without validating; NO when nobody implements it. */ +- (BOOL)dispatchCommand:(AxyneCommandId)command sender:(id)sender; +/* Validates, then dispatches (keyboard, palette). */ +- (BOOL)runCommand:(AxyneCommandId)command; +/* Action of every registry menu item; the sender's tag is the command. */ +- (void)performCommand:(id)sender; +/* Keyboard: feeds a key-down to the key map (debug context while a session + * runs) and runs the bound command. YES when the key was consumed. */ +- (BOOL)handleKeyEvent:(NSEvent *)event; +/* Pending-chord plumbing (hint, monitor, 1.5 s expiry). */ +- (NSEvent *)chordMonitorEvent:(NSEvent *)event; +- (void)syncChordHint; +- (void)teardownKeyboard; +/* Command mode of the palette from the registry (after every input change). */ +- (void)paletteRebuildCommandRows; +@end + +/* ---- feature categories ---------------------------------------------------- + * Every feature category is one file ui__macos.m implementing + * `AxyneWorkspaceView (AxyneFeature)` with the fixed hook set below. + * ui_macos.m calls the hooks of every category, in AXYNE_MAC_FEATURES order: + * ValidateCommand - menu validation, keyboard dispatch and palette listing + * of a registry command (item is nil outside menus; set + * the item's state for check/radio entries). Return + * AXYNE_MAC_COMMAND_UNHANDLED for commands the category + * does not implement. The first category that does not + * return UNHANDLED owns the command (before the legacy + * base handlers in ui_commands_macos.m). + * PerformCommand - runs an owned command; YES when handled. + * DocumentActivated- after a document was loaded into the editor (NULL in + * the empty state). + * DocumentWillClose- before a tab (or a replaced preview) is released. + * DocumentSaved - after Save / Save As wrote the active document. + * EditorNotification - every Scintilla notification of the active editor. + * DrawStatusBarInRect - inside drawRect:; YES when the category painted + * the status segments (the default text is skipped). + * DrawBottomPanelStripInRect - inside drawRect: after the tab strip. + * LayoutBottomPanelInRect - inside -layout with the content rect of the + * selected tab (NSZeroRect while the panel is hidden); + * YES when the category shows its own view for `tab` + * (the default output/problems views are hidden). + * Layout - at the end of -layout. + * ApplyPreferences - at the end of -applyPreferences (theme, settings). + * Teardown - first thing in -dealloc: release _Ui and + * anything the category registered (observers, timers, + * monitors). Never let blocks or timers retain the view: + * use an assign-only AxyneDiscoveryBox. */ +#define AXYNE_MAC_FEATURES(X) \ + X(file) X(edit) X(view) X(build) X(debug) X(terminal) X(status) \ + X(tools) X(help) X(lspNav) X(diag) X(release) + +/* ui_file_macos.m - D4-D6: new project, save all, auto save, close window, recent files and folders. */ +@interface AxyneWorkspaceView (AxyneFileFeature) +- (NSInteger)fileValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)filePerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)fileDocumentActivated:(AxyneDocument *)document; +- (void)fileDocumentWillClose:(AxyneDocument *)document; +- (void)fileDocumentSaved:(AxyneDocument *)document; +- (void)fileEditorNotification:(SCNotification *)notification; +- (BOOL)fileDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)fileDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)fileLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)fileLayout; +- (void)fileApplyPreferences; +- (void)fileTeardown; +@end + +/* ui_edit_macos.m - Edit menu additions: block comment, undo limit (D8). */ +@interface AxyneWorkspaceView (AxyneEditFeature) +- (NSInteger)editValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)editPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)editDocumentActivated:(AxyneDocument *)document; +- (void)editDocumentWillClose:(AxyneDocument *)document; +- (void)editDocumentSaved:(AxyneDocument *)document; +- (void)editEditorNotification:(SCNotification *)notification; +- (BOOL)editDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)editDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)editLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)editLayout; +- (void)editApplyPreferences; +- (void)editTeardown; +@end + +/* ui_view_macos.m - View toggles, themes and full screen (D7, D9, D18). */ +@interface AxyneWorkspaceView (AxyneViewFeature) +- (NSInteger)viewValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)viewPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)viewDocumentActivated:(AxyneDocument *)document; +- (void)viewDocumentWillClose:(AxyneDocument *)document; +- (void)viewDocumentSaved:(AxyneDocument *)document; +- (void)viewEditorNotification:(SCNotification *)notification; +- (BOOL)viewDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)viewDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)viewLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)viewLayout; +- (void)viewApplyPreferences; +- (void)viewTeardown; +@end + +/* ui_build_macos.m - CMake, build configurations, targets, tasks.json (D10, D11). */ +@interface AxyneWorkspaceView (AxyneBuildFeature) +- (NSInteger)buildValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)buildPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)buildDocumentActivated:(AxyneDocument *)document; +- (void)buildDocumentWillClose:(AxyneDocument *)document; +- (void)buildDocumentSaved:(AxyneDocument *)document; +- (void)buildEditorNotification:(SCNotification *)notification; +- (BOOL)buildDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)buildDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)buildLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)buildLayout; +- (void)buildApplyPreferences; +- (void)buildTeardown; +@end + +/* ui_debug_macos.m - debugger, run configuration, external debuggers, debug console (D1, D12, D13). */ +@interface AxyneWorkspaceView (AxyneDebugFeature) +- (NSInteger)debugValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)debugPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)debugDocumentActivated:(AxyneDocument *)document; +- (void)debugDocumentWillClose:(AxyneDocument *)document; +- (void)debugDocumentSaved:(AxyneDocument *)document; +- (void)debugEditorNotification:(SCNotification *)notification; +- (BOOL)debugDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)debugDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)debugLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)debugLayout; +- (void)debugApplyPreferences; +- (void)debugTeardown; +@end + +/* ui_terminal_macos.m - terminal profiles and sessions (D14). */ +@interface AxyneWorkspaceView (AxyneTerminalFeature) +- (NSInteger)terminalValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)terminalPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)terminalDocumentActivated:(AxyneDocument *)document; +- (void)terminalDocumentWillClose:(AxyneDocument *)document; +- (void)terminalDocumentSaved:(AxyneDocument *)document; +- (void)terminalEditorNotification:(SCNotification *)notification; +- (BOOL)terminalDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)terminalDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)terminalLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)terminalLayout; +- (void)terminalApplyPreferences; +- (void)terminalTeardown; +@end + +/* ui_status_macos.m - status bar segments and memory gauge (D15, D16). */ +@interface AxyneWorkspaceView (AxyneStatusFeature) +- (NSInteger)statusValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)statusPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)statusDocumentActivated:(AxyneDocument *)document; +- (void)statusDocumentWillClose:(AxyneDocument *)document; +- (void)statusDocumentSaved:(AxyneDocument *)document; +- (void)statusEditorNotification:(SCNotification *)notification; +- (BOOL)statusDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)statusDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)statusLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)statusLayout; +- (void)statusApplyPreferences; +- (void)statusTeardown; +@end + +/* ui_tools_macos.m - Tools menu: external tools, keybindings, file associations, memory dialog (D23, D24). */ +@interface AxyneWorkspaceView (AxyneToolsFeature) +- (NSInteger)toolsValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)toolsPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)toolsDocumentActivated:(AxyneDocument *)document; +- (void)toolsDocumentWillClose:(AxyneDocument *)document; +- (void)toolsDocumentSaved:(AxyneDocument *)document; +- (void)toolsEditorNotification:(SCNotification *)notification; +- (BOOL)toolsDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)toolsDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)toolsLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)toolsLayout; +- (void)toolsApplyPreferences; +- (void)toolsTeardown; +@end + +/* ui_help_macos.m - Help menu: getting started, log folder, updates, licenses (D22, D27, D28). */ +@interface AxyneWorkspaceView (AxyneHelpFeature) +- (NSInteger)helpValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)helpPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)helpDocumentActivated:(AxyneDocument *)document; +- (void)helpDocumentWillClose:(AxyneDocument *)document; +- (void)helpDocumentSaved:(AxyneDocument *)document; +- (void)helpEditorNotification:(SCNotification *)notification; +- (BOOL)helpDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)helpDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)helpLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)helpLayout; +- (void)helpApplyPreferences; +- (void)helpTeardown; +@end + +/* ui_lsp_nav_macos.m - go to definition, references, workspace symbols (D20). */ +@interface AxyneWorkspaceView (AxyneLspNavFeature) +- (NSInteger)lspNavValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)lspNavPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)lspNavDocumentActivated:(AxyneDocument *)document; +- (void)lspNavDocumentWillClose:(AxyneDocument *)document; +- (void)lspNavDocumentSaved:(AxyneDocument *)document; +- (void)lspNavEditorNotification:(SCNotification *)notification; +- (BOOL)lspNavDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)lspNavDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)lspNavLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)lspNavLayout; +- (void)lspNavApplyPreferences; +- (void)lspNavTeardown; +@end + +/* ui_diag_macos.m - inline diagnostics and explorer badges (D17, D19). */ +@interface AxyneWorkspaceView (AxyneDiagFeature) +- (NSInteger)diagValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)diagPerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)diagDocumentActivated:(AxyneDocument *)document; +- (void)diagDocumentWillClose:(AxyneDocument *)document; +- (void)diagDocumentSaved:(AxyneDocument *)document; +- (void)diagEditorNotification:(SCNotification *)notification; +- (BOOL)diagDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)diagDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)diagLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)diagLayout; +- (void)diagApplyPreferences; +- (void)diagTeardown; +@end + +/* ui_release_macos.m - release notes window and banner (D21). */ +@interface AxyneWorkspaceView (AxyneReleaseFeature) +- (NSInteger)releaseValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item; +- (BOOL)releasePerformCommand:(AxyneCommandId)command sender:(id)sender; +- (void)releaseDocumentActivated:(AxyneDocument *)document; +- (void)releaseDocumentWillClose:(AxyneDocument *)document; +- (void)releaseDocumentSaved:(AxyneDocument *)document; +- (void)releaseEditorNotification:(SCNotification *)notification; +- (BOOL)releaseDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors; +- (void)releaseDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors; +- (BOOL)releaseLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab; +- (void)releaseLayout; +- (void)releaseApplyPreferences; +- (void)releaseTeardown; +@end + +#endif diff --git a/src/features/ui/platform/macos/ui_release_macos.m b/src/features/ui/platform/macos/ui_release_macos.m new file mode 100644 index 0000000..e5a3a5a --- /dev/null +++ b/src/features/ui/platform/macos/ui_release_macos.m @@ -0,0 +1,55 @@ +/* Release feature category of the macOS workspace window: release notes window and banner (D21). + * Owns _releaseUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneReleaseFeature) + +- (NSInteger)releaseValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)releasePerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)releaseDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)releaseDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)releaseDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)releaseEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)releaseDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)releaseDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)releaseLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)releaseLayout {} + +- (void)releaseApplyPreferences {} + +- (void)releaseTeardown +{ + [_releaseUi release]; + _releaseUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_status_macos.m b/src/features/ui/platform/macos/ui_status_macos.m new file mode 100644 index 0000000..7c44392 --- /dev/null +++ b/src/features/ui/platform/macos/ui_status_macos.m @@ -0,0 +1,55 @@ +/* Status feature category of the macOS workspace window: status bar segments and memory gauge (D15, D16). + * Owns _statusUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneStatusFeature) + +- (NSInteger)statusValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)statusPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)statusDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)statusDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)statusDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)statusEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)statusDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)statusDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)statusLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)statusLayout {} + +- (void)statusApplyPreferences {} + +- (void)statusTeardown +{ + [_statusUi release]; + _statusUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_terminal_macos.m b/src/features/ui/platform/macos/ui_terminal_macos.m new file mode 100644 index 0000000..66e23b4 --- /dev/null +++ b/src/features/ui/platform/macos/ui_terminal_macos.m @@ -0,0 +1,55 @@ +/* Terminal feature category of the macOS workspace window: terminal profiles and sessions (D14). + * Owns _terminalUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneTerminalFeature) + +- (NSInteger)terminalValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)terminalPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)terminalDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)terminalDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)terminalDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)terminalEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)terminalDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)terminalDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)terminalLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)terminalLayout {} + +- (void)terminalApplyPreferences {} + +- (void)terminalTeardown +{ + [_terminalUi release]; + _terminalUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_tools_macos.m b/src/features/ui/platform/macos/ui_tools_macos.m new file mode 100644 index 0000000..802308f --- /dev/null +++ b/src/features/ui/platform/macos/ui_tools_macos.m @@ -0,0 +1,55 @@ +/* Tools feature category of the macOS workspace window: Tools menu: external tools, keybindings, file associations, memory dialog (D23, D24). + * Owns _toolsUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneToolsFeature) + +- (NSInteger)toolsValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)toolsPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)toolsDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)toolsDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)toolsDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)toolsEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)toolsDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)toolsDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)toolsLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)toolsLayout {} + +- (void)toolsApplyPreferences {} + +- (void)toolsTeardown +{ + [_toolsUi release]; + _toolsUi = nil; +} + +@end diff --git a/src/features/ui/platform/macos/ui_view_macos.m b/src/features/ui/platform/macos/ui_view_macos.m new file mode 100644 index 0000000..f19a556 --- /dev/null +++ b/src/features/ui/platform/macos/ui_view_macos.m @@ -0,0 +1,55 @@ +/* View feature category of the macOS workspace window: View toggles, themes and full screen (D7, D9, D18). + * Owns _viewUi. Hook contract: ui_macos_internal.h ("feature categories"). + * Base stub: implements no command and draws nothing yet. */ +#include "ui_macos_internal.h" + +@implementation AxyneWorkspaceView (AxyneViewFeature) + +- (NSInteger)viewValidateCommand:(AxyneCommandId)command item:(NSMenuItem *)item +{ + (void)command; (void)item; + return AXYNE_MAC_COMMAND_UNHANDLED; +} + +- (BOOL)viewPerformCommand:(AxyneCommandId)command sender:(id)sender +{ + (void)command; (void)sender; + return NO; +} + +- (void)viewDocumentActivated:(AxyneDocument *)document { (void)document; } + +- (void)viewDocumentWillClose:(AxyneDocument *)document { (void)document; } + +- (void)viewDocumentSaved:(AxyneDocument *)document { (void)document; } + +- (void)viewEditorNotification:(SCNotification *)notification { (void)notification; } + +- (BOOL)viewDrawStatusBarInRect:(NSRect)rect colors:(const AxyneMacChromeColors *)colors +{ + (void)rect; (void)colors; + return NO; +} + +- (void)viewDrawBottomPanelStripInRect:(NSRect)strip colors:(const AxyneMacChromeColors *)colors +{ + (void)strip; (void)colors; +} + +- (BOOL)viewLayoutBottomPanelInRect:(NSRect)content tab:(AxyneBottomPanelTab)tab +{ + (void)content; (void)tab; + return NO; +} + +- (void)viewLayout {} + +- (void)viewApplyPreferences {} + +- (void)viewTeardown +{ + [_viewUi release]; + _viewUi = nil; +} + +@end diff --git a/src/features/ui/platform/windows/ui_build_windows.c b/src/features/ui/platform/windows/ui_build_windows.c new file mode 100644 index 0000000..61b4351 --- /dev/null +++ b/src/features/ui/platform/windows/ui_build_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 빌드 카테고리: CMake, 빌드 구성, 다시 빌드/정리, tasks.json (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->build_ui (struct AxyneWinBuildUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_build_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_build_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_build = { + .name = "build", + .command = axyne_win_build_command, + .command_state = axyne_win_build_command_state +}; diff --git a/src/features/ui/platform/windows/ui_commands_windows.c b/src/features/ui/platform/windows/ui_commands_windows.c new file mode 100644 index 0000000..366beaf --- /dev/null +++ b/src/features/ui/platform/windows/ui_commands_windows.c @@ -0,0 +1,832 @@ +#include "ui_windows_internal.h" + +#include +#include +#include + +#include "axyne/palette.h" +#include "axyne/ui_design.h" +#include "../../app_dialogs.h" + +/* Base registry/feature glue of the Windows UI: the feature table and the + * hook fan-out that ui_windows.c calls at fixed points, registry command + * dispatch, the key map and the menus built from the registry. */ + +static const AxyneWinFeature *const axyne_win_features[AXYNE_WIN_FEATURE_COUNT] = { + &axyne_win_feature_file, + &axyne_win_feature_edit, + &axyne_win_feature_view, + &axyne_win_feature_build, + &axyne_win_feature_debug, + &axyne_win_feature_terminal, + &axyne_win_feature_status, + &axyne_win_feature_tools, + &axyne_win_feature_help, + &axyne_win_feature_lsp_nav, + &axyne_win_feature_diag, + &axyne_win_feature_release +}; + +const AxyneWinFeature *axyne_win_feature(AxyneWinFeatureId id) +{ + if ((int)id < 0 || id >= AXYNE_WIN_FEATURE_COUNT) return NULL; + return axyne_win_features[id]; +} + +/* Hooks other than create/destroy run only while the features exist. */ +#define AXYNE_WIN_HOOKS_LIVE(state) ((state) != NULL && (state)->features_created) + +void axyne_win_hooks_create(AxyneWindowState *state) +{ + if (state == NULL || state->features_created) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->create != NULL) f->create(state); + } + state->features_created = 1; +} + +void axyne_win_hooks_destroy(AxyneWindowState *state) +{ + /* Reverse order: later features may depend on earlier ones. */ + size_t i = AXYNE_WIN_FEATURE_COUNT; + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + state->features_created = 0; + while (i-- > 0) + if (axyne_win_features[i]->destroy != NULL) axyne_win_features[i]->destroy(state); +} + +int axyne_win_hooks_message(AxyneWindowState *state, HWND window, UINT message, + WPARAM w_param, LPARAM l_param, LRESULT *result) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return 0; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->message != NULL && f->message(state, window, message, w_param, l_param, result)) + return 1; + } + return 0; +} + +int axyne_win_hooks_command(AxyneWindowState *state, AxyneCommandId id) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return 0; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->command != NULL && f->command(state, id)) return 1; + } + return 0; +} + +int axyne_win_hooks_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + for (size_t i = 0; AXYNE_WIN_HOOKS_LIVE(state) && i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->command_state == NULL) continue; + out->enabled = 0; + out->checked = 0; + if (f->command_state(state, id, out)) return 1; + } + out->enabled = 0; + out->checked = 0; + return 0; +} + +void axyne_win_hooks_menu_populate(AxyneWindowState *state, AxyneWinMenuSlot slot, + AxyneWinMenu *menu) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->menu_populate != NULL) f->menu_populate(state, slot, menu); + } +} + +int axyne_win_hooks_timer(AxyneWindowState *state, UINT_PTR timer_id) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return 0; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->timer != NULL && f->timer(state, timer_id)) return 1; + } + return 0; +} + +void axyne_win_hooks_editor_notify(AxyneWindowState *state, + const SCNotification *notification) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->editor_notify != NULL) f->editor_notify(state, notification); + } +} + +void axyne_win_hooks_document_activated(AxyneWindowState *state, AxyneDocument *document) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->document_activated != NULL) f->document_activated(state, document); + } +} + +void axyne_win_hooks_document_closed(AxyneWindowState *state, const AxyneDocument *document) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->document_closed != NULL) f->document_closed(state, document); + } +} + +void axyne_win_hooks_document_saved(AxyneWindowState *state, AxyneDocument *document) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->document_saved != NULL) f->document_saved(state, document); + } +} + +int axyne_win_hooks_status_paint(AxyneWindowState *state, HDC dc, RECT area) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return 0; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->status_paint != NULL && f->status_paint(state, dc, area)) return 1; + } + return 0; +} + +int axyne_win_hooks_panel_paint(AxyneWindowState *state, HDC dc, AxyneWinPanelTab tab, + RECT body) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return 0; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->panel_paint != NULL && f->panel_paint(state, dc, tab, body)) return 1; + } + return 0; +} + +void axyne_win_hooks_panel_layout(AxyneWindowState *state, AxyneWinPanelTab tab, + RECT body, int visible) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->panel_layout != NULL) f->panel_layout(state, tab, body, visible); + } +} + +void axyne_win_hooks_layout(AxyneWindowState *state, int width, int height) +{ + if (!AXYNE_WIN_HOOKS_LIVE(state)) return; + for (size_t i = 0; i < AXYNE_WIN_FEATURE_COUNT; ++i) { + const AxyneWinFeature *f = axyne_win_features[i]; + if (f->layout != NULL) f->layout(state, width, height); + } +} + +/* ---- registry command dispatch -------------------------------------------- */ + +static int axyne_win_command_known(AxyneCommandId id) +{ + return axyne_command_available(id, AXYNE_WIN_COMMAND_PLATFORM); +} + +int axyne_win_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + AxyneWinCommandState ignored; + if (out == NULL) out = &ignored; + out->enabled = 0; + out->checked = 0; + if (state == NULL || !axyne_win_command_known(id)) return 0; + if (axyne_win_hooks_command_state(state, id, out)) return 1; + return axyne_win_base_command_state(state, id, out); +} + +int axyne_win_command_enabled(AxyneWindowState *state, AxyneCommandId id) +{ + AxyneWinCommandState command; + return axyne_win_command_state(state, id, &command) && command.enabled; +} + +int axyne_win_dispatch(AxyneWindowState *state, AxyneCommandId id) +{ + if (state == NULL || !axyne_win_command_known(id)) return 0; + if (axyne_win_hooks_command(state, id)) return 1; + return axyne_win_base_command(state, id); +} + +int axyne_win_execute(AxyneWindowState *state, AxyneCommandId id) +{ + if (!axyne_win_command_enabled(state, id)) return 0; + return axyne_win_dispatch(state, id); +} + +size_t axyne_win_command_key_text(const AxyneWindowState *state, AxyneCommandId id, + char *buffer, size_t capacity) +{ + if (buffer != NULL && capacity > 0) buffer[0] = '\0'; + if (state == NULL || !state->keymap_ready) return 0; + return axyne_keymap_display(&state->keymap, id, buffer, capacity); +} + +/* ---- key map -------------------------------------------------------------- */ + +void axyne_win_keymap_destroy(AxyneWindowState *state) +{ + if (state == NULL || !state->keymap_ready) return; + axyne_keymap_destroy(&state->keymap); + state->keymap_ready = 0; +} + +/* Registry defaults, then the base-step exceptions, then the user's + * keybindings (legacy actions and command-keyed entries, which win). + * Base-step exception: the existing keys of existing commands stay, so Run + * (디버깅하지 않고 실행) keeps F5 and 디버깅 시작 has no key on Windows; the + * Debug category applies D1 (and calls axyne_preferences_migrate_run_binding) + * once the debugger works. */ +void axyne_win_keymap_rebuild(AxyneWindowState *state) +{ + AxyneKeySequence run; + if (state == NULL) return; + axyne_win_keymap_destroy(state); + if (axyne_keymap_init(&state->keymap, AXYNE_WIN_COMMAND_PLATFORM) != AXYNE_STATUS_OK) + return; + state->keymap_ready = 1; + if (axyne_key_sequence_parse("F5", &run) == AXYNE_STATUS_OK) + (void)axyne_keymap_set_command(&state->keymap, + AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING, &run, 1); + (void)axyne_keymap_set_command(&state->keymap, AXYNE_COMMAND_DEBUG_START, NULL, 0); + /* Malformed user key text is skipped (the rest still applies). */ + (void)axyne_keymap_apply_preferences(&state->keymap, &state->preferences); +} + +/* ---- menu table ------------------------------------------------------------ + * The Windows menus in Figma order (file 7J8SYhLpybJgpxD3qFqL5u, frames + * 24:14189 파일, 24:14297 편집, 24:14400 보기, 24:14516 빌드, 24:14603 디버그, + * 24:14695 도구, 39:2402 도움말) plus the existing items Figma does not show. + * Titles and shortcut text come from the registry and the key map; commands + * not available on Windows are skipped; separators collapse when a group is + * empty. */ + +typedef enum AxyneWinMenuEntryKind { + AXYNE_WIN_MENU_ENTRY_END = 0, + AXYNE_WIN_MENU_ENTRY_ITEM, + AXYNE_WIN_MENU_ENTRY_SEPARATOR, + AXYNE_WIN_MENU_ENTRY_SUBMENU, /* title; disabled when no child is enabled */ + AXYNE_WIN_MENU_ENTRY_SUBMENU_KEEP, /* title; always enabled */ + AXYNE_WIN_MENU_ENTRY_SUBMENU_END, + AXYNE_WIN_MENU_ENTRY_SLOT, /* feature items (AxyneWinMenuSlot) */ + AXYNE_WIN_MENU_ENTRY_RECENT_FILES /* recent files, or "최근 항목 없음" */ +} AxyneWinMenuEntryKind; + +typedef struct AxyneWinMenuEntry { + AxyneWinMenuEntryKind kind; + AxyneCommandId command; + const char *title; + AxyneWinMenuSlot slot; +} AxyneWinMenuEntry; + +#define I(id) { AXYNE_WIN_MENU_ENTRY_ITEM, id, NULL, AXYNE_WIN_MENU_SLOT_COUNT } +#define SEP { AXYNE_WIN_MENU_ENTRY_SEPARATOR, AXYNE_COMMAND_NONE, NULL, AXYNE_WIN_MENU_SLOT_COUNT } +#define SUB(title) { AXYNE_WIN_MENU_ENTRY_SUBMENU, AXYNE_COMMAND_NONE, title, AXYNE_WIN_MENU_SLOT_COUNT } +#define SUB_KEEP(title) { AXYNE_WIN_MENU_ENTRY_SUBMENU_KEEP, AXYNE_COMMAND_NONE, title, AXYNE_WIN_MENU_SLOT_COUNT } +#define END_SUB { AXYNE_WIN_MENU_ENTRY_SUBMENU_END, AXYNE_COMMAND_NONE, NULL, AXYNE_WIN_MENU_SLOT_COUNT } +#define SLOT(slot) { AXYNE_WIN_MENU_ENTRY_SLOT, AXYNE_COMMAND_NONE, NULL, slot } +#define RECENT_FILES { AXYNE_WIN_MENU_ENTRY_RECENT_FILES, AXYNE_COMMAND_NONE, NULL, AXYNE_WIN_MENU_SLOT_COUNT } +#define END { AXYNE_WIN_MENU_ENTRY_END, AXYNE_COMMAND_NONE, NULL, AXYNE_WIN_MENU_SLOT_COUNT } + +static const AxyneWinMenuEntry axyne_win_menu_file[] = { + I(AXYNE_COMMAND_FILE_NEW), I(AXYNE_COMMAND_FILE_NEW_PROJECT), + I(AXYNE_COMMAND_FILE_OPEN), I(AXYNE_COMMAND_FILE_OPEN_FOLDER), SEP, + SUB_KEEP("최근 항목"), + SLOT(AXYNE_WIN_MENU_SLOT_RECENT_FOLDERS), RECENT_FILES, SEP, + I(AXYNE_COMMAND_FILE_CLEAR_RECENT), + END_SUB, SEP, + I(AXYNE_COMMAND_FILE_SAVE), I(AXYNE_COMMAND_FILE_SAVE_AS), I(AXYNE_COMMAND_FILE_SAVE_ALL), SEP, + I(AXYNE_COMMAND_FILE_AUTO_SAVE), + SUB("환경 설정"), + I(AXYNE_COMMAND_TOOLS_SETTINGS), I(AXYNE_COMMAND_TOOLS_WORKSPACE_SETTINGS), + I(AXYNE_COMMAND_TOOLS_OPEN_SETTINGS_JSON), I(AXYNE_COMMAND_TOOLS_KEYBINDINGS), + END_SUB, SEP, + I(AXYNE_COMMAND_FILE_CLOSE), I(AXYNE_COMMAND_FILE_CLOSE_WINDOW), I(AXYNE_COMMAND_FILE_EXIT), + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_edit[] = { + I(AXYNE_COMMAND_EDIT_UNDO), I(AXYNE_COMMAND_EDIT_REDO), SEP, + I(AXYNE_COMMAND_EDIT_CUT), I(AXYNE_COMMAND_EDIT_COPY), I(AXYNE_COMMAND_EDIT_PASTE), + I(AXYNE_COMMAND_EDIT_SELECT_ALL), SEP, + I(AXYNE_COMMAND_EDIT_FIND), I(AXYNE_COMMAND_EDIT_REPLACE), I(AXYNE_COMMAND_EDIT_FIND_IN_FILES), + I(AXYNE_COMMAND_EDIT_GO_TO_LINE), I(AXYNE_COMMAND_EDIT_SELECT_LINE), SEP, + I(AXYNE_COMMAND_EDIT_TOGGLE_LINE_COMMENT), I(AXYNE_COMMAND_EDIT_TOGGLE_BLOCK_COMMENT), + I(AXYNE_COMMAND_EDIT_DUPLICATE_LINE), I(AXYNE_COMMAND_EDIT_MOVE_LINE_UP), + I(AXYNE_COMMAND_EDIT_MOVE_LINE_DOWN), SEP, + I(AXYNE_COMMAND_EDIT_INDENT), I(AXYNE_COMMAND_EDIT_OUTDENT), SEP, + I(AXYNE_COMMAND_EDIT_GO_TO_DEFINITION), I(AXYNE_COMMAND_EDIT_FIND_REFERENCES), + I(AXYNE_COMMAND_EDIT_GO_TO_SYMBOL), + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_view[] = { + I(AXYNE_COMMAND_VIEW_COMMAND_PALETTE), I(AXYNE_COMMAND_VIEW_QUICK_OPEN), SEP, + I(AXYNE_COMMAND_VIEW_EXPLORER), I(AXYNE_COMMAND_VIEW_GIT_PANEL), I(AXYNE_COMMAND_VIEW_OUTLINE), + I(AXYNE_COMMAND_VIEW_TOOLBAR), I(AXYNE_COMMAND_VIEW_STATUS_BAR), I(AXYNE_COMMAND_VIEW_PANEL), SEP, + I(AXYNE_COMMAND_VIEW_OUTPUT), I(AXYNE_COMMAND_VIEW_PROBLEMS), I(AXYNE_COMMAND_VIEW_TERMINAL), + I(AXYNE_COMMAND_VIEW_CLEAR_OUTPUT), SEP, + I(AXYNE_COMMAND_VIEW_ZOOM_IN), I(AXYNE_COMMAND_VIEW_ZOOM_OUT), I(AXYNE_COMMAND_VIEW_ZOOM_RESET), SEP, + I(AXYNE_COMMAND_VIEW_ALWAYS_SHOW_ACTIONS), I(AXYNE_COMMAND_VIEW_WORD_WRAP), + SUB("테마"), + I(AXYNE_COMMAND_VIEW_THEME_DARK), I(AXYNE_COMMAND_VIEW_THEME_LIGHT), + I(AXYNE_COMMAND_VIEW_THEME_HIGH_CONTRAST), I(AXYNE_COMMAND_VIEW_THEME_CUSTOM), SEP, + I(AXYNE_COMMAND_VIEW_THEME_EDIT_JSON), + END_SUB, SEP, + I(AXYNE_COMMAND_VIEW_FULL_SCREEN), + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_build[] = { + I(AXYNE_COMMAND_BUILD_BUILD), I(AXYNE_COMMAND_BUILD_REBUILD), I(AXYNE_COMMAND_BUILD_CLEAN), SEP, + I(AXYNE_COMMAND_BUILD_CMAKE_CONFIGURE), I(AXYNE_COMMAND_BUILD_CMAKE_RECONFIGURE_CLEAN), SEP, + SUB("빌드 구성"), + I(AXYNE_COMMAND_BUILD_CONFIG_DEBUG), I(AXYNE_COMMAND_BUILD_CONFIG_RELEASE), + I(AXYNE_COMMAND_BUILD_CONFIG_REL_WITH_DEB_INFO), I(AXYNE_COMMAND_BUILD_CONFIG_MIN_SIZE_REL), SEP, + I(AXYNE_COMMAND_BUILD_OPEN_CMAKE_PRESETS), + END_SUB, + I(AXYNE_COMMAND_BUILD_SELECT_TARGET), I(AXYNE_COMMAND_BUILD_CONFIGURE_RUNNER), SEP, + I(AXYNE_COMMAND_BUILD_RUN_TASK), I(AXYNE_COMMAND_BUILD_EDIT_TASKS), SEP, + I(AXYNE_COMMAND_BUILD_CANCEL), + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_debug[] = { + I(AXYNE_COMMAND_DEBUG_START), I(AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING), + I(AXYNE_COMMAND_DEBUG_STOP), I(AXYNE_COMMAND_DEBUG_RESTART), + I(AXYNE_COMMAND_DEBUG_PAUSE), I(AXYNE_COMMAND_DEBUG_CONTINUE), SEP, + I(AXYNE_COMMAND_DEBUG_TOGGLE_BREAKPOINT), I(AXYNE_COMMAND_DEBUG_CLEAR_BREAKPOINTS), SEP, + I(AXYNE_COMMAND_DEBUG_STEP_OVER), I(AXYNE_COMMAND_DEBUG_STEP_INTO), I(AXYNE_COMMAND_DEBUG_STEP_OUT), SEP, + I(AXYNE_COMMAND_DEBUG_RUN_ARGUMENTS), + SUB("외부 디버거 열기"), + I(AXYNE_COMMAND_DEBUG_EXTERNAL_VISUAL_STUDIO), I(AXYNE_COMMAND_DEBUG_EXTERNAL_WINDBG), + I(AXYNE_COMMAND_DEBUG_EXTERNAL_X64DBG), I(AXYNE_COMMAND_DEBUG_EXTERNAL_LLDB), SEP, + I(AXYNE_COMMAND_DEBUG_CONFIGURE_DEBUGGERS), + END_SUB, + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_tools[] = { + SUB("새 터미널"), + I(AXYNE_COMMAND_TERMINAL_NEW), I(AXYNE_COMMAND_TERMINAL_NEW_POWERSHELL), + I(AXYNE_COMMAND_TERMINAL_NEW_COMMAND_PROMPT), I(AXYNE_COMMAND_TERMINAL_NEW_WSL), + I(AXYNE_COMMAND_TERMINAL_NEW_ZSH), I(AXYNE_COMMAND_TERMINAL_NEW_BASH), SEP, + I(AXYNE_COMMAND_TERMINAL_SELECT_DEFAULT_PROFILE), + END_SUB, + SUB("외부 도구"), + SLOT(AXYNE_WIN_MENU_SLOT_EXTERNAL_TOOLS), SEP, + I(AXYNE_COMMAND_TOOLS_CONFIGURE_EXTERNAL_TOOLS), + END_SUB, SEP, + I(AXYNE_COMMAND_TOOLS_SETTINGS), I(AXYNE_COMMAND_TOOLS_WORKSPACE_SETTINGS), + I(AXYNE_COMMAND_TOOLS_OPEN_SETTINGS_JSON), I(AXYNE_COMMAND_TOOLS_OPEN_THEME_JSON), + I(AXYNE_COMMAND_TOOLS_KEYBINDINGS), SEP, + I(AXYNE_COMMAND_TOOLS_MEMORY_USAGE), I(AXYNE_COMMAND_TOOLS_FILE_ASSOCIATIONS), SEP, + I(AXYNE_COMMAND_GIT_STATUS), I(AXYNE_COMMAND_GIT_DIFF), I(AXYNE_COMMAND_GIT_STAGE_ALL), + I(AXYNE_COMMAND_GIT_UNSTAGE_ALL), SEP, + I(AXYNE_COMMAND_GIT_COMMIT), I(AXYNE_COMMAND_GIT_PUSH), I(AXYNE_COMMAND_GIT_PULL), + I(AXYNE_COMMAND_GIT_LOG), + END +}; + +static const AxyneWinMenuEntry axyne_win_menu_help[] = { + I(AXYNE_COMMAND_HELP_GETTING_STARTED), I(AXYNE_COMMAND_HELP_KEYBOARD_SHORTCUTS), + I(AXYNE_COMMAND_HELP_OPEN_LOG_FOLDER), SEP, + I(AXYNE_COMMAND_HELP_CHECK_FOR_UPDATES), I(AXYNE_COMMAND_HELP_RELEASE_NOTES), + I(AXYNE_COMMAND_HELP_REPORT_ISSUE), SEP, + I(AXYNE_COMMAND_HELP_LICENSES), I(AXYNE_COMMAND_HELP_ABOUT), + I(AXYNE_COMMAND_HELP_CONTENTS), I(AXYNE_COMMAND_HELP_SHOW_SETTINGS_FOLDER), + END +}; + +/* Menu-bar order (ui_design.h titles): 파일 편집 보기 빌드 디버그 도구 도움말. */ +static const AxyneWinMenuEntry *const axyne_win_menu_bar[] = { + axyne_win_menu_file, axyne_win_menu_edit, axyne_win_menu_view, axyne_win_menu_build, + axyne_win_menu_debug, axyne_win_menu_tools, axyne_win_menu_help +}; + +#undef I +#undef SEP +#undef SUB +#undef SUB_KEEP +#undef END_SUB +#undef SLOT +#undef RECENT_FILES +#undef END +/* ---- end of menu table ---------------------------------------------------- */ + +typedef struct AxyneWinMenuLevel { + AxyneWinMenu *menu; + int pending_separator; + int parent_position; /* position of this submenu in its parent */ + int keep_enabled; +} AxyneWinMenuLevel; + +static void axyne_win_menu_flush_separator(AxyneWinMenuLevel *level) +{ + if (level->pending_separator && axyne_win_menu_count(level->menu) > 0) + axyne_win_menu_separator(level->menu); + level->pending_separator = 0; +} + +static void axyne_win_menu_add_command(AxyneWindowState *state, AxyneWinMenuLevel *level, + AxyneCommandId id) +{ + const AxyneCommandInfo *info = axyne_command_info(id); + AxyneWinCommandState command; + char keys[64]; + unsigned flags = 0; + if (info == NULL || !axyne_command_available(id, AXYNE_WIN_COMMAND_PLATFORM)) return; + /* Rule: no enabled entry that does nothing. Unimplemented = disabled. */ + if (!axyne_win_command_state(state, id, &command) || !command.enabled) + flags |= AXYNE_WIN_MENU_DISABLED; + if (command.checked) flags |= AXYNE_WIN_MENU_CHECKED; + if ((info->flags & AXYNE_COMMAND_FLAG_RADIO) != 0) flags |= AXYNE_WIN_MENU_RADIO; + (void)axyne_win_command_key_text(state, id, keys, sizeof(keys)); + axyne_win_menu_flush_separator(level); + axyne_win_menu_add(level->menu, AXYNE_WIN_COMMAND_MENU_ID(id), + axyne_command_title(id, AXYNE_WIN_COMMAND_PLATFORM), keys, flags); +} + +void axyne_win_menu_bar_fill(AxyneWindowState *state, int index, AxyneWinMenu *menu) +{ + AxyneWinMenuLevel levels[4]; + size_t depth = 0; + const AxyneWinMenuEntry *entry; + int recent_items = 0; + if (state == NULL || menu == NULL || index < 0 || + index >= (int)(sizeof(axyne_win_menu_bar) / sizeof(axyne_win_menu_bar[0]))) + return; + memset(levels, 0, sizeof(levels)); + levels[0].menu = menu; + for (entry = axyne_win_menu_bar[index]; entry->kind != AXYNE_WIN_MENU_ENTRY_END; ++entry) { + AxyneWinMenuLevel *level = &levels[depth]; + switch (entry->kind) { + case AXYNE_WIN_MENU_ENTRY_ITEM: + axyne_win_menu_add_command(state, level, entry->command); + break; + case AXYNE_WIN_MENU_ENTRY_SEPARATOR: + level->pending_separator = 1; + break; + case AXYNE_WIN_MENU_ENTRY_SUBMENU: + case AXYNE_WIN_MENU_ENTRY_SUBMENU_KEEP: { + AxyneWinMenu *sub; + int position; + if (depth + 1 >= sizeof(levels) / sizeof(levels[0])) break; + axyne_win_menu_flush_separator(level); + position = axyne_win_menu_count(level->menu); + sub = axyne_win_menu_submenu(level->menu, entry->title, 0); + if (sub == NULL) break; + ++depth; + memset(&levels[depth], 0, sizeof(levels[depth])); + levels[depth].menu = sub; + levels[depth].parent_position = position; + levels[depth].keep_enabled = entry->kind == AXYNE_WIN_MENU_ENTRY_SUBMENU_KEEP; + recent_items = 0; + break; + } + case AXYNE_WIN_MENU_ENTRY_SUBMENU_END: + if (depth == 0) break; + if (!level->keep_enabled && !axyne_win_menu_has_enabled(level->menu)) + axyne_win_menu_enable_at(levels[depth - 1].menu, level->parent_position, 0); + --depth; + break; + case AXYNE_WIN_MENU_ENTRY_SLOT: { + int before = axyne_win_menu_count(level->menu); + axyne_win_menu_flush_separator(level); + axyne_win_hooks_menu_populate(state, entry->slot, level->menu); + recent_items += axyne_win_menu_count(level->menu) - before; + break; + } + case AXYNE_WIN_MENU_ENTRY_RECENT_FILES: + axyne_win_menu_flush_separator(level); + recent_items += axyne_win_menu_add_recent_files(state, level->menu); + if (recent_items <= 0) + axyne_win_menu_add(level->menu, 0, "최근 항목 없음", NULL, + AXYNE_WIN_MENU_DISABLED); + break; + default: + break; + } + } +} + +/* ---- keyboard dispatch ---------------------------------------------------- */ + +void axyne_win_show_status_text(AxyneWindowState *state, const char *utf8) +{ + if (state == NULL) return; + (void)snprintf(state->status_text, sizeof(state->status_text), "%s", + utf8 != NULL ? utf8 : ""); + if (state->main_window != NULL && state->client_height > 0) { + RECT status = {0, state->client_height - AXYNE_UI_STATUS, state->client_width, + state->client_height}; + InvalidateRect(state->main_window, &status, FALSE); + } +} + +int axyne_win_debugging(AxyneWindowState *state) +{ + return state != NULL && (state->debug_context || + axyne_debugger_is_active(&state->debugger)); +} + +static AxyneKeyStroke axyne_win_stroke(WPARAM virtual_key) +{ + AxyneKeyStroke stroke; + int control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + int alt = (GetKeyState(VK_MENU) & 0x8000) != 0; + stroke.key = axyne_key_from_windows_vk((unsigned int)virtual_key); + stroke.mods = 0; + if ((GetKeyState(VK_SHIFT) & 0x8000) != 0) stroke.mods |= AXYNE_KEYMOD_SHIFT; + if (control) stroke.mods |= AXYNE_KEYMOD_CTRL; + if (alt) stroke.mods |= AXYNE_KEYMOD_ALT; + /* AltGr reports as Ctrl+Alt with the right Alt down; it types text. */ + if (control && alt && (GetKeyState(VK_RMENU) & 0x8000) != 0) + stroke.mods |= AXYNE_KEYMOD_ALTGR; + return stroke; +} + +static void axyne_win_chord_hint(AxyneWindowState *state, uint64_t now) +{ + char hint[sizeof(state->status_text)]; + if (axyne_keymap_chord_hint(&state->keymap, now, hint, sizeof(hint)) == 0) hint[0] = '\0'; + if (hint[0] != '\0') { + axyne_win_show_status_text(state, hint); + state->chord_hint_shown = 1; + if (state->main_window != NULL) + (void)SetTimer(state->main_window, AXYNE_WIN_CHORD_TIMER_ID, + AXYNE_KEYMAP_CHORD_TIMEOUT_MS + 20u, NULL); + return; + } + if (!state->chord_hint_shown) return; + state->chord_hint_shown = 0; + if (state->main_window != NULL) KillTimer(state->main_window, AXYNE_WIN_CHORD_TIMER_ID); + axyne_win_show_status_text(state, NULL); +} + +void axyne_win_chord_timer(AxyneWindowState *state) +{ + uint64_t now; + if (state == NULL) return; + now = GetTickCount64(); + if (state->keymap_ready && !axyne_keymap_pending(&state->keymap, now, NULL)) + axyne_keymap_cancel_pending(&state->keymap); + if (state->keymap_ready) axyne_win_chord_hint(state, now); +} + +/* Runs the command a shortcut resolved to; 1 = the key is consumed. */ +static int axyne_win_run_key_command(AxyneWindowState *state, AxyneCommandId command, + int completed_chord) +{ + const AxyneCommandInfo *info = axyne_command_info(command); + AxyneWinCommandState feature; + if (axyne_win_command_enabled(state, command)) { + (void)axyne_win_dispatch(state, command); + return 1; + } + if (completed_chord) return 1; + /* The 11 legacy-action shortcuts were always consumed and ran the base + * action, which reports why it cannot run (busy, unsaved, ...) or does + * nothing in the empty state. A feature that owns the command decides + * through its enabled state instead. */ + if (info != NULL && info->legacy_action >= 0 && + !axyne_win_hooks_command_state(state, command, &feature)) { + (void)axyne_win_base_command(state, command); + return 1; + } + return 0; +} + +int axyne_win_handle_key(AxyneWindowState *state, WPARAM virtual_key) +{ + AxyneCommandId command = AXYNE_COMMAND_NONE; + AxyneKeymapResult result; + uint64_t now; + int was_pending; + if (state == NULL || !state->keymap_ready) return 0; + /* The native terminal grid takes every key itself (direct shell input, + * Ctrl+C etc. go to the shell), as before the key map. */ + if (state->shell_view != NULL && GetFocus() == state->shell_view) return 0; + now = GetTickCount64(); + was_pending = axyne_keymap_pending(&state->keymap, now, NULL); + result = axyne_keymap_feed(&state->keymap, axyne_win_stroke(virtual_key), now, + axyne_win_debugging(state) ? AXYNE_KEYMAP_CONTEXT_DEBUGGING : 0u, + &command); + axyne_win_chord_hint(state, now); + switch (result) { + case AXYNE_KEYMAP_PENDING: + case AXYNE_KEYMAP_ABORTED: + return 1; + case AXYNE_KEYMAP_COMMAND: + return axyne_win_run_key_command(state, command, was_pending); + default: + return 0; + } +} + +int axyne_win_palette_key(AxyneWindowState *state, WPARAM virtual_key) +{ + AxyneKeySequence sequence; + AxyneCommandId command; + if (state == NULL || !state->keymap_ready) return 0; + memset(&sequence, 0, sizeof(sequence)); + sequence.strokes[0] = axyne_win_stroke(virtual_key); + sequence.count = 1; + if ((sequence.strokes[0].mods & AXYNE_KEYMOD_ALTGR) != 0) return 0; + command = axyne_keymap_lookup(&state->keymap, &sequence, 0); + if (command != AXYNE_COMMAND_VIEW_QUICK_OPEN && command != AXYNE_COMMAND_VIEW_COMMAND_PALETTE) + return 0; + (void)axyne_win_dispatch(state, command); + return 1; +} + +/* ---- command palette and shortcut reference ------------------------------- */ + +/* Registry title without a trailing "..." / "…" (menus keep it). */ +static void axyne_win_plain_title(AxyneCommandId id, char *buffer, size_t capacity) +{ + size_t length; + (void)snprintf(buffer, capacity, "%s", axyne_command_title(id, AXYNE_WIN_COMMAND_PLATFORM)); + length = strlen(buffer); + if (length >= 3 && strcmp(buffer + length - 3, "...") == 0) buffer[length - 3] = '\0'; + else if (length >= 3 && strcmp(buffer + length - 3, "\xe2\x80\xa6") == 0) + buffer[length - 3] = '\0'; + length = strlen(buffer); + while (length > 0 && buffer[length - 1] == ' ') buffer[--length] = '\0'; +} + +typedef struct AxyneWinPaletteCandidate { + AxyneCommandId id; + int score; + size_t order; + size_t start, length; +} AxyneWinPaletteCandidate; + +static int axyne_win_palette_compare(const void *left, const void *right) +{ + const AxyneWinPaletteCandidate *a = (const AxyneWinPaletteCandidate *)left; + const AxyneWinPaletteCandidate *b = (const AxyneWinPaletteCandidate *)right; + if (a->score != b->score) return a->score > b->score ? -1 : 1; + if (a->order != b->order) return a->order < b->order ? -1 : 1; + return 0; +} + +static char *axyne_win_strdup(const char *text) +{ + size_t length = strlen(text); + char *copy = (char *)malloc(length + 1); + if (copy != NULL) memcpy(copy, text, length + 1); + return copy; +} + +void axyne_win_palette_commands(AxyneWindowState *state) +{ + AxynePaletteController *palette; + AxyneWinPaletteCandidate *candidates; + const char *query; + size_t i, count = 0, keep; + if (state == NULL) return; + palette = &state->palette; + if (!palette->active || palette->mode != AXYNE_PALETTE_MODE_COMMAND) return; + query = palette->input != NULL ? palette->input + palette->query_offset : ""; + candidates = (AxyneWinPaletteCandidate *)calloc(axyne_command_count() + 1, + sizeof(*candidates)); + if (candidates == NULL) return; + for (i = 0; i < axyne_command_count(); ++i) { + const AxyneCommandInfo *info = axyne_command_at(i); + AxynePaletteMatch title_match, keyword_match; + char title[160]; + int title_hit, keyword_hit; + if (info == NULL || (info->flags & AXYNE_COMMAND_FLAG_PALETTE) == 0 || + !axyne_command_available(info->id, AXYNE_WIN_COMMAND_PLATFORM) || + !axyne_win_command_state(state, info->id, NULL)) + continue; + axyne_win_plain_title(info->id, title, sizeof(title)); + memset(&title_match, 0, sizeof(title_match)); + memset(&keyword_match, 0, sizeof(keyword_match)); + title_hit = axyne_palette_match(title, query, &title_match); + keyword_hit = query[0] != '\0' && info->keywords != NULL && + axyne_palette_match(info->keywords, query, &keyword_match); + if (!title_hit && !keyword_hit) continue; + candidates[count].id = info->id; + candidates[count].order = i; + /* Same ranking as the shared palette: the title match unless the + * keyword match is clearly better (keywords count half). */ + if (title_hit && (!keyword_hit || title_match.score >= keyword_match.score / 2)) { + candidates[count].score = title_match.score; + candidates[count].start = title_match.start; + candidates[count].length = title_match.length; + } else { + candidates[count].score = keyword_match.score / 2 > 0 ? keyword_match.score / 2 : 1; + } + ++count; + } + if (count > 1) qsort(candidates, count, sizeof(*candidates), axyne_win_palette_compare); + keep = count < AXYNE_PALETTE_MAX_ITEMS ? count : AXYNE_PALETTE_MAX_ITEMS; + axyne_palette_list_destroy(&palette->list); + palette->message[0] = '\0'; + palette->message_actionable = 0; + palette->selection = 0; + palette->scroll = 0; + if (keep > 0) + palette->list.items = (AxynePaletteItem *)calloc(keep, sizeof(*palette->list.items)); + for (i = 0; i < keep && palette->list.items != NULL; ++i) { + const AxyneCommandInfo *info = axyne_command_info(candidates[i].id); + AxynePaletteItem *item = &palette->list.items[i]; + char title[160], keys[64]; + axyne_win_plain_title(candidates[i].id, title, sizeof(title)); + (void)axyne_win_command_key_text(state, candidates[i].id, keys, sizeof(keys)); + item->label = axyne_win_strdup(title); + item->detail = axyne_win_strdup(keys); + item->badge = axyne_win_strdup(""); + if (item->label == NULL || item->detail == NULL || item->badge == NULL) { + free(item->label); + free(item->detail); + free(item->badge); + memset(item, 0, sizeof(*item)); + break; + } + item->badge_color = 0x8b919b; + item->kind = AXYNE_PALETTE_ITEM_COMMAND; + item->match_start = candidates[i].length != 0 ? candidates[i].start : 0; + item->match_len = candidates[i].length; + item->payload = info->palette_id != 0 + ? info->palette_id : AXYNE_WIN_PALETTE_REGISTRY_PAYLOAD + (int)info->id; + ++palette->list.count; + } + if (palette->list.count == 0) { + free(palette->list.items); + palette->list.items = NULL; + (void)snprintf(palette->message, sizeof(palette->message), "%s", "일치하는 명령 없음"); + } + free(candidates); +} + +void axyne_win_show_shortcuts(AxyneWindowState *state) +{ + enum { ROW_TEXT = 160, KEY_TEXT = 64 }; + size_t total = axyne_command_count() * AXYNE_COMMAND_DEFAULT_KEYS; + AxyneShortcutRow *rows; + char (*labels)[ROW_TEXT]; + char (*keys)[KEY_TEXT]; + AxyneShortcutSection sections[AXYNE_COMMAND_GROUP_CONTEXT]; + size_t section_count = 0, used = 0; + int group; + if (state == NULL || !state->keymap_ready) return; + rows = (AxyneShortcutRow *)calloc(total, sizeof(*rows)); + labels = (char (*)[ROW_TEXT])calloc(total, sizeof(*labels)); + keys = (char (*)[KEY_TEXT])calloc(total, sizeof(*keys)); + if (rows == NULL || labels == NULL || keys == NULL) { + free(rows); free(labels); free(keys); + return; + } + for (group = 0; group < (int)AXYNE_COMMAND_GROUP_CONTEXT; ++group) { + size_t first = used, i; + for (i = 0; i < axyne_command_count() && used < total; ++i) { + const AxyneCommandInfo *info = axyne_command_at(i); + AxyneKeySequence bindings[AXYNE_COMMAND_DEFAULT_KEYS]; + size_t count, k; + if (info == NULL || (int)info->group != group || + !axyne_command_available(info->id, AXYNE_WIN_COMMAND_PLATFORM)) + continue; + count = axyne_keymap_bindings(&state->keymap, info->id, bindings, + AXYNE_COMMAND_DEFAULT_KEYS); + if (count > AXYNE_COMMAND_DEFAULT_KEYS) count = AXYNE_COMMAND_DEFAULT_KEYS; + for (k = 0; k < count && used < total; ++k) { + char *text = keys[used]; + size_t c; + (void)axyne_key_sequence_format(&bindings[k], AXYNE_KEY_FORMAT_WINDOWS, + text, KEY_TEXT); + /* The dialog splits chips at '+': a chord reads Ctrl+K+Ctrl+O. */ + for (c = 0; text[c] != '\0'; ++c) if (text[c] == ' ') text[c] = '+'; + axyne_win_plain_title(info->id, labels[used], ROW_TEXT); + rows[used].label = labels[used]; + rows[used].keys = text; + ++used; + } + } + if (used == first) continue; + sections[section_count].title = axyne_command_group_title((AxyneCommandGroup)group); + sections[section_count].rows = rows + first; + sections[section_count].row_count = used - first; + ++section_count; + } + axyne_shortcuts_dialog_show(state->main_window, sections, section_count); + free(rows); + free(labels); + free(keys); +} diff --git a/src/features/ui/platform/windows/ui_debug_windows.c b/src/features/ui/platform/windows/ui_debug_windows.c new file mode 100644 index 0000000..f543bb4 --- /dev/null +++ b/src/features/ui/platform/windows/ui_debug_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 디버그 카테고리: 디버거 세션, 디버그 콘솔, 외부 디버거, 실행 인수 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->debug_ui (struct AxyneWinDebugUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_debug_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_debug_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_debug = { + .name = "debug", + .command = axyne_win_debug_command, + .command_state = axyne_win_debug_command_state +}; diff --git a/src/features/ui/platform/windows/ui_diag_windows.c b/src/features/ui/platform/windows/ui_diag_windows.c new file mode 100644 index 0000000..7d8f973 --- /dev/null +++ b/src/features/ui/platform/windows/ui_diag_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 진단 카테고리: 인라인 진단, 탐색기 배지 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->diag_ui (struct AxyneWinDiagUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_diag_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_diag_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_diag = { + .name = "diag", + .command = axyne_win_diag_command, + .command_state = axyne_win_diag_command_state +}; diff --git a/src/features/ui/platform/windows/ui_edit_windows.c b/src/features/ui/platform/windows/ui_edit_windows.c new file mode 100644 index 0000000..0100072 --- /dev/null +++ b/src/features/ui/platform/windows/ui_edit_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 편집 카테고리: 블록 주석 등 새 편집 명령 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->edit_ui (struct AxyneWinEditUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_edit_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_edit_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_edit = { + .name = "edit", + .command = axyne_win_edit_command, + .command_state = axyne_win_edit_command_state +}; diff --git a/src/features/ui/platform/windows/ui_file_windows.c b/src/features/ui/platform/windows/ui_file_windows.c new file mode 100644 index 0000000..4fbe7c3 --- /dev/null +++ b/src/features/ui/platform/windows/ui_file_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 파일 카테고리: 새 프로젝트, 모두 저장, 자동 저장, 창 닫기, 최근 폴더/지우기 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->file_ui (struct AxyneWinFileUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_file_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_file_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_file = { + .name = "file", + .command = axyne_win_file_command, + .command_state = axyne_win_file_command_state +}; diff --git a/src/features/ui/platform/windows/ui_help_windows.c b/src/features/ui/platform/windows/ui_help_windows.c new file mode 100644 index 0000000..bbe7087 --- /dev/null +++ b/src/features/ui/platform/windows/ui_help_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 도움말 카테고리: 시작하기, 로그 폴더, 라이선스 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->help_ui (struct AxyneWinHelpUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_help_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_help_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_help = { + .name = "help", + .command = axyne_win_help_command, + .command_state = axyne_win_help_command_state +}; diff --git a/src/features/ui/platform/windows/ui_lsp_nav_windows.c b/src/features/ui/platform/windows/ui_lsp_nav_windows.c new file mode 100644 index 0000000..f235fcd --- /dev/null +++ b/src/features/ui/platform/windows/ui_lsp_nav_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* LSP 탐색 카테고리: 정의/참조 이동, 작업 영역 기호 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->lsp_nav_ui (struct AxyneWinLspNavUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_lsp_nav_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_lsp_nav_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_lsp_nav = { + .name = "lsp_nav", + .command = axyne_win_lsp_nav_command, + .command_state = axyne_win_lsp_nav_command_state +}; diff --git a/src/features/ui/platform/windows/ui_release_windows.c b/src/features/ui/platform/windows/ui_release_windows.c new file mode 100644 index 0000000..2fa9556 --- /dev/null +++ b/src/features/ui/platform/windows/ui_release_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 릴리스 카테고리: 릴리스 노트, 업데이트 확인, 첫 실행 배너 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->release_ui (struct AxyneWinReleaseUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_release_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_release_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_release = { + .name = "release", + .command = axyne_win_release_command, + .command_state = axyne_win_release_command_state +}; diff --git a/src/features/ui/platform/windows/ui_status_windows.c b/src/features/ui/platform/windows/ui_status_windows.c new file mode 100644 index 0000000..541cb87 --- /dev/null +++ b/src/features/ui/platform/windows/ui_status_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 상태 표시줄 카테고리: 세그먼트, 메모리 게이지 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->status_ui (struct AxyneWinStatusUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_status_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_status_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_status = { + .name = "status", + .command = axyne_win_status_command, + .command_state = axyne_win_status_command_state +}; diff --git a/src/features/ui/platform/windows/ui_terminal_windows.c b/src/features/ui/platform/windows/ui_terminal_windows.c new file mode 100644 index 0000000..e2d27a5 --- /dev/null +++ b/src/features/ui/platform/windows/ui_terminal_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 터미널 카테고리: 프로필, 여러 세션, 분할 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->terminal_ui (struct AxyneWinTerminalUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_terminal_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_terminal_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_terminal = { + .name = "terminal", + .command = axyne_win_terminal_command, + .command_state = axyne_win_terminal_command_state +}; diff --git a/src/features/ui/platform/windows/ui_tools_windows.c b/src/features/ui/platform/windows/ui_tools_windows.c new file mode 100644 index 0000000..89ef60c --- /dev/null +++ b/src/features/ui/platform/windows/ui_tools_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 도구 카테고리: 외부 도구, 키바인딩, 메모리 사용량, 파일 연결, theme.json (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->tools_ui (struct AxyneWinToolsUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_tools_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_tools_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_tools = { + .name = "tools", + .command = axyne_win_tools_command, + .command_state = axyne_win_tools_command_state +}; diff --git a/src/features/ui/platform/windows/ui_view_windows.c b/src/features/ui/platform/windows/ui_view_windows.c new file mode 100644 index 0000000..8244f2d --- /dev/null +++ b/src/features/ui/platform/windows/ui_view_windows.c @@ -0,0 +1,28 @@ +#include "ui_windows_internal.h" + +/* 보기 카테고리: 테마, 개요/도구 모음/상태 표시줄 토글, 동작 항상 표시 (Windows). + * Stub registered by the UI foundation: the feature category fills in these + * hooks and keeps its data in state->view_ui (struct AxyneWinViewUi). + * Until then it implements no command, so its menu items stay disabled. */ + +static int axyne_win_view_command(AxyneWindowState *state, AxyneCommandId id) +{ + (void)state; + (void)id; + return 0; +} + +static int axyne_win_view_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + (void)state; + (void)id; + (void)out; + return 0; +} + +const AxyneWinFeature axyne_win_feature_view = { + .name = "view", + .command = axyne_win_view_command, + .command_state = axyne_win_view_command_state +}; diff --git a/src/features/ui/platform/windows/ui_windows.c b/src/features/ui/platform/windows/ui_windows.c index 2826f04..2600e60 100644 --- a/src/features/ui/platform/windows/ui_windows.c +++ b/src/features/ui/platform/windows/ui_windows.c @@ -12,6 +12,7 @@ #include #include "axyne/ui.h" +#include "axyne/app_paths.h" #include "terminal_view_windows.h" #include "axyne/ui_design.h" #include "axyne/layout_metrics.h" @@ -42,6 +43,7 @@ #include "../../debugger_actions.h" #include "../../preferences_window.h" #include "../../app_dialogs.h" +#include "ui_windows_internal.h" enum { AXYNE_TOP_MENU = AXYNE_UI_MENU, @@ -118,13 +120,6 @@ static COLORREF AXYNE_PROBLEM_FIELD; static COLORREF AXYNE_PROBLEM_FIELD_BORDER; static HBRUSH AXYNE_PROBLEM_FIELD_BRUSH; -typedef struct AxyneGitUiRun AxyneGitUiRun; -typedef void *(__stdcall *AxyneCreateLexer)(const char *name); - -/* The left column shows either the file explorer or the Git panel. */ -enum { AXYNE_SIDEBAR_TAB_EXPLORER = 0, AXYNE_SIDEBAR_TAB_GIT = 1, - AXYNE_SIDEBAR_TAB_COUNT = 2, AXYNE_SIDEBAR_TAB_PAD = 9 }; - /* Git panel geometry (logical pixels) and its debounced refresh timer. */ enum { AXYNE_GIT_ROW = AXYNE_UI_ROW, @@ -145,179 +140,6 @@ enum { AXYNE_GIT_HSCROLL_STEP = 48 /* graph pixels per horizontal wheel notch */ }; -/* Snapshot of the Git panel's data. Written only on the UI thread: worker - * jobs return their results by window message and are applied there. */ -typedef struct AxyneGitPanelUi { - AxyneGitChanges changes; - AxyneGitGraph graph; /* newest first, at most graph_loaded_limit commits */ - int graph_ok; /* the graph could be read (zero commits is fine) */ - int graph_limit; /* commits requested by refreshes; 0 = default, grown by "더 불러오기" */ - AxyneGitGraphMode graph_mode; - unsigned graph_generation; /* mode request identity, independent of commits/workspace */ - HWND graph_selector; - int graph_loaded_limit; /* the request the shown graph answered (0 = none) */ - int graph_loading; /* a larger graph was requested and has not arrived */ - int loaded; /* a snapshot of the current workspace arrived */ - int repo_ok; /* the change list could be read */ - char error[512]; /* Git's message when !repo_ok */ - int has_staged; - int job_busy; /* a refresh or stage job runs on a worker thread */ - int refresh_pending; /* another refresh was requested meanwhile */ - unsigned generation; /* bumped per workspace; stale results are dropped */ - int changes_scroll; /* first visible change row */ - int graph_scroll; /* first visible graph row */ - int graph_hscroll; /* pixels the graph rows are scrolled to the left */ - int graph_width_base; /* widest graph item without ref chips */ - int graph_width_valid; /* graph_width_base matches the rows and expansion */ - int graph_width; /* scrollable width of the graph rows for graph_width_list */ - int graph_width_list; /* list width graph_width was measured for */ - int wheel_remainder; - int hwheel_remainder; - unsigned diff_serial; /* newest diff request; older results are dropped */ - char *selected_path; /* selected change (repository path) or NULL */ - char *selected_hash; /* selected commit or NULL (one selection at a time) */ - char *selected_cfile; /* selected file row under the expanded commit */ - /* The commit whose changed files are listed inline below its row (only - * one at a time). expanded_state: 0 none, 1 loading, 2 ready, 3 failed. */ - char *expanded_hash; - size_t expanded_row; /* index of that commit in graph, (size_t)-1 = none */ - AxyneGitChanges expanded_files; - int expanded_state; - char expanded_error[256]; - unsigned expand_serial; /* newest file-list request; older results are dropped */ - HWND message_edit; /* multi-line commit message */ - int message_filled; /* the message has a non-blank character */ - int message_focus; -} AxyneGitPanelUi; - -typedef struct AxyneWindowState { - HMODULE scintilla_module; - HMODULE lexilla_module; - AxyneCreateLexer create_lexer; - HWND editor; - HFONT ui_font; - HFONT ui_font_italic; /* 12px italic: preview tab titles */ - HFONT code_font; - HFONT badge_font; - HFONT tab_badge_font; - HFONT font_small; /* 11px: toolbar chips, explorer header, panel tabs */ - HFONT font_tiny; /* 10px: shortcut hints */ - HFONT font_bold; /* 11px bold: accent run label */ - HFONT font_glyph13; /* 13px: tab close glyph, toolbar glyphs */ - HFONT font_glyph14; /* 14px: toolbar glyphs */ - HFONT font_dot; /* 7px: dirty marker */ - HFONT font_output; /* 12px mono: output and terminal panel text */ - HFONT font_mono_small; /* 10px mono: problems file name and position */ - HWND main_window; - AxyneDocumentSet documents; - AxyneExplorer explorer; - AxyneWatcher *watcher; - size_t explorer_selection; - int explorer_has_selection; - size_t explorer_scroll; - int explorer_wheel_remainder; - /* Outline (개요) section at the bottom of the explorer tab. It is scanned - * only while that tab is shown; otherwise it is marked stale and rescanned - * when the tab appears. */ - AxyneOutline outline; - int outline_timer_active; - int outline_stale; - int outline_wheel_remainder; - size_t first_visible_tab; - size_t tab_reveal_index; - int tab_wheel_remainder; - int terminal_panel_selected; - int problems_panel_selected; - int closing; - int loading_editor; - int empty_shown; /* shortcut guide replaces the editor (no open document) */ - AxyneRunnerConfig terminal_runner; - AxyneRunnerConfig action_runner; - /* Build target selector. Kept in memory for the session; runtimes are - * discovered lazily (first editor file, click or build), never at startup. */ - AxyneBuildTarget build_target; - AxyneRuntimeList runtimes; - int runtimes_discovered; - int build_menu_open; /* chevron points up while the popup is open */ - int runtime_discovery_posted; - AxyneLanguagePlan pending_plan; /* run step that follows a successful build */ - int pending_run; - AxyneDebugger debugger; - AxyneProcess *action_process; - AxyneProcess *git_process; - AxyneGitUiRun *git_run; - int git_batch_busy; /* a commit, push or pull runs on a worker thread */ - HWND terminal_output; - HWND shell_view; - HWND terminal_start; - HWND terminal_stop; - HWND debug_start; - HWND debug_pause; - HWND debug_continue; - HWND debug_step_over; - HWND debug_breakpoint; - int active_action; - int last_exit_code; - int last_exit_failed; - int has_exit_status; - AxynePreferences global_preferences; - AxynePreferences preferences; - char *global_preferences_path; - char *workspace_preferences_path; - unsigned char workspace_binding_present[AXYNE_ACTION_COUNT]; - AxyneLspClient *lsp; - char lsp_status[192]; - /* Problems panel: the model (LSP diagnostics + build output), the rows - * built from it for painting and the panel's view state. */ - AxyneProblemList problems; - AxyneProblemCollapsed problems_collapsed; - AxyneBuildFeed build_feed; - int build_feed_active; - /* Process output is in the ANSI code page; a DBCS lead byte cut off from - * its trail byte at a chunk end waits here per stream (0 out, 1 err). */ - unsigned char build_feed_lead[2]; - int build_feed_has_lead[2]; - AxyneProblemRow *problem_rows; - size_t problem_row_count; - AxyneProblemCounts problem_counts; - char problem_summary[160]; - wchar_t problems_tab_label[32]; - int problem_selected; /* row index or -1 */ - int problem_scroll; /* pixels */ - int problem_wheel_remainder; - int problem_refresh_pending; - int problem_has_sel; /* selection identity kept across rebuilds */ - int problem_sel_kind; - size_t problem_sel_line; - size_t problem_sel_column; - char *problem_sel_path; - char *problem_sel_message; - HWND problems_filter; - int menu_active; /* 1-based index of the menu bar item whose popup is open */ - int explorer_hidden; /* View > Explorer */ - int sidebar_tab; /* AXYNE_SIDEBAR_TAB_*: what the sidebar shows */ - AxyneGitPanelUi git_panel; - int panel_hidden; /* View > Bottom Panel */ - int sidebar_size; /* dragged explorer width; 0 = default, session only */ - int panel_size; /* dragged bottom panel height; 0 = default */ - int client_width; /* client size cached by axyne_layout and paint */ - int client_height; - int splitter_drag; /* 0 none, 1 explorer border, 2 panel border */ - int splitter_start; /* pointer coordinate when the drag began */ - int splitter_start_size; - int fullscreen; - LONG_PTR saved_style; - WINDOWPLACEMENT saved_placement; - AxynePaletteController palette; - HWND palette_edit; - HWND palette_popup; - HWND palette_dim; - WNDPROC palette_edit_proc; - HBRUSH palette_edit_brush; - HFONT palette_fonts[7]; - int palette_updating; - int palette_wheel_remainder; -} AxyneWindowState; /* The explorer column and bottom panel collapse to zero when hidden, so every * layout, paint and hit-test computation shares these two sizes. */ @@ -530,6 +352,8 @@ static void axyne_lsp_error(AxyneLspClient *client, AxyneStatus status, } static AxyneDocument *axyne_active(AxyneWindowState *state); +static int axyne_settings_file_exists(const char *utf8_path); +static int axyne_ensure_settings_file(HWND owner, AxyneWindowState *state, int workspace); static int axyne_empty_state(AxyneWindowState *state); static AxyneDocument *axyne_active_visible(AxyneWindowState *state); static void axyne_sync_empty_state(AxyneWindowState *state); @@ -565,8 +389,6 @@ static void axyne_palette_open(HWND window, AxyneWindowState *state, const char *initial); static int axyne_palette_document(void *user, char **path, char **text, size_t *length, size_t *line_count); -static void axyne_palette_menu_label(wchar_t *buffer, size_t capacity, - const wchar_t *title); static wchar_t *axyne_wide(const char *utf8) { @@ -722,31 +544,35 @@ static int axyne_windows_prefers_dark(void) return value == 0; } -static char *axyne_global_preferences_path(void) +/* Settings files (D3): profiles are read from settings.json, falling back to + * the old preferences.json, and always written to settings.json; the old file + * is never deleted. The read path is refreshed after every write. */ +static char *axyne_workspace_settings_path(const char *root, int write) { - wchar_t app_data[MAX_PATH]; - wchar_t directory[MAX_PATH]; - wchar_t path[MAX_PATH]; - if (SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, SHGFP_TYPE_CURRENT, - app_data) != S_OK) return NULL; - if (swprintf_s(directory, MAX_PATH, L"%ls\\Axyne", app_data) < 0 || - swprintf_s(path, MAX_PATH, L"%ls\\preferences.json", directory) < 0) - return NULL; - return axyne_utf8(path); + char *directory, *path; + if (root == NULL) return NULL; + directory = axyne_preferences_workspace_directory(root); + if (directory == NULL) return NULL; + path = write ? axyne_preferences_write_path(directory) + : axyne_preferences_read_path(directory); + axyne_preferences_free_path(directory); + return path; } -static char *axyne_workspace_preferences_path(const char *root) +static void axyne_refresh_settings_read_paths(AxyneWindowState *state) { - wchar_t *wide_root; wchar_t directory[32768]; wchar_t path[32768]; - if (root == NULL) return NULL; - wide_root = axyne_wide(root); - if (wide_root == NULL || swprintf_s(directory, 32768, L"%ls\\.axyne", - wide_root) < 0 || - swprintf_s(path, 32768, L"%ls\\preferences.json", directory) < 0) { - free(wide_root); return NULL; + char *global = axyne_app_path(AXYNE_APP_PATH_SETTINGS_READ); + if (global != NULL) { + free(state->global_preferences_path); + state->global_preferences_path = global; + } + if (state->explorer.root != NULL && state->workspace_preferences_path != NULL) { + char *workspace = axyne_workspace_settings_path(state->explorer.root, 0); + if (workspace != NULL) { + free(state->workspace_preferences_path); + state->workspace_preferences_path = workspace; + } } - free(wide_root); - return axyne_utf8(path); } static void axyne_debug_log(const char *format, const char *detail, unsigned long code) @@ -1132,6 +958,7 @@ static void axyne_apply_preferences(AxyneWindowState *state) axyne_apply_editor_preferences(state); axyne_refresh_terminal_theme(state); if (state->editor != NULL) axyne_style_title_bar(GetParent(state->editor)); + axyne_win_keymap_rebuild(state); } static void axyne_load_global_preferences(AxyneWindowState *state) @@ -1139,7 +966,8 @@ static void axyne_load_global_preferences(AxyneWindowState *state) AxyneError error; AxyneStatus status; axyne_preferences_defaults(&state->global_preferences); - state->global_preferences_path = axyne_global_preferences_path(); + state->global_preferences_path = axyne_app_path(AXYNE_APP_PATH_SETTINGS_READ); + state->global_settings_write_path = axyne_app_path(AXYNE_APP_PATH_SETTINGS_WRITE); status = state->global_preferences_path == NULL ? AXYNE_STATUS_NOT_FOUND : axyne_preferences_load_global(state->global_preferences_path, &state->global_preferences, &error); @@ -1158,7 +986,9 @@ static void axyne_load_workspace_preferences(AxyneWindowState *state, memset(state->workspace_binding_present, 0, sizeof(state->workspace_binding_present)); free(state->workspace_preferences_path); - state->workspace_preferences_path = axyne_workspace_preferences_path(root); + free(state->workspace_settings_write_path); + state->workspace_preferences_path = axyne_workspace_settings_path(root, 0); + state->workspace_settings_write_path = axyne_workspace_settings_path(root, 1); if (state->workspace_preferences_path == NULL) { axyne_apply_preferences(state); return; @@ -1224,7 +1054,7 @@ static void axyne_refresh_action_controls(AxyneWindowState *state) saved_document = document != NULL && !document->is_untitled && document->path != NULL && !document->is_dirty; EnableWindow(state->terminal_start, !axyne_terminal_view_running(state->shell_view)); - EnableWindow(state->terminal_stop, state->terminal_panel_selected ? + EnableWindow(state->terminal_stop, state->panel_tab == AXYNE_WIN_PANEL_TERMINAL ? axyne_terminal_view_running(state->shell_view) : terminal_active); EnableWindow(state->debug_start, axyne_debugger_can_start(debugger_active, terminal_active, @@ -1314,7 +1144,8 @@ static int axyne_terminal_configure_default(AxyneWindowState *state) static void axyne_start_terminal_panel(HWND window, AxyneWindowState *state) { - state->terminal_panel_selected = 1; state->problems_panel_selected = 0; state->panel_hidden = 0; + state->panel_tab = AXYNE_WIN_PANEL_TERMINAL; + state->panel_hidden = 0; axyne_layout(window, state); if (axyne_terminal_view_running(state->shell_view)) { SetFocus(state->shell_view); return; } AxyneProcessSpec process_spec = {0}; AxyneError error = {0}; @@ -1331,7 +1162,7 @@ static void axyne_start_terminal_panel(HWND window, AxyneWindowState *state) SetFocus(state->shell_view); axyne_refresh_action_controls(state); } static void axyne_terminal_stop(AxyneWindowState *state) { - if (state->terminal_panel_selected) axyne_terminal_view_stop(state->shell_view); + if (state->panel_tab == AXYNE_WIN_PANEL_TERMINAL) axyne_terminal_view_stop(state->shell_view); else if (state->action_process) axyne_process_terminate(state->action_process, NULL); } @@ -1342,8 +1173,7 @@ static void axyne_windows_debugger_start(HWND window, AxyneWindowState *state) if (axyne_debugger_is_active(&state->debugger)) return; /* Debugger messages go to the Output panel, which must be visible even * when View > Bottom Panel hid it. */ - state->terminal_panel_selected = 0; - state->problems_panel_selected = 0; + state->panel_tab = AXYNE_WIN_PANEL_OUTPUT; state->panel_hidden = 0; axyne_layout(window, state); if (state->action_process != NULL) { @@ -1556,8 +1386,7 @@ static wchar_t *axyne_git_ui_wide(const char *text) * when Problems or Terminal was the selected panel. */ static void axyne_git_ui_show_output(HWND window, AxyneWindowState *state) { - state->terminal_panel_selected = 0; - state->problems_panel_selected = 0; + state->panel_tab = AXYNE_WIN_PANEL_OUTPUT; state->panel_hidden = 0; axyne_layout(window, state); InvalidateRect(window, NULL, FALSE); @@ -2196,8 +2025,7 @@ static void axyne_start_action(HWND window, AxyneWindowState *state, int run) int action; AxyneStatus status; if (doc == NULL) return; - state->terminal_panel_selected = 0; - state->problems_panel_selected = 0; + state->panel_tab = AXYNE_WIN_PANEL_OUTPUT; state->panel_hidden = 0; axyne_layout(window, state); if (state->action_process != NULL || @@ -2303,12 +2131,13 @@ static int axyne_windows_preferences_save(void *context, AxynePreferences *edite AxyneWindowState *state = info->state; HWND owner = info->owner; int workspace = info->workspace; - const char *path = workspace ? state->workspace_preferences_path : state->global_preferences_path; + const char *path = workspace ? state->workspace_settings_write_path : state->global_settings_write_path; AxyneError error; AxyneStatus status; if (path == NULL) { MessageBoxA(owner, "The preference path is unavailable.", "Axyne - Preferences", MB_OK | MB_ICONERROR); return 0; } status = workspace ? axyne_preferences_save_workspace(edited, path, &error) : axyne_preferences_save_global(edited, path, &error); if (status != AXYNE_STATUS_OK) { MessageBoxA(owner, error.message, "Axyne - Preferences", MB_OK | MB_ICONERROR); return 0; } + axyne_refresh_settings_read_paths(state); state->preferences = *edited; if (workspace) memcpy(state->workspace_binding_present, edited->binding_present, @@ -2330,6 +2159,44 @@ static int axyne_windows_preferences_save(void *context, AxynePreferences *edite return 1; } +/* "settings.json 열기" needs the file written from now on (D3). When only the + * old preferences.json exists it is copied verbatim, so a workspace keeps + * every override; when no file exists at all, settings.json is created from + * the effective profile (a new workspace file overrides nothing). */ +static int axyne_ensure_settings_file(HWND owner, AxyneWindowState *state, int workspace) +{ + const char *write_path = workspace ? state->workspace_settings_write_path + : state->global_settings_write_path; + char *directory; + AxyneError error; + AxyneStatus status; + if (write_path == NULL) return 0; + if (axyne_settings_file_exists(write_path)) return 1; + directory = workspace + ? (state->explorer.root != NULL + ? axyne_preferences_workspace_directory(state->explorer.root) : NULL) + : axyne_app_path(AXYNE_APP_PATH_CONFIG_DIR); + memset(&error, 0, sizeof(error)); + status = directory != NULL ? axyne_preferences_adopt_legacy_file(directory, &error) + : AXYNE_STATUS_NOT_FOUND; + free(directory); + if (status == AXYNE_STATUS_NOT_FOUND) { + AxynePreferences created = workspace ? state->preferences : state->global_preferences; + if (workspace) { + created.present_fields = 0; + memset(created.binding_present, 0, sizeof(created.binding_present)); + } + status = workspace ? axyne_preferences_save_workspace(&created, write_path, &error) + : axyne_preferences_save_global(&created, write_path, &error); + } + if (status != AXYNE_STATUS_OK) { + axyne_workspace_show_error(owner, "Unable to create settings file", &error); + return 0; + } + axyne_refresh_settings_read_paths(state); + return 1; +} + /* Opens the Figma preferences window. The "settings.json 열기" link closes it * and opens the profile's file as an editor document; a profile that was * never saved is created first so there is something to open. */ @@ -2340,10 +2207,12 @@ static int axyne_preferences_dialog(HWND owner, AxyneWindowState *state, AxyneWindowsPreferencesContext context; AxynePreferencesWindowHooks hooks; const char *path = workspace ? state->workspace_preferences_path : state->global_preferences_path; + const char *write_path = workspace ? state->workspace_settings_write_path + : state->global_settings_write_path; AxyneError error; context.owner = owner; context.state = state; context.workspace = workspace; hooks.context = &context; hooks.save = axyne_windows_preferences_save; - if (path == NULL) { MessageBoxA(owner, "The preference path is unavailable.", "Axyne - Preferences", MB_OK | MB_ICONERROR); return 0; } + if (path == NULL || write_path == NULL) { MessageBoxA(owner, "The preference path is unavailable.", "Axyne - Preferences", MB_OK | MB_ICONERROR); return 0; } if (workspace) { /* Only fields the workspace file already overrides stay present. */ AxynePreferences stored; @@ -2355,94 +2224,8 @@ static int axyne_preferences_dialog(HWND owner, AxyneWindowState *state, } } if (!axyne_preferences_window_show(owner, workspace, &initial, &hooks)) return 1; - if (GetFileAttributesA(path) == INVALID_FILE_ATTRIBUTES) { - AxynePreferences created = workspace ? state->preferences : state->global_preferences; - AxyneStatus status; - if (workspace) { created.present_fields = 0; memset(created.binding_present, 0, sizeof(created.binding_present)); } - status = workspace ? axyne_preferences_save_workspace(&created, path, &error) : axyne_preferences_save_global(&created, path, &error); - if (status != AXYNE_STATUS_OK) { axyne_workspace_show_error(owner, "Unable to create settings file", &error); return 0; } - } - axyne_open_document(owner, state, path); - return 1; -} - -static int axyne_windows_binding_matches(const AxyneWindowState *state, - AxynePreferenceAction action, - WPARAM key) -{ - const AxyneKeyBinding *binding = axyne_preferences_find_binding( - &state->preferences, action); - unsigned int modifiers = 0; - if (binding == NULL || !binding->enabled) return 0; - if ((GetKeyState(VK_CONTROL) & 0x8000) != 0) - modifiers |= AXYNE_KEY_MODIFIER_CONTROL; - if ((GetKeyState(VK_SHIFT) & 0x8000) != 0) - modifiers |= AXYNE_KEY_MODIFIER_SHIFT; - if ((GetKeyState(VK_MENU) & 0x8000) != 0) - modifiers |= AXYNE_KEY_MODIFIER_ALT; - { - unsigned int required = binding->modifiers; - if ((required & AXYNE_KEY_MODIFIER_COMMAND) != 0) - required = (required & ~AXYNE_KEY_MODIFIER_COMMAND) | - AXYNE_KEY_MODIFIER_CONTROL; - if (required != modifiers) return 0; - } - if (strlen(binding->key) == 1) - return toupper((unsigned char)binding->key[0]) == toupper((int)key); - return (key == VK_F5 && _stricmp(binding->key, "F5") == 0); -} - -/* Ctrl+Shift+P: the quick-file binding plus Shift opens the palette with ">". */ -static int axyne_windows_palette_shift_matches(const AxyneWindowState *state, - WPARAM key) -{ - const AxyneKeyBinding *binding = axyne_preferences_find_binding( - &state->preferences, AXYNE_ACTION_QUICK_FILE); - unsigned int modifiers = 0, required; - if (binding == NULL || !binding->enabled || strlen(binding->key) != 1) return 0; - if ((GetKeyState(VK_CONTROL) & 0x8000) != 0) modifiers |= AXYNE_KEY_MODIFIER_CONTROL; - if ((GetKeyState(VK_SHIFT) & 0x8000) != 0) modifiers |= AXYNE_KEY_MODIFIER_SHIFT; - if ((GetKeyState(VK_MENU) & 0x8000) != 0) modifiers |= AXYNE_KEY_MODIFIER_ALT; - required = binding->modifiers; - if ((required & AXYNE_KEY_MODIFIER_COMMAND) != 0) - required = (required & ~AXYNE_KEY_MODIFIER_COMMAND) | AXYNE_KEY_MODIFIER_CONTROL; - if ((required & AXYNE_KEY_MODIFIER_SHIFT) != 0) return 0; - if (modifiers != (required | AXYNE_KEY_MODIFIER_SHIFT)) return 0; - return toupper((unsigned char)binding->key[0]) == toupper((int)key); -} - -static int axyne_action_key(HWND window, AxyneWindowState *state, WPARAM key); - -static int axyne_palette_handle_open_key(HWND window, AxyneWindowState *state, - WPARAM key) -{ - if (axyne_windows_palette_shift_matches(state, key)) { - axyne_palette_open(window, state, ">"); - return 1; - } - if (axyne_windows_binding_matches(state, AXYNE_ACTION_QUICK_FILE, key)) { - axyne_palette_open(window, state, ""); - return 1; - } - return 0; -} - -static int axyne_handle_key(HWND window, AxyneWindowState *state, WPARAM key) -{ - if (GetFocus() == state->shell_view) return 0; - /* Document commands are consumed but do nothing in the empty state. */ - int empty = axyne_empty_state(state); - if (axyne_palette_handle_open_key(window, state, key)) return 1; - if (axyne_windows_binding_matches(state, AXYNE_ACTION_NEW, key)) axyne_new_document(window, state); - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_OPEN, key)) axyne_open_document(window, state, NULL); - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_SAVE, key)) { if (!empty) (void)axyne_save_active(window, state); } - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_CLOSE, key)) { if (!empty) axyne_close_tab(window, state, state->documents.active_index); } - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_FIND, key)) { if (!empty) axyne_find(window, state, 0, 0); } - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_REPLACE, key)) { if (!empty) axyne_find(window, state, 1, 0); } - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_SEARCH_WORKSPACE, key)) axyne_search_folder(window, state, 0); - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_BUILD, key)) { if (!empty) axyne_start_action(window, state, 0); } - else if (axyne_windows_binding_matches(state, AXYNE_ACTION_RUN, key)) { if (!empty) axyne_start_action(window, state, 1); } - else return axyne_action_key(window, state, key); + if (!axyne_ensure_settings_file(owner, state, workspace)) return 0; + axyne_open_document(owner, state, write_path); return 1; } @@ -2727,6 +2510,7 @@ static int axyne_save_active(HWND window, AxyneWindowState *state) axyne_refresh_outline(state); /* Save As may change the file type */ axyne_update_title(window, state); axyne_refresh_action_controls(state); + axyne_win_hooks_document_saved(state, axyne_active(state)); return 1; } @@ -2786,6 +2570,7 @@ static int axyne_show_document(AxyneWindowState *state, size_t index) axyne_refresh_action_controls(state); axyne_problems_refresh(state); /* the active file's problems come first */ axyne_refresh_outline(state); + axyne_win_hooks_document_activated(state, doc); SetFocus(state->editor); return 1; } @@ -2894,6 +2679,7 @@ static void axyne_open_document_ex(HWND window, AxyneWindowState *state, return; } if (replaced) { + axyne_win_hooks_document_closed(state, &evicted); if (state->lsp != NULL) (void)axyne_lsp_did_close(state->lsp, &evicted, NULL); axyne_problems_clear_lsp(state, evicted.path); if (evicted.owns_native_editor_document && state->editor != NULL) @@ -2935,6 +2721,7 @@ static int axyne_open_virtual_document(HWND window, AxyneWindowState *state, return 0; } if (replaced) { + axyne_win_hooks_document_closed(state, &evicted); if (state->lsp != NULL) (void)axyne_lsp_did_close(state->lsp, &evicted, NULL); axyne_problems_clear_lsp(state, evicted.path); if (evicted.owns_native_editor_document && state->editor != NULL) @@ -2981,6 +2768,7 @@ static void axyne_close_tab(HWND window, AxyneWindowState *state, size_t index) if (!axyne_show_document(state, successor)) return; } AxyneDocument *doc = &state->documents.documents[index]; + axyne_win_hooks_document_closed(state, doc); if (state->lsp != NULL) (void)axyne_lsp_did_close(state->lsp, doc, NULL); axyne_problems_clear_lsp(state, doc->path); if (doc->owns_native_editor_document && state->editor != NULL) @@ -3018,12 +2806,12 @@ static int axyne_editor_actionable(AxyneWindowState *state) focus != state->shell_view && focus != state->terminal_output; } -static int axyne_preferences_file_exists(const AxyneWindowState *state) +static int axyne_settings_file_exists(const char *utf8_path) { wchar_t *path; DWORD attributes; - if (state->global_preferences_path == NULL) return 0; - path = axyne_wide(state->global_preferences_path); + if (utf8_path == NULL) return 0; + path = axyne_wide(utf8_path); if (path == NULL) return 0; attributes = GetFileAttributesW(path); free(path); @@ -3031,6 +2819,22 @@ static int axyne_preferences_file_exists(const AxyneWindowState *state) (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0; } +/* A global profile exists (settings.json or the old preferences.json). */ +static int axyne_preferences_file_exists(const AxyneWindowState *state) +{ + return axyne_settings_file_exists(state->global_preferences_path); +} + +/* Tools > settings.json 열기: opens the global settings.json (copied from the + * old preferences.json first when only that exists). */ +static void axyne_open_settings_json(HWND window, AxyneWindowState *state) +{ + if (state->global_settings_write_path == NULL || + !axyne_ensure_settings_file(window, state, 0)) + return; + axyne_open_document(window, state, state->global_settings_write_path); +} + /* One enabled-state rule shared by menu flags and keyboard shortcuts, so a * shortcut can never run a command its menu item shows as unavailable. */ static int axyne_action_enabled(AxyneWindowState *state, UINT command) @@ -3073,14 +2877,9 @@ static int axyne_action_enabled(AxyneWindowState *state, UINT command) } } -static UINT axyne_action_flags(AxyneWindowState *state, UINT command) -{ - return axyne_action_enabled(state, command) ? MF_ENABLED : MF_GRAYED; -} - -/* Edit menu commands act on a focused standard text field (terminal input - * or output, Git commit message) when it owns the keyboard, and on the - * Scintilla editor otherwise. */ +/* Edit menu commands act on the focused terminal grid (copy, paste, select + * all) or a focused standard text field (output, Git commit message) when it + * owns the keyboard, and on the Scintilla editor otherwise. */ static HWND axyne_edit_field_focused(const AxyneWindowState *state) { HWND focus = GetFocus(); @@ -3111,11 +2910,6 @@ static int axyne_edit_command_enabled(AxyneWindowState *state, UINT command) return state->editor != NULL && !axyne_empty_state(state); } -static UINT axyne_edit_flags(AxyneWindowState *state, UINT command) -{ - return axyne_edit_command_enabled(state, command) ? MF_ENABLED : MF_GRAYED; -} - /* Returns 1 when `command` is an Edit command (handled or not available). */ static int axyne_edit_command(AxyneWindowState *state, UINT command) { @@ -3176,67 +2970,6 @@ static void axyne_toggle_fullscreen(HWND window, AxyneWindowState *state) } } -/* Appends a row whose chips are `keys` (UTF-8, "Ctrl+Shift+S"). */ -static void axyne_shortcut_row(AxyneShortcutRow *rows, size_t *count, - const char *label, const char *keys) -{ - rows[*count].label = label; - rows[*count].keys = keys; - ++*count; -} - -/* Lists the shortcuts implemented here plus the effective Preferences - * bindings (which can differ from the defaults shown in the menus). */ -static void axyne_show_shortcuts(HWND window, AxyneWindowState *state) -{ - static const AxyneShortcutRow fixed[] = { - {"줄로 이동", "Ctrl+G"}, {"줄 주석 토글", "Ctrl+/"}, - {"줄 복제", "Ctrl+D"}, {"줄 위로 이동", "Alt+Up"}, - {"줄 아래로 이동", "Alt+Down"}, {"들여쓰기", "Tab"}, - {"내어쓰기", "Shift+Tab"}, {"폴더 열기", "Ctrl+Shift+O"}, - {"탐색기", "Ctrl+Shift+E"}, {"Git 패널", "Ctrl+Shift+G"}, - {"하단 패널", "Ctrl+J"}, - {"출력", "Ctrl+Shift+U"}, {"문제", "Ctrl+Shift+M"}, - {"터미널", "Ctrl+`"}, {"확대", "Ctrl+="}, {"축소", "Ctrl+-"}, - {"기본 크기", "Ctrl+0"}, {"자동 줄 바꿈", "Alt+Z"}, - {"전체 화면 (디버깅 중에는 한 단계씩 코드 실행)", "F11"}, - {"프로시저 나가기", "Shift+F11"}, {"디버깅 중지", "Shift+F5"}, - {"모든 중단점 삭제", "Ctrl+Shift+F9"}, - {"다른 이름으로 저장", "Ctrl+Shift+S"}, - {"정의로 이동", "Ctrl+Alt+D"}, {"참조 찾기", "Ctrl+Alt+R"} - }; - AxyneShortcutRow bindings[AXYNE_ACTION_COUNT]; - char keys[AXYNE_ACTION_COUNT][64]; - size_t binding_rows = 0; - AxyneShortcutSection sections[2]; - size_t i; - for (i = 0; i < state->preferences.binding_count && binding_rows < AXYNE_ACTION_COUNT; ++i) { - const AxyneKeyBinding *binding = &state->preferences.bindings[i]; - char *text = keys[binding_rows]; - size_t k, used = 0; - if (!binding->enabled || binding->key[0] == '\0') continue; - text[0] = '\0'; - if ((binding->modifiers & (AXYNE_KEY_MODIFIER_CONTROL | AXYNE_KEY_MODIFIER_COMMAND)) != 0) - used += (size_t)snprintf(text + used, 64 - used, "Ctrl+"); - if ((binding->modifiers & AXYNE_KEY_MODIFIER_ALT) != 0) - used += (size_t)snprintf(text + used, 64 - used, "Alt+"); - if ((binding->modifiers & AXYNE_KEY_MODIFIER_SHIFT) != 0) - used += (size_t)snprintf(text + used, 64 - used, "Shift+"); - for (k = 0; binding->key[k] != '\0' && used + 1 < 64; ++k) - text[used++] = (char)toupper((unsigned char)binding->key[k]); - text[used] = '\0'; - axyne_shortcut_row(bindings, &binding_rows, - axyne_dialogs_action_title((int)binding->action), text); - } - sections[0].title = "단축키"; - sections[0].rows = fixed; - sections[0].row_count = sizeof(fixed) / sizeof(fixed[0]); - sections[1].title = "환경 설정 키 바인딩"; - sections[1].rows = bindings; - sections[1].row_count = binding_rows; - axyne_shortcuts_dialog_show(window, sections, 2); -} - static int axyne_action_command(HWND window, AxyneWindowState *state, UINT command) { void *editor = state->editor; @@ -3321,9 +3054,9 @@ static int axyne_action_command(HWND window, AxyneWindowState *state, UINT comma break; } case AXYNE_CMD_OPEN_PREFERENCES_FILE: - axyne_open_document(window, state, state->global_preferences_path); + axyne_open_settings_json(window, state); break; - case AXYNE_CMD_SHORTCUTS: axyne_show_shortcuts(window, state); break; + case AXYNE_CMD_SHORTCUTS: axyne_win_show_shortcuts(state); break; case AXYNE_CMD_REPORT_ISSUE: if ((INT_PTR)ShellExecuteW(window, L"open", L"https://github.com/team-native/Axyne/issues/new", NULL, NULL, @@ -3336,56 +3069,6 @@ static int axyne_action_command(HWND window, AxyneWindowState *state, UINT comma return 1; } -/* Keyboard shortcuts for the actions above. Alt combinations arrive as - * WM_SYSKEYDOWN, which the message loop also routes here. A shortcut whose - * command is currently unavailable is left to the focused control. */ -static int axyne_action_key(HWND window, AxyneWindowState *state, WPARAM key) -{ - int control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; - int shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; - int alt = (GetKeyState(VK_MENU) & 0x8000) != 0; - /* AltGr reports as Ctrl+Alt; it types characters, never a shortcut. */ - int altgr = (GetKeyState(VK_RMENU) & 0x8000) != 0; - UINT command = 0; - if (control && !alt && !shift) { - if (key == 'G') command = AXYNE_CMD_GOTO_LINE; - else if (key == VK_OEM_2) command = AXYNE_CMD_TOGGLE_COMMENT; - else if (key == 'D') command = AXYNE_CMD_DUPLICATE_LINE; - else if (key == 'J') command = AXYNE_CMD_VIEW_PANEL; - else if (key == VK_OEM_PLUS || key == VK_ADD) command = AXYNE_CMD_ZOOM_IN; - else if (key == VK_OEM_MINUS || key == VK_SUBTRACT) command = AXYNE_CMD_ZOOM_OUT; - else if (key == '0' || key == VK_NUMPAD0) command = AXYNE_CMD_ZOOM_RESET; - else if (key == VK_OEM_3) command = AXYNE_CMD_PANEL_TERMINAL; - } else if (control && alt && !shift && !altgr) { - /* Handled here rather than in the window procedure's WM_KEYDOWN, - * which only runs while the main window itself has focus, not the - * editor. */ - if (key == 'D') command = AXYNE_CMD_LSP_DEFINITION; - else if (key == 'R') command = AXYNE_CMD_LSP_REFERENCES; - } else if (control && shift && !alt) { - if (key == 'S') command = AXYNE_CMD_SAVE_AS; - else if (key == 'O') command = AXYNE_CMD_WORKSPACE; - else if (key == 'E') command = AXYNE_CMD_VIEW_EXPLORER; - else if (key == 'G') command = AXYNE_CMD_VIEW_GIT; - else if (key == 'U') command = AXYNE_CMD_PANEL_OUTPUT; - else if (key == 'M') command = AXYNE_CMD_PANEL_PROBLEMS; - else if (key == VK_F9) command = AXYNE_CMD_DEBUG_CLEAR_BREAKPOINTS; - } else if (alt && !control && !shift) { - if (key == VK_UP) command = AXYNE_CMD_MOVE_LINE_UP; - else if (key == VK_DOWN) command = AXYNE_CMD_MOVE_LINE_DOWN; - else if (key == 'Z') command = AXYNE_CMD_WORD_WRAP; - } else if (!control && !alt) { - if (key == VK_F5 && shift) command = AXYNE_CMD_DEBUG_STOP; - else if (key == VK_F11 && shift) command = AXYNE_CMD_DEBUG_STEP_OUT; - else if (key == VK_F11) - command = axyne_debugger_is_active(&state->debugger) - ? AXYNE_CMD_DEBUG_STEP_INTO : AXYNE_CMD_FULLSCREEN; - } - if (command == 0 || !axyne_action_enabled(state, command)) return 0; - SendMessageW(window, WM_COMMAND, command, 0); - return 1; -} - static char *axyne_workspace_join(const char *parent, const char *name) { size_t parent_length, name_length, separator; @@ -3470,24 +3153,25 @@ static void axyne_workspace_show_error(HWND window, const char *prefix, MessageBoxA(window, message, "Axyne - Workspace", MB_OK | MB_ICONERROR); } -static int axyne_workspace_select_root(HWND window, AxyneWindowState *state) +/* Opens `root` as the workspace: watcher, explorer, workspace settings and + * the Git panel. 1 on success (errors are reported to the user). */ +static int axyne_workspace_open_root(HWND window, AxyneWindowState *state, + const char *root) { - char *root = NULL; AxyneWatcher *watcher = NULL; AxyneError error; AxyneStatus status; - if (!axyne_choose_folder(window, &root)) return 0; status = axyne_watcher_start(root, axyne_workspace_watch_callback, window, &watcher, &error); if (status != AXYNE_STATUS_OK) { axyne_workspace_show_error(window, "Unable to watch workspace", &error); - free(root); return 0; + return 0; } status = axyne_explorer_set_root(&state->explorer, root, &error); if (status != AXYNE_STATUS_OK) { axyne_watcher_stop(watcher); axyne_watcher_release(watcher); axyne_workspace_show_error(window, "Unable to open workspace", &error); - free(root); return 0; + return 0; } if (state->watcher != NULL) { axyne_watcher_stop(state->watcher); @@ -3497,12 +3181,21 @@ static int axyne_workspace_select_root(HWND window, AxyneWindowState *state) state->explorer_has_selection = 0; state->explorer_scroll = 0; axyne_load_workspace_preferences(state, root); - free(root); axyne_git_panel_reset(window, state); InvalidateRect(window, NULL, FALSE); return 1; } +static int axyne_workspace_select_root(HWND window, AxyneWindowState *state) +{ + char *root = NULL; + int opened; + if (!axyne_choose_folder(window, &root)) return 0; + opened = axyne_workspace_open_root(window, state, root); + free(root); + return opened; +} + /* The outline section is part of the explorer tab only. */ static int axyne_outline_visible(const AxyneWindowState *state) { @@ -4050,6 +3743,7 @@ typedef struct AxyneMenuItem { wchar_t *shortcut; int separator; int arrow; + int radio; /* checked state drawn as a dot */ int first; int last; } AxyneMenuItem; @@ -4198,18 +3892,6 @@ static POINT axyne_menu_anchor(AxyneWindowState *state, int index) return point; } -static void axyne_menu_track(HWND window, AxyneWindowState *state, - int menu_index, HMENU menu, AxyneMenuItem *pool) -{ - POINT point = axyne_menu_anchor(state, menu_index); - axyne_menu_seal(menu); - ClientToScreen(window, &point); - TrackPopupMenu(menu, TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON, - point.x, point.y, 0, window, NULL); - DestroyMenu(menu); - axyne_menu_pool_free(pool); -} - static BOOL CALLBACK axyne_style_popup_menu_window(HWND menu, LPARAM unused) { wchar_t class_name[32]; @@ -4233,214 +3915,159 @@ static void axyne_style_popup_menu_windows(void) EnumWindows(axyne_style_popup_menu_window, 0); } -static void axyne_file_popup(HWND window, AxyneWindowState *state) +/* ---- menus built by ui_commands_windows.c (AxyneWinMenu) ----------------- */ + +struct AxyneWinMenu { + HMENU menu; + AxyneMenuItem **pool; /* the root's item pool */ + AxyneMenuItem *items; /* root only: every item of the tree */ + struct AxyneWinMenu *root; + struct AxyneWinMenu *children; /* root only: submenu wrappers */ + struct AxyneWinMenu *next; +}; + +AxyneWinMenu *axyne_win_menu_create(void) { - AxyneMenuItem *pool = NULL; - HMENU menu = axyne_menu_create(); - HMENU recent = axyne_menu_create(); - AxyneDocument *document = axyne_active_visible(state); - UINT document_flags = document != NULL ? MF_ENABLED : MF_GRAYED; - UINT save_flags = document != NULL && axyne_document_can_save(document) - ? MF_ENABLED : MF_GRAYED; - size_t i; - if (menu == NULL || recent == NULL) { - if (menu != NULL) DestroyMenu(menu); - if (recent != NULL) DestroyMenu(recent); - return; + AxyneWinMenu *menu = (AxyneWinMenu *)calloc(1, sizeof(*menu)); + if (menu == NULL) return NULL; + menu->menu = axyne_menu_create(); + if (menu->menu == NULL) { free(menu); return NULL; } + menu->pool = &menu->items; + menu->root = menu; + return menu; +} + +void axyne_win_menu_add(AxyneWinMenu *menu, UINT id, const char *label, + const char *shortcut, unsigned flags) +{ + wchar_t *wide_label, *wide_shortcut; + AxyneMenuItem *item; + if (menu == NULL) return; + wide_label = axyne_wide(label != NULL ? label : ""); + wide_shortcut = shortcut != NULL && shortcut[0] != '\0' ? axyne_wide(shortcut) : NULL; + item = axyne_menu_item(menu->pool, menu->menu, wide_label != NULL ? wide_label : L"", + wide_shortcut); + if (item != NULL) { + item->radio = (flags & AXYNE_WIN_MENU_RADIO) != 0; + AppendMenuW(menu->menu, MF_OWNERDRAW | MF_STRING | + ((flags & AXYNE_WIN_MENU_DISABLED) != 0 ? MF_GRAYED : MF_ENABLED) | + ((flags & AXYNE_WIN_MENU_CHECKED) != 0 ? MF_CHECKED : 0), + id, (LPCWSTR)item); + } + free(wide_label); + free(wide_shortcut); +} + +void axyne_win_menu_separator(AxyneWinMenu *menu) +{ + if (menu != NULL) axyne_menu_separator(menu->menu, menu->pool); +} + +AxyneWinMenu *axyne_win_menu_submenu(AxyneWinMenu *menu, const char *label, + unsigned flags) +{ + AxyneWinMenu *sub; + wchar_t *wide; + if (menu == NULL) return NULL; + sub = (AxyneWinMenu *)calloc(1, sizeof(*sub)); + if (sub == NULL) return NULL; + sub->menu = axyne_menu_create(); + if (sub->menu == NULL) { free(sub); return NULL; } + sub->pool = menu->pool; + sub->root = menu->root; + sub->next = menu->root->children; + menu->root->children = sub; + wide = axyne_wide(label != NULL ? label : ""); + /* The parent owns the submenu handle from here on (DestroyMenu). */ + axyne_menu_submenu(menu->menu, menu->pool, sub->menu, wide != NULL ? wide : L"", + (flags & AXYNE_WIN_MENU_DISABLED) != 0 ? MF_GRAYED : MF_ENABLED); + free(wide); + return sub; +} + +int axyne_win_menu_count(const AxyneWinMenu *menu) +{ + int count; + if (menu == NULL) return 0; + count = GetMenuItemCount(menu->menu); + return count > 0 ? count : 0; +} + +int axyne_win_menu_has_enabled(const AxyneWinMenu *menu) +{ + int i, count = axyne_win_menu_count(menu); + for (i = 0; i < count; ++i) { + UINT state = GetMenuState(menu->menu, (UINT)i, MF_BYPOSITION); + if (state != (UINT)-1 && (state & (MF_SEPARATOR | MF_GRAYED | MF_DISABLED)) == 0) + return 1; } - axyne_menu_add(menu, &pool, AXYNE_CMD_NEW, L"새 파일", L"Ctrl+N", MF_ENABLED); - axyne_menu_add(menu, &pool, AXYNE_CMD_OPEN, L"열기...", L"Ctrl+O", MF_ENABLED); - axyne_menu_add(menu, &pool, AXYNE_CMD_WORKSPACE, L"폴더 열기...", L"Ctrl+Shift+O", MF_ENABLED); - axyne_menu_separator(menu, &pool); + return 0; +} + +void axyne_win_menu_enable_at(AxyneWinMenu *menu, int position, int enabled) +{ + if (menu == NULL || position < 0) return; + EnableMenuItem(menu->menu, (UINT)position, + MF_BYPOSITION | (enabled ? MF_ENABLED : MF_GRAYED)); +} + +UINT axyne_win_menu_track(AxyneWindowState *state, AxyneWinMenu *menu, POINT point, + int return_command) +{ + AxyneWinMenu *child; + UINT picked; + if (state == NULL || menu == NULL || state->main_window == NULL) return 0; + for (child = menu->root->children; child != NULL; child = child->next) + axyne_menu_seal(child->menu); + axyne_menu_seal(menu->menu); + ClientToScreen(state->main_window, &point); + picked = (UINT)TrackPopupMenu(menu->menu, TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | + (return_command ? TPM_RETURNCMD : 0), + point.x, point.y, 0, state->main_window, NULL); + return return_command ? picked : 0; +} + +void axyne_win_menu_destroy(AxyneWinMenu *menu) +{ + AxyneWinMenu *child; + if (menu == NULL || menu->root != menu) return; + DestroyMenu(menu->menu); /* also destroys the attached submenus */ + child = menu->children; + while (child != NULL) { + AxyneWinMenu *next = child->next; + free(child); + child = next; + } + axyne_menu_pool_free(menu->items); + free(menu); +} + +/* File > 최근 항목: the recent files (ids AXYNE_CMD_RECENT_BASE + i). */ +int axyne_win_menu_add_recent_files(AxyneWindowState *state, AxyneWinMenu *menu) +{ + size_t i; + int added = 0; + if (state == NULL || menu == NULL) return 0; for (i = 0; i < state->documents.recent_count; ++i) { wchar_t *path = axyne_wide(state->documents.recent_paths[i]); - if (path != NULL) { - axyne_menu_add(recent, &pool, AXYNE_CMD_RECENT_BASE + (UINT)i, path, - NULL, MF_ENABLED); - free(path); - } + if (path == NULL) continue; + axyne_menu_add(menu->menu, menu->pool, AXYNE_CMD_RECENT_BASE + (UINT)i, path, + NULL, MF_ENABLED); + free(path); + ++added; } - if (state->documents.recent_count == 0) - axyne_menu_add(recent, &pool, 0, L"최근 항목 없음", NULL, MF_GRAYED); - axyne_menu_seal(recent); - axyne_menu_submenu(menu, &pool, recent, L"최근 항목", MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_SAVE, L"저장", L"Ctrl+S", save_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_SAVE_AS, L"다른 이름으로 저장...", - L"Ctrl+Shift+S", save_flags); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_PREFERENCES, L"환경 설정...", NULL, MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_CLOSE, L"닫기", L"Ctrl+W", document_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_EXIT, L"종료", L"Alt+F4", MF_ENABLED); - axyne_menu_track(window, state, 0, menu, pool); + return added; } -static void axyne_edit_popup(HWND window, AxyneWindowState *state) -{ - AxyneMenuItem *pool = NULL; - HMENU menu = axyne_menu_create(); - UINT has_editor = state->editor != NULL && !axyne_empty_state(state) - ? MF_ENABLED : MF_GRAYED; - UINT saved_document_flags = axyne_action_flags(state, AXYNE_CMD_LSP_DEFINITION); - if (menu == NULL) return; - axyne_menu_add(menu, &pool, AXYNE_CMD_UNDO, L"실행 취소", L"Ctrl+Z", - axyne_edit_flags(state, AXYNE_CMD_UNDO)); - axyne_menu_add(menu, &pool, AXYNE_CMD_REDO, L"다시 실행", L"Ctrl+Y", - axyne_edit_flags(state, AXYNE_CMD_REDO)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_CUT, L"잘라내기", L"Ctrl+X", - axyne_edit_flags(state, AXYNE_CMD_CUT)); - axyne_menu_add(menu, &pool, AXYNE_CMD_COPY, L"복사", L"Ctrl+C", - axyne_edit_flags(state, AXYNE_CMD_COPY)); - axyne_menu_add(menu, &pool, AXYNE_CMD_PASTE, L"붙여넣기", L"Ctrl+V", - axyne_edit_flags(state, AXYNE_CMD_PASTE)); - axyne_menu_add(menu, &pool, AXYNE_CMD_SELECT_ALL, L"모두 선택", L"Ctrl+A", - axyne_edit_flags(state, AXYNE_CMD_SELECT_ALL)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_FIND, L"찾기...", L"Ctrl+F", has_editor); - axyne_menu_add(menu, &pool, AXYNE_CMD_REPLACE, L"바꾸기...", L"Ctrl+H", has_editor); - axyne_menu_add(menu, &pool, AXYNE_CMD_SEARCH_FOLDER, L"파일에서 찾기...", - L"Ctrl+Shift+F", MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_GOTO_LINE, L"줄로 이동...", L"Ctrl+G", - axyne_action_flags(state, AXYNE_CMD_GOTO_LINE)); - axyne_menu_add(menu, &pool, AXYNE_CMD_SELECT_LINE, L"줄 선택", NULL, - axyne_action_flags(state, AXYNE_CMD_SELECT_LINE)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_TOGGLE_COMMENT, L"줄 주석 토글", L"Ctrl+/", - axyne_action_flags(state, AXYNE_CMD_TOGGLE_COMMENT)); - axyne_menu_add(menu, &pool, AXYNE_CMD_DUPLICATE_LINE, L"줄 복제", L"Ctrl+D", - axyne_action_flags(state, AXYNE_CMD_DUPLICATE_LINE)); - axyne_menu_add(menu, &pool, AXYNE_CMD_MOVE_LINE_UP, L"줄 위로 이동", L"Alt+\u2191", - axyne_action_flags(state, AXYNE_CMD_MOVE_LINE_UP)); - axyne_menu_add(menu, &pool, AXYNE_CMD_MOVE_LINE_DOWN, L"줄 아래로 이동", L"Alt+\u2193", - axyne_action_flags(state, AXYNE_CMD_MOVE_LINE_DOWN)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_INDENT, L"들여쓰기", L"Tab", - axyne_action_flags(state, AXYNE_CMD_INDENT)); - axyne_menu_add(menu, &pool, AXYNE_CMD_OUTDENT, L"내어쓰기", L"Shift+Tab", - axyne_action_flags(state, AXYNE_CMD_OUTDENT)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_LSP_DEFINITION, L"정의로 이동", - L"Ctrl+Alt+D", saved_document_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_LSP_REFERENCES, L"참조 찾기", - L"Ctrl+Alt+R", saved_document_flags); - axyne_menu_track(window, state, 1, menu, pool); -} - -static void axyne_chrome_popup(HWND window, AxyneWindowState *state, - int menu_index) +/* Popup of menu-bar item `index` built from the registry layout. */ +static void axyne_menu_bar_popup(HWND window, AxyneWindowState *state, int index) { - AxyneMenuItem *pool = NULL; - HMENU menu = axyne_menu_create(); + AxyneWinMenu *menu = axyne_win_menu_create(); + (void)window; if (menu == NULL) return; - if (menu_index == 2) { - UINT output = !state->terminal_panel_selected && !state->problems_panel_selected - ? MF_CHECKED : 0; - UINT shown = state->panel_hidden ? 0 : MF_CHECKED; - axyne_menu_add(menu, &pool, AXYNE_CMD_QUICK_FILE, L"파일 이동...", L"Ctrl+P", - MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_VIEW_EXPLORER, L"탐색기", L"Ctrl+Shift+E", - MF_ENABLED | (!state->explorer_hidden && - state->sidebar_tab == AXYNE_SIDEBAR_TAB_EXPLORER - ? MF_CHECKED : 0)); - axyne_menu_add(menu, &pool, AXYNE_CMD_VIEW_GIT, L"Git 패널", L"Ctrl+Shift+G", - MF_ENABLED | (!state->explorer_hidden && - state->sidebar_tab == AXYNE_SIDEBAR_TAB_GIT - ? MF_CHECKED : 0)); - axyne_menu_add(menu, &pool, AXYNE_CMD_VIEW_PANEL, L"하단 패널", L"Ctrl+J", - MF_ENABLED | shown); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_PANEL_OUTPUT, L"출력", L"Ctrl+Shift+U", - MF_ENABLED | (output & shown)); - axyne_menu_add(menu, &pool, AXYNE_CMD_PANEL_PROBLEMS, L"문제", L"Ctrl+Shift+M", - MF_ENABLED | (state->problems_panel_selected ? shown : 0)); - axyne_menu_add(menu, &pool, AXYNE_CMD_PANEL_TERMINAL, L"터미널", L"Ctrl+`", - MF_ENABLED | (state->terminal_panel_selected ? shown : 0)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_ZOOM_IN, L"확대", L"Ctrl+=", - axyne_action_flags(state, AXYNE_CMD_ZOOM_IN)); - axyne_menu_add(menu, &pool, AXYNE_CMD_ZOOM_OUT, L"축소", L"Ctrl+-", - axyne_action_flags(state, AXYNE_CMD_ZOOM_OUT)); - axyne_menu_add(menu, &pool, AXYNE_CMD_ZOOM_RESET, L"기본 크기", L"Ctrl+0", - axyne_action_flags(state, AXYNE_CMD_ZOOM_RESET)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_WORD_WRAP, L"자동 줄 바꿈", L"Alt+Z", - axyne_action_flags(state, AXYNE_CMD_WORD_WRAP) | - (state->preferences.editor.word_wrap ? MF_CHECKED : 0)); - axyne_menu_add(menu, &pool, AXYNE_CMD_FULLSCREEN, L"전체 화면", L"F11", - MF_ENABLED | (state->fullscreen ? MF_CHECKED : 0)); - } else if (menu_index == 3) { - UINT flags = axyne_active_visible(state) != NULL && state->action_process == NULL && - !axyne_debugger_is_active(&state->debugger) ? MF_ENABLED : MF_GRAYED; - axyne_menu_add(menu, &pool, AXYNE_CMD_BUILD, L"빌드", L"Ctrl+B", flags); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_RUN, L"실행", L"F5", flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_CONFIGURE_RUNNER, L"Runner 설정...", NULL, - MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_CANCEL_BUILD, L"빌드 취소", NULL, - state->active_action == 1 && state->action_process != NULL - ? MF_ENABLED : MF_GRAYED); - } else if (menu_index == 4) { - axyne_refresh_action_controls(state); - axyne_menu_add(menu, &pool, AXYNE_DEBUG_START, L"디버깅 시작", NULL, - IsWindowEnabled(state->debug_start) ? MF_ENABLED : MF_GRAYED); - axyne_menu_add(menu, &pool, AXYNE_CMD_DEBUG_STOP, L"중지", L"Shift+F5", - axyne_action_flags(state, AXYNE_CMD_DEBUG_STOP)); - axyne_menu_add(menu, &pool, AXYNE_DEBUG_PAUSE, L"일시 중지", NULL, - IsWindowEnabled(state->debug_pause) ? MF_ENABLED : MF_GRAYED); - axyne_menu_add(menu, &pool, AXYNE_DEBUG_CONTINUE, L"계속", NULL, - IsWindowEnabled(state->debug_continue) ? MF_ENABLED : MF_GRAYED); - /* Figma separates run control, breakpoints and stepping. */ - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_DEBUG_BREAKPOINT, L"중단점 토글", NULL, - IsWindowEnabled(state->debug_breakpoint) ? MF_ENABLED : MF_GRAYED); - axyne_menu_add(menu, &pool, AXYNE_CMD_DEBUG_CLEAR_BREAKPOINTS, L"모든 중단점 삭제", - L"Ctrl+Shift+F9", - axyne_action_flags(state, AXYNE_CMD_DEBUG_CLEAR_BREAKPOINTS)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_DEBUG_STEP_OVER, L"프로시저 단위 실행", NULL, - IsWindowEnabled(state->debug_step_over) ? MF_ENABLED : MF_GRAYED); - axyne_menu_add(menu, &pool, AXYNE_CMD_DEBUG_STEP_INTO, L"한 단계씩 코드 실행", L"F11", - axyne_action_flags(state, AXYNE_CMD_DEBUG_STEP_INTO)); - axyne_menu_add(menu, &pool, AXYNE_CMD_DEBUG_STEP_OUT, L"프로시저 나가기", L"Shift+F11", - axyne_action_flags(state, AXYNE_CMD_DEBUG_STEP_OUT)); - } else if (menu_index == 5) { - UINT git_flags = state->explorer.root != NULL && state->git_process == NULL && - !state->git_batch_busy - ? MF_ENABLED : MF_GRAYED; - axyne_menu_add(menu, &pool, AXYNE_TERMINAL_START, L"새 터미널", NULL, - !axyne_terminal_view_running(state->shell_view) - ? MF_ENABLED : MF_GRAYED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_PREFERENCES, L"설정...", NULL, MF_ENABLED); - axyne_menu_add(menu, &pool, AXYNE_CMD_WORKSPACE_PREFERENCES, L"작업 영역 설정...", - NULL, state->explorer.root != NULL ? MF_ENABLED : MF_GRAYED); - axyne_menu_add(menu, &pool, AXYNE_CMD_OPEN_PREFERENCES_FILE, L"preferences.json 열기", - NULL, axyne_action_flags(state, AXYNE_CMD_OPEN_PREFERENCES_FILE)); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_STATUS, L"Git 상태", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_DIFF, L"Git 변경 사항", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_STAGE_ALL, L"모두 스테이지", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_UNSTAGE_ALL, L"모두 스테이지 해제", NULL, - git_flags); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_COMMIT, L"Git 커밋...", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_PUSH, L"Git 푸시", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_PULL, L"Git 풀", NULL, git_flags); - axyne_menu_add(menu, &pool, AXYNE_CMD_GIT_LOG, L"Git 기록 보기", NULL, git_flags); - } else { - axyne_menu_add(menu, &pool, AXYNE_CMD_SHORTCUTS, L"키보드 단축키 참조", NULL, - MF_ENABLED); - axyne_menu_add(menu, &pool, AXYNE_CMD_REPORT_ISSUE, L"문제 보고...", NULL, - MF_ENABLED); - axyne_menu_separator(menu, &pool); - axyne_menu_add(menu, &pool, AXYNE_CMD_ABOUT, L"Axyne 정보", NULL, MF_ENABLED); - } - axyne_menu_track(window, state, menu_index, menu, pool); + axyne_win_menu_bar_fill(state, index, menu); + (void)axyne_win_menu_track(state, menu, axyne_menu_anchor(state, index), 0); + axyne_win_menu_destroy(menu); } static void axyne_fill(HDC dc, int left, int top, int right, int bottom, @@ -4572,7 +4199,10 @@ static void axyne_menu_draw(AxyneWindowState *state, const DRAWITEMSTRUCT *draw) cell.right = cell.left + 18; cell.top = top; cell.bottom = top + AXYNE_MENU_ROW; - axyne_text_rect(dc, state->font_bold, AXYNE_POPUP_CHECK, cell, L"\u2713", DT_CENTER); + if (item->radio) + axyne_text_rect(dc, state->font_small, AXYNE_POPUP_CHECK, cell, L"\u25cf", DT_CENTER); + else + axyne_text_rect(dc, state->font_bold, AXYNE_POPUP_CHECK, cell, L"\u2713", DT_CENTER); } cell.left = rect.left + AXYNE_MENU_PAD + 10 + 18 + 8; cell.right = rect.right - AXYNE_MENU_PAD - 10; @@ -4597,10 +4227,13 @@ static void axyne_menu_draw(AxyneWindowState *state, const DRAWITEMSTRUCT *draw) axyne_text_rect(dc, state->ui_font, text, cell, item->label, DT_LEFT); } -static const UINT AXYNE_TOOLBAR_COMMANDS[] = { - AXYNE_CMD_NEW, AXYNE_CMD_OPEN, AXYNE_CMD_SAVE, AXYNE_CMD_UNDO, - AXYNE_CMD_REDO, AXYNE_CMD_BUILD_TARGET, AXYNE_CMD_BUILD, - AXYNE_CMD_RUN, AXYNE_CMD_QUICK_FILE +/* Toolbar slots in layout order (registry commands, so feature overrides + * apply to the toolbar too). */ +static const AxyneCommandId AXYNE_TOOLBAR_COMMANDS[] = { + AXYNE_COMMAND_FILE_NEW, AXYNE_COMMAND_FILE_OPEN, AXYNE_COMMAND_FILE_SAVE, + AXYNE_COMMAND_EDIT_UNDO, AXYNE_COMMAND_EDIT_REDO, AXYNE_COMMAND_BUILD_SELECT_TARGET, + AXYNE_COMMAND_BUILD_BUILD, AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING, + AXYNE_COMMAND_VIEW_QUICK_OPEN }; /* Build-target label, "Debug · x64 (MSVC)", for the active file's language; @@ -4695,19 +4328,15 @@ static void axyne_toolbar_layout(AxyneWindowState *state, int width, free(runner); } -static int axyne_toolbar_enabled(AxyneWindowState *state, UINT command) +/* Toolbar Undo/Redo follow the editor's own undo stack (not a focused text + * field); the other buttons use the command's state. */ +static int axyne_toolbar_enabled(AxyneWindowState *state, AxyneCommandId command) { - if (command == AXYNE_CMD_SAVE) - return axyne_active_visible(state) != NULL && - axyne_document_can_save(axyne_active_visible(state)); - if (command == AXYNE_CMD_UNDO || command == AXYNE_CMD_REDO) + if (command == AXYNE_COMMAND_EDIT_UNDO || command == AXYNE_COMMAND_EDIT_REDO) return state->editor != NULL && !axyne_empty_state(state) && SendMessageA(state->editor, - command == AXYNE_CMD_UNDO ? 2174 : 2016, 0, 0) != 0; - if (command == AXYNE_CMD_BUILD || command == AXYNE_CMD_RUN) - return axyne_active_visible(state) != NULL && state->action_process == NULL && - !axyne_debugger_is_active(&state->debugger); - return 1; + command == AXYNE_COMMAND_EDIT_UNDO ? 2174 : 2016, 0, 0) != 0; + return axyne_win_command_enabled(state, command); } /* Build-target dropdown: Configuration and Architecture groups with the @@ -4855,35 +4484,80 @@ static size_t axyne_max_first_tab(AxyneWindowState *state, int width) return first; } -static const wchar_t *const AXYNE_PANEL_LABELS[3] = { - L"출력", L"문제", L"터미널" +/* Bottom panel tab strip: one entry per AxyneWinPanelTab, painted and + * hit-tested only while enabled (DEBUG_CONSOLE starts disabled). */ +static const wchar_t *const AXYNE_PANEL_LABELS[AXYNE_WIN_PANEL_COUNT] = { + L"출력", L"문제", L"터미널", L"디버그 콘솔" }; +enum { AXYNE_PANEL_TABS_DEFAULT = (1u << AXYNE_WIN_PANEL_OUTPUT) | + (1u << AXYNE_WIN_PANEL_PROBLEMS) | + (1u << AXYNE_WIN_PANEL_TERMINAL) }; + +static int axyne_panel_tab_enabled(const AxyneWindowState *state, int tab) +{ + return tab >= 0 && tab < AXYNE_WIN_PANEL_COUNT && + (state->panel_tabs_enabled & (1u << tab)) != 0; +} + /* The problems tab reads "문제 N" while there are problems. */ static const wchar_t *axyne_panel_tab_label(const AxyneWindowState *state, int index) { - if (index == 1 && state->problems_tab_label[0] != L'\0') + if (index == AXYNE_WIN_PANEL_PROBLEMS && state->problems_tab_label[0] != L'\0') return state->problems_tab_label; return AXYNE_PANEL_LABELS[index]; } /* Figma panel tabs: items start 8px past the explorer column, 6px padding - * either side of the 11px label, 2px gaps. Paint and hit-test share this. */ + * either side of the 11px label, 2px gaps. Paint and hit-test share this. + * A disabled tab has an empty rectangle and takes no room. */ static RECT axyne_panel_tab_rect(AxyneWindowState *state, int index, int panel_top) { RECT rect = {0, panel_top, 0, panel_top + AXYNE_UI_PANEL_HEADER}; int x = axyne_sidebar_width(state) + 8; int i; - for (i = 0; i <= index && i < 3; ++i) { - int width = 12 + axyne_measure_text(state->font_small, - axyne_panel_tab_label(state, i)); + for (i = 0; i <= index && i < AXYNE_WIN_PANEL_COUNT; ++i) { + int width; + if (!axyne_panel_tab_enabled(state, i)) continue; + width = 12 + axyne_measure_text(state->font_small, axyne_panel_tab_label(state, i)); if (i == index) { rect.left = x; rect.right = x + width; } x += width + 2; } return rect; } +/* Area below the tab strip; 0 while the panel is hidden. */ +static int axyne_panel_body_rect(const AxyneWindowState *state, RECT *body) +{ + int status_top = state->client_height - AXYNE_STATUS; + int panel = axyne_panel_height(state); + body->left = axyne_sidebar_width(state); + body->top = status_top - panel + AXYNE_UI_PANEL_HEADER; + body->right = state->client_width; + body->bottom = status_top; + if (body->top > body->bottom) body->top = body->bottom; + return !state->panel_hidden && panel > 0; +} + +/* Shows the panel with `tab` selected; `focus` moves the keyboard to the + * tab's control (problems list, editor for the output). Selecting the + * terminal tab with `focus` starts the native shell when none runs and + * focuses its grid (the old Ctrl+`/tab click behaviour); without `focus` the + * grid is only shown. */ +static void axyne_select_panel_tab(HWND window, AxyneWindowState *state, + AxyneWinPanelTab tab, int focus) +{ + if (!axyne_panel_tab_enabled(state, (int)tab)) return; + state->panel_tab = tab; + state->panel_hidden = 0; + axyne_layout(window, state); + if (!focus) return; + if (tab == AXYNE_WIN_PANEL_TERMINAL) axyne_start_terminal_panel(window, state); + else if (tab == AXYNE_WIN_PANEL_OUTPUT) { if (state->editor != NULL) SetFocus(state->editor); } + else SetFocus(window); +} + static size_t axyne_visible_tabs(AxyneWindowState *state, int width) { int available = width > axyne_sidebar_width(state) ? width - axyne_sidebar_width(state) : 0; @@ -5397,6 +5071,7 @@ static void axyne_palette_text_changed(HWND window, AxyneWindowState *state) if (text == NULL) return; (void)axyne_palette_ctl_set_input(&state->palette, text); free(text); + axyne_win_palette_commands(state); axyne_palette_refresh(window, state); } @@ -5416,47 +5091,12 @@ static void axyne_palette_goto(AxyneWindowState *state, size_t line, size_t colu SetFocus(state->editor); } -static UINT axyne_palette_command_message(AxynePaletteCommandId id) -{ - switch (id) { - case AXYNE_PALETTE_COMMAND_NEW_FILE: return AXYNE_CMD_NEW; - case AXYNE_PALETTE_COMMAND_OPEN_FILE: return AXYNE_CMD_OPEN; - case AXYNE_PALETTE_COMMAND_OPEN_FOLDER: return AXYNE_CMD_WORKSPACE; - case AXYNE_PALETTE_COMMAND_SAVE: return AXYNE_CMD_SAVE; - case AXYNE_PALETTE_COMMAND_SAVE_AS: return AXYNE_CMD_SAVE_AS; - case AXYNE_PALETTE_COMMAND_CLOSE_TAB: return AXYNE_CMD_CLOSE; - case AXYNE_PALETTE_COMMAND_FIND: return AXYNE_CMD_FIND; - case AXYNE_PALETTE_COMMAND_REPLACE: return AXYNE_CMD_REPLACE; - case AXYNE_PALETTE_COMMAND_BUILD: return AXYNE_CMD_BUILD; - case AXYNE_PALETTE_COMMAND_RUN: return AXYNE_CMD_RUN; - case AXYNE_PALETTE_COMMAND_START_DEBUGGING: return AXYNE_DEBUG_START; - case AXYNE_PALETTE_COMMAND_GIT_STATUS: return AXYNE_CMD_GIT_STATUS; - case AXYNE_PALETTE_COMMAND_GIT_DIFF: return AXYNE_CMD_GIT_DIFF; - case AXYNE_PALETTE_COMMAND_GIT_STAGE_ALL: return AXYNE_CMD_GIT_STAGE_ALL; - case AXYNE_PALETTE_COMMAND_GIT_UNSTAGE_ALL: return AXYNE_CMD_GIT_UNSTAGE_ALL; - case AXYNE_PALETTE_COMMAND_PREFERENCES: return AXYNE_CMD_PREFERENCES; - case AXYNE_PALETTE_COMMAND_WORKSPACE_SETTINGS: return AXYNE_CMD_WORKSPACE_PREFERENCES; - case AXYNE_PALETTE_COMMAND_PANEL_OUTPUT: return AXYNE_CMD_PANEL_OUTPUT; - case AXYNE_PALETTE_COMMAND_PANEL_PROBLEMS: return AXYNE_CMD_PANEL_PROBLEMS; - case AXYNE_PALETTE_COMMAND_PANEL_TERMINAL: return AXYNE_CMD_PANEL_TERMINAL; - case AXYNE_PALETTE_COMMAND_CONFIGURE_RUNNER: return AXYNE_CMD_CONFIGURE_RUNNER; - default: return 0; - } -} - +/* Palette command ids map onto the registry (axyne_command_from_palette), so + * a palette row runs exactly what the menu item and the shortcut run. */ static void axyne_palette_run_command(HWND window, AxyneWindowState *state, AxynePaletteCommandId id) { - UINT message = axyne_palette_command_message(id); - if (id == AXYNE_PALETTE_COMMAND_CLEAR_OUTPUT) { - if (state->terminal_output != NULL) SetWindowTextW(state->terminal_output, L""); - return; - } - if (message == 0) return; - if ((id == AXYNE_PALETTE_COMMAND_SAVE || id == AXYNE_PALETTE_COMMAND_SAVE_AS || - id == AXYNE_PALETTE_COMMAND_CLOSE_TAB) && axyne_active_visible(state) == NULL) return; - if ((id == AXYNE_PALETTE_COMMAND_BUILD || id == AXYNE_PALETTE_COMMAND_RUN) && - !axyne_toolbar_enabled(state, message)) return; + AxyneCommandId command = axyne_command_from_palette((int)id); if (id >= AXYNE_PALETTE_COMMAND_GIT_STATUS && id <= AXYNE_PALETTE_COMMAND_GIT_UNSTAGE_ALL && (state->explorer.root == NULL || state->git_process != NULL || state->git_batch_busy)) { @@ -5466,7 +5106,7 @@ static void axyne_palette_run_command(HWND window, AxyneWindowState *state, MB_OK | MB_ICONINFORMATION); return; } - SendMessageW(window, WM_COMMAND, message, 0); + if (command != AXYNE_COMMAND_NONE) (void)axyne_win_execute(state, command); } /* Enter or a click: closes the palette and runs what the controller returns. */ @@ -5487,6 +5127,10 @@ static void axyne_palette_execute(HWND window, AxyneWindowState *state, size_t r axyne_open_document(window, state, action.path); else if (action.kind == AXYNE_PALETTE_ACTION_GOTO) axyne_palette_goto(state, action.line, action.column); + else if (action.kind == AXYNE_PALETTE_ACTION_COMMAND && + (int)action.command >= AXYNE_WIN_PALETTE_REGISTRY_PAYLOAD) + (void)axyne_win_execute(state, (AxyneCommandId)((int)action.command - + AXYNE_WIN_PALETTE_REGISTRY_PAYLOAD)); else if (action.kind == AXYNE_PALETTE_ACTION_COMMAND) axyne_palette_run_command(window, state, action.command); axyne_palette_action_destroy(&action); @@ -5741,6 +5385,7 @@ static void axyne_palette_open(HWND window, AxyneWindowState *state, const char return; } free(paths); + axyne_win_palette_commands(state); state->palette_wheel_remainder = 0; axyne_palette_set_text(state, initial); axyne_palette_refresh(window, state); @@ -5777,22 +5422,6 @@ static void axyne_palette_destroy_ui(AxyneWindowState *state) state->palette_edit_brush = NULL; } -/* Menu label for the quick-file entry, with the shortcut from the palette - * command table (Ctrl+P on Windows). */ -static void axyne_palette_menu_label(wchar_t *buffer, size_t capacity, - const wchar_t *title) -{ - const AxynePaletteCommand *command = - axyne_palette_command(AXYNE_PALETTE_COMMAND_QUICK_FILE); - char shortcut[64] = ""; - wchar_t *wide; - if (command != NULL) - (void)axyne_palette_format_shortcut(&command->shortcut, 0, shortcut, sizeof(shortcut)); - wide = axyne_wide(shortcut); - (void)swprintf_s(buffer, capacity, L"%ls\t%ls", title, wide != NULL ? wide : L""); - free(wide); -} - static void axyne_open_scintilla(AxyneWindowState *state, HWND parent, HINSTANCE instance) { @@ -8073,7 +7702,7 @@ enum { static int axyne_problems_visible(const AxyneWindowState *state) { - return !state->panel_hidden && state->problems_panel_selected; + return !state->panel_hidden && state->panel_tab == AXYNE_WIN_PANEL_PROBLEMS; } /* The list area: the whole panel below its 32px tab header. */ @@ -8856,6 +8485,11 @@ static void axyne_layout(HWND window, AxyneWindowState *state) } if (state->terminal_output != NULL) { int terminal_top = bottom_top + AXYNE_UI_PANEL_HEADER + 6; + /* Output tab: build/run/debugger output; Terminal tab: the native + * shell grid (shell_view). Other tabs belong to their owners. */ + int terminal_selected = state->panel_tab == AXYNE_WIN_PANEL_TERMINAL; + int output_shown = !state->panel_hidden && + state->panel_tab == AXYNE_WIN_PANEL_OUTPUT; int terminal_bottom = status_top - 6; int terminal_width = width - axyne_sidebar_width(state) - 32; if (terminal_width < 0) terminal_width = 0; @@ -8869,8 +8503,9 @@ static void axyne_layout(HWND window, AxyneWindowState *state) SetWindowPos(state->terminal_stop, NULL, width - 116, bottom_top + 5, 48, 22, SWP_NOZORDER | SWP_NOACTIVATE); ShowWindow(state->terminal_output, - state->terminal_panel_selected || state->problems_panel_selected || state->panel_hidden ? SW_HIDE : SW_SHOW); - ShowWindow(state->shell_view, state->terminal_panel_selected && !state->panel_hidden ? SW_SHOW : SW_HIDE); + output_shown ? SW_SHOW : SW_HIDE); + ShowWindow(state->shell_view, + terminal_selected && !state->panel_hidden ? SW_SHOW : SW_HIDE); ShowWindow(state->terminal_start, state->panel_hidden ? SW_HIDE : SW_SHOW); ShowWindow(state->terminal_stop, state->panel_hidden ? SW_HIDE : SW_SHOW); ShowWindow(state->debug_start, SW_HIDE); @@ -8887,6 +8522,12 @@ static void axyne_layout(HWND window, AxyneWindowState *state) axyne_problems_update_filter(state); axyne_problems_clamp_scroll(state); axyne_git_panel_place_controls(state); + { + RECT body; + int visible = axyne_panel_body_rect(state, &body); + axyne_win_hooks_panel_layout(state, state->panel_tab, body, visible); + } + axyne_win_hooks_layout(state, width, height); axyne_palette_refresh(window, state); InvalidateRect(window, NULL, FALSE); } @@ -8970,6 +8611,50 @@ static void axyne_paint_empty_guide(HDC dc, AxyneWindowState *state, int left, if (saved != 0) RestoreDC(dc, saved); } +/* Base status bar text: action state (left), LSP status (middle), caret + * position and encoding (right). */ +static void axyne_paint_status(HDC dc, AxyneWindowState *state, int status_top, + int width, int height) +{ + { + wchar_t status[96]; + if (state->last_exit_failed) { + (void)swprintf_s(status, 96, L"✗ 실행 실패 (exit %d)", + state->last_exit_code); + } else if (state->active_action != 0) { + (void)swprintf_s(status, 96, L"● 실행 중"); + } else if (state->has_exit_status) { + (void)swprintf_s(status, 96, L"✓ 실행 완료 (exit 0)"); + } else { + (void)swprintf_s(status, 96, L"준비"); + } + RECT rect = {12, status_top, axyne_sidebar_size(state) - 8, height}; + axyne_text_rect(dc, state->ui_font, + state->last_exit_failed ? AXYNE_ACCENT : AXYNE_MUTED, rect, status, DT_LEFT); + } + if (state->status_text[0] != '\0' || state->lsp_status[0] != '\0') { + /* A transient status message (chord hint, feature notices) wins over + * the LSP status line. */ + wchar_t *lsp_status = axyne_wide(state->status_text[0] != '\0' + ? state->status_text : state->lsp_status); + if (lsp_status != NULL) { + RECT rect = {axyne_sidebar_size(state) + 8, status_top, width - 250, height}; + axyne_text_rect(dc, state->ui_font, AXYNE_MUTED, rect, lsp_status, DT_LEFT); + free(lsp_status); + } + } + if (state->editor != NULL && axyne_active_visible(state) != NULL) { + wchar_t position[96]; + LRESULT caret = SendMessageA(state->editor, SCI_GETCURRENTPOS, 0, 0); + LRESULT line = SendMessageA(state->editor, SCI_LINEFROMPOSITION, (WPARAM)caret, 0); + LRESULT column = SendMessageA(state->editor, SCI_GETCOLUMN, (WPARAM)caret, 0); + RECT rect = {width - 246, status_top, width - 12, height}; + (void)swprintf_s(position, 96, L"줄 %lld, 열 %lld UTF-8", + (long long)line + 1, (long long)column + 1); + axyne_text_rect(dc, state->ui_font, AXYNE_MUTED, rect, position, DT_RIGHT); + } +} + static void axyne_paint_shell(HWND window, AxyneWindowState *state) { PAINTSTRUCT paint; @@ -9081,7 +8766,7 @@ static void axyne_paint_shell(HWND window, AxyneWindowState *state) state->build_menu_open); } { - COLORREF color = axyne_toolbar_enabled(state, AXYNE_CMD_BUILD) + COLORREF color = axyne_toolbar_enabled(state, AXYNE_COMMAND_BUILD_BUILD) ? AXYNE_TEXT : AXYNE_MUTED; axyne_round_fill(dc, rects[6].left, rects[6].top, rects[6].right, rects[6].bottom, 3, AXYNE_BUTTON_BG, AXYNE_BUTTON_BG); @@ -9091,7 +8776,7 @@ static void axyne_paint_shell(HWND window, AxyneWindowState *state) { RECT rect = rects[7]; RECT part = {rect.left + 14, rect.top, rect.right - 14, rect.bottom}; - COLORREF color = axyne_toolbar_enabled(state, AXYNE_CMD_RUN) + COLORREF color = axyne_toolbar_enabled(state, AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING) ? AXYNE_RUN_TEXT : axyne_blend(AXYNE_RUN_TEXT, AXYNE_ACCENT, 45); axyne_round_fill(dc, rect.left, rect.top, rect.right, rect.bottom, 4, AXYNE_ACCENT, AXYNE_ACCENT); @@ -9167,57 +8852,31 @@ static void axyne_paint_shell(HWND window, AxyneWindowState *state) } } if (!state->panel_hidden) { - size_t i; - for (i = 0; i < 3; ++i) { - int selected = (i == 0 && !state->terminal_panel_selected && !state->problems_panel_selected) || - (i == 1 && state->problems_panel_selected) || - (i == 2 && state->terminal_panel_selected); - RECT rect = axyne_panel_tab_rect(state, (int)i, bottom_top); - RECT label = {rect.left + 6, rect.top, rect.right - 6, rect.bottom}; + int i; + RECT body; + for (i = 0; i < AXYNE_WIN_PANEL_COUNT; ++i) { + int selected = i == (int)state->panel_tab; + RECT rect, label; + if (!axyne_panel_tab_enabled(state, i)) continue; + rect = axyne_panel_tab_rect(state, i, bottom_top); + label = (RECT){rect.left + 6, rect.top, rect.right - 6, rect.bottom}; /* Figma keeps every label #737780; only the underline marks the * selected tab. Custom palettes still brighten the selection. */ axyne_text_rect(dc, state->font_small, selected && !AXYNE_REFERENCE ? AXYNE_TEXT : AXYNE_MUTED, - label, axyne_panel_tab_label(state, (int)i), DT_LEFT); + label, axyne_panel_tab_label(state, i), DT_LEFT); if (selected) axyne_fill(dc, rect.left, rect.bottom - 3, rect.right, rect.bottom, AXYNE_INDICATOR); } + (void)axyne_panel_body_rect(state, &body); + if (!axyne_win_hooks_panel_paint(state, dc, state->panel_tab, body) && + axyne_problems_visible(state)) + axyne_problems_paint(dc, state); } - if (axyne_problems_visible(state)) - axyne_problems_paint(dc, state); { - wchar_t status[96]; - if (state->last_exit_failed) { - (void)swprintf_s(status, 96, L"✗ 실행 실패 (exit %d)", - state->last_exit_code); - } else if (state->active_action != 0) { - (void)swprintf_s(status, 96, L"● 실행 중"); - } else if (state->has_exit_status) { - (void)swprintf_s(status, 96, L"✓ 실행 완료 (exit 0)"); - } else { - (void)swprintf_s(status, 96, L"준비"); - } - RECT rect = {12, status_top, axyne_sidebar_size(state) - 8, height}; - axyne_text_rect(dc, state->ui_font, - state->last_exit_failed ? AXYNE_ACCENT : AXYNE_MUTED, rect, status, DT_LEFT); - } - if (state->lsp_status[0] != '\0') { - wchar_t *lsp_status = axyne_wide(state->lsp_status); - if (lsp_status != NULL) { - RECT rect = {axyne_sidebar_size(state) + 8, status_top, width - 250, height}; - axyne_text_rect(dc, state->ui_font, AXYNE_MUTED, rect, lsp_status, DT_LEFT); - free(lsp_status); - } - } - if (state->editor != NULL && axyne_active_visible(state) != NULL) { - wchar_t position[96]; - LRESULT caret = SendMessageA(state->editor, SCI_GETCURRENTPOS, 0, 0); - LRESULT line = SendMessageA(state->editor, SCI_LINEFROMPOSITION, (WPARAM)caret, 0); - LRESULT column = SendMessageA(state->editor, SCI_GETCOLUMN, (WPARAM)caret, 0); - RECT rect = {width - 246, status_top, width - 12, height}; - (void)swprintf_s(position, 96, L"줄 %lld, 열 %lld UTF-8", - (long long)line + 1, (long long)column + 1); - axyne_text_rect(dc, state->ui_font, AXYNE_MUTED, rect, position, DT_RIGHT); + RECT status_area = {0, status_top, width, height}; + if (!axyne_win_hooks_status_paint(state, dc, status_area)) + axyne_paint_status(dc, state, status_top, width, height); } if (axyne_empty_state(state)) @@ -9236,11 +8895,623 @@ static void axyne_paint_shell(HWND window, AxyneWindowState *state) EndPaint(window, &paint); } +/* Runs a command by its legacy id (toolbar, explorer context menu, recent + * files, panel buttons and the registry commands mapped onto it by + * axyne_win_base_command). */ +static void axyne_legacy_command(HWND window, AxyneWindowState *state, UINT command) +{ + /* Edit commands go to a focused text field first, which works + * without a document too. */ + if (axyne_edit_field_focused(state) != NULL && + axyne_edit_command(state, command)) + return; + /* Document commands have nothing to act on in the empty state. */ + if (axyne_empty_state(state) && axyne_command_needs_document(command)) + return; + if (command == AXYNE_CMD_PANEL_OUTPUT || command == AXYNE_CMD_PANEL_TERMINAL || + command == AXYNE_CMD_PANEL_PROBLEMS) { + axyne_select_panel_tab(window, state, + command == AXYNE_CMD_PANEL_TERMINAL ? AXYNE_WIN_PANEL_TERMINAL + : command == AXYNE_CMD_PANEL_PROBLEMS ? AXYNE_WIN_PANEL_PROBLEMS + : AXYNE_WIN_PANEL_OUTPUT, 1); + } + else if (axyne_action_command(window, state, command)) { /* handled */ } + else if (command == AXYNE_CMD_EXIT) PostMessageW(window, WM_CLOSE, 0, 0); + else if (command == AXYNE_CMD_ABOUT) + MessageBoxW(window, + L"Axyne\nLightweight native IDE\n\nhttps://github.com/team-native/Axyne", + L"About Axyne", MB_OK | MB_ICONINFORMATION); + else if (command == AXYNE_CMD_BUILD) axyne_start_action(window, state, 0); + else if (command == AXYNE_CMD_RUN) axyne_start_action(window, state, 1); + else if (command == AXYNE_CMD_BUILD_TARGET) axyne_build_target_popup(window, state); + else if (command == AXYNE_CMD_CONFIGURE_RUNNER) { + (void)axyne_configure_runner(window, &state->action_runner); + InvalidateRect(window, NULL, FALSE); + } + else if (command == AXYNE_CMD_PREFERENCES) + (void)axyne_preferences_dialog(window, state, 0); + else if (command == AXYNE_CMD_WORKSPACE_PREFERENCES) { + if (state->workspace_preferences_path == NULL) + MessageBoxA(window, "Open a workspace folder before editing workspace settings.", "Axyne - Preferences", MB_OK | MB_ICONINFORMATION); + else (void)axyne_preferences_dialog(window, state, 1); + } + else if (command == AXYNE_TERMINAL_START) axyne_start_terminal_panel(window, state); + else if (command == AXYNE_TERMINAL_STOP) axyne_terminal_stop(state); + else if (command == AXYNE_CMD_CANCEL_BUILD) { + if (state->action_process != NULL) + (void)axyne_process_terminate(state->action_process, NULL); + } + else if (command == AXYNE_DEBUG_START) axyne_windows_debugger_start(window, state); + else if (command == AXYNE_DEBUG_PAUSE) + axyne_debugger_command_ui(state, AXYNE_DEBUGGER_PAUSE); + else if (command == AXYNE_DEBUG_CONTINUE) + axyne_debugger_command_ui(state, AXYNE_DEBUGGER_CONTINUE); + else if (command == AXYNE_DEBUG_STEP_OVER) + axyne_debugger_command_ui(state, AXYNE_DEBUGGER_STEP_OVER); + else if (command == AXYNE_DEBUG_BREAKPOINT) + axyne_debugger_toggle_current_breakpoint(state); + else if (command == AXYNE_CMD_NEW) axyne_new_document(window, state); + else if (command == AXYNE_CMD_OPEN) axyne_open_document(window, state, NULL); + else if (command == AXYNE_CMD_SAVE) (void)axyne_save_active(window, state); + else if (command == AXYNE_CMD_SAVE_AS) { + char *path = NULL; + if (axyne_active(state) == NULL || + !axyne_document_can_save(axyne_active(state)) || + !axyne_editor_ready_for_save(window, state)) return; + if (axyne_choose_path(window, 1, &path)) { + if (!axyne_capture_editor(state)) { + free(path); + return; + } + AxyneError error; + AxyneDocument *active = axyne_active(state); + char *old_path = active != NULL && !active->is_untitled && + active->path != NULL ? _strdup(active->path) : NULL; + AxyneStatus status = axyne_documents_save_as(&state->documents, + state->documents.active_index, path, &error); + free(path); + if (status == AXYNE_STATUS_OK) { + SendMessageA(state->editor, SCI_SETSAVEPOINT, 0, 0); + axyne_after_save_as(state, old_path); + axyne_refresh_outline(state); + axyne_update_title(window, state); + axyne_refresh_action_controls(state); + axyne_win_hooks_document_saved(state, axyne_active(state)); + } else MessageBoxA(window, error.message, "Axyne - Save failed", + MB_OK | MB_ICONERROR); + free(old_path); + } + } else if (command == AXYNE_CMD_CLOSE) + axyne_close_tab(window, state, state->documents.active_index); + else if (command == AXYNE_CMD_FIND) axyne_find(window, state, 0, 0); + else if (command == AXYNE_CMD_REPLACE) axyne_find(window, state, 1, 0); + else if (command == AXYNE_CMD_SEARCH_FOLDER) axyne_search_folder(window, state, 0); + else if (command == AXYNE_CMD_QUICK_FILE) axyne_search_folder(window, state, 1); + else if (command == AXYNE_CMD_GIT_STATUS) + axyne_git_start(window, state, "No Git status output.", command); + else if (command == AXYNE_CMD_GIT_DIFF) + axyne_git_start(window, state, "No Git differences.", command); + else if (command == AXYNE_CMD_GIT_STAGE_ALL) + axyne_git_start(window, state, "All workspace changes staged.", command); + else if (command == AXYNE_CMD_GIT_UNSTAGE_ALL) + axyne_git_start(window, state, "All changes unstaged.", command); + else if (command == AXYNE_CMD_GIT_COMMIT) + axyne_git_commit_command(window, state); + else if (command == AXYNE_CMD_GIT_PUSH) + axyne_git_batch_start(window, state, command, NULL, 0, 0); + else if (command == AXYNE_CMD_GIT_PULL) + axyne_git_batch_start(window, state, command, NULL, 0, 0); + else if (command == AXYNE_CMD_GIT_LOG) + axyne_git_batch_start(window, state, command, NULL, 0, 0); + else if (command == AXYNE_CMD_VIEW_GIT) + axyne_git_panel_toggle_view(window, state); + else if (command == AXYNE_CMD_LSP_DEFINITION) axyne_lsp_navigate(window, state, 0); + else if (command == AXYNE_CMD_LSP_REFERENCES) axyne_lsp_navigate(window, state, 1); + else if (command == AXYNE_CMD_UNDO || command == AXYNE_CMD_REDO || + command == AXYNE_CMD_CUT || command == AXYNE_CMD_COPY || + command == AXYNE_CMD_PASTE || command == AXYNE_CMD_SELECT_ALL) + (void)axyne_edit_command(state, command); + else if (command >= AXYNE_CMD_WORKSPACE && command <= AXYNE_CMD_EXPLORER_REMOVE) + axyne_workspace_operation(window, state, command); + else if (command >= AXYNE_CMD_RECENT_BASE && + command - AXYNE_CMD_RECENT_BASE < state->documents.recent_count) + axyne_open_document(window, state, + state->documents.recent_paths[command - AXYNE_CMD_RECENT_BASE]); +} + +/* ---- registry commands implemented by the base -------------------------- + * The enabled/checked rules of the former hand-written menus, keyed by + * registry id. A command without a case here (and without a feature that + * implements it) is shown disabled and never runs. Every case of the state + * function has a case in axyne_win_base_command (checked by + * tests/test_menu_consistency.c). */ + +static int axyne_git_available(const AxyneWindowState *state) +{ + return state->explorer.root != NULL && state->git_process == NULL && + !state->git_batch_busy; +} + +int axyne_win_base_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out) +{ + const AxyneDocument *document = axyne_active_visible(state); + int debugger_active = axyne_debugger_is_active(&state->debugger); + int shown = !state->panel_hidden; + out->enabled = 1; + out->checked = 0; + switch (id) { + case AXYNE_COMMAND_FILE_NEW: case AXYNE_COMMAND_FILE_OPEN: + case AXYNE_COMMAND_FILE_OPEN_FOLDER: case AXYNE_COMMAND_FILE_EXIT: + case AXYNE_COMMAND_EDIT_FIND_IN_FILES: case AXYNE_COMMAND_VIEW_COMMAND_PALETTE: + case AXYNE_COMMAND_VIEW_QUICK_OPEN: case AXYNE_COMMAND_VIEW_CLEAR_OUTPUT: + case AXYNE_COMMAND_BUILD_SELECT_TARGET: case AXYNE_COMMAND_BUILD_CONFIGURE_RUNNER: + case AXYNE_COMMAND_TOOLS_SETTINGS: case AXYNE_COMMAND_HELP_KEYBOARD_SHORTCUTS: + case AXYNE_COMMAND_HELP_REPORT_ISSUE: case AXYNE_COMMAND_HELP_ABOUT: + return 1; + case AXYNE_COMMAND_FILE_SAVE: case AXYNE_COMMAND_FILE_SAVE_AS: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_SAVE); + return 1; + case AXYNE_COMMAND_FILE_CLOSE: + out->enabled = document != NULL; + return 1; + case AXYNE_COMMAND_EDIT_UNDO: out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_UNDO); return 1; + case AXYNE_COMMAND_EDIT_REDO: out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_REDO); return 1; + case AXYNE_COMMAND_EDIT_CUT: out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_CUT); return 1; + case AXYNE_COMMAND_EDIT_COPY: out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_COPY); return 1; + case AXYNE_COMMAND_EDIT_PASTE: out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_PASTE); return 1; + case AXYNE_COMMAND_EDIT_SELECT_ALL: + out->enabled = axyne_edit_command_enabled(state, AXYNE_CMD_SELECT_ALL); + return 1; + case AXYNE_COMMAND_EDIT_FIND: case AXYNE_COMMAND_EDIT_REPLACE: + out->enabled = state->editor != NULL && !axyne_empty_state(state); + return 1; + case AXYNE_COMMAND_EDIT_GO_TO_LINE: out->enabled = axyne_action_enabled(state, AXYNE_CMD_GOTO_LINE); return 1; + case AXYNE_COMMAND_EDIT_SELECT_LINE: out->enabled = axyne_action_enabled(state, AXYNE_CMD_SELECT_LINE); return 1; + case AXYNE_COMMAND_EDIT_TOGGLE_LINE_COMMENT: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_TOGGLE_COMMENT); + return 1; + case AXYNE_COMMAND_EDIT_DUPLICATE_LINE: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_DUPLICATE_LINE); + return 1; + case AXYNE_COMMAND_EDIT_MOVE_LINE_UP: out->enabled = axyne_action_enabled(state, AXYNE_CMD_MOVE_LINE_UP); return 1; + case AXYNE_COMMAND_EDIT_MOVE_LINE_DOWN: out->enabled = axyne_action_enabled(state, AXYNE_CMD_MOVE_LINE_DOWN); return 1; + case AXYNE_COMMAND_EDIT_INDENT: out->enabled = axyne_action_enabled(state, AXYNE_CMD_INDENT); return 1; + case AXYNE_COMMAND_EDIT_OUTDENT: out->enabled = axyne_action_enabled(state, AXYNE_CMD_OUTDENT); return 1; + case AXYNE_COMMAND_EDIT_GO_TO_DEFINITION: case AXYNE_COMMAND_EDIT_FIND_REFERENCES: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_LSP_DEFINITION); + return 1; + case AXYNE_COMMAND_EDIT_GO_TO_SYMBOL: + out->enabled = state->editor != NULL; + return 1; + case AXYNE_COMMAND_VIEW_EXPLORER: + out->checked = !state->explorer_hidden && state->sidebar_tab == AXYNE_SIDEBAR_TAB_EXPLORER; + return 1; + case AXYNE_COMMAND_VIEW_GIT_PANEL: + out->checked = !state->explorer_hidden && state->sidebar_tab == AXYNE_SIDEBAR_TAB_GIT; + return 1; + case AXYNE_COMMAND_VIEW_PANEL: + out->checked = shown; + return 1; + case AXYNE_COMMAND_VIEW_OUTPUT: + out->checked = shown && state->panel_tab == AXYNE_WIN_PANEL_OUTPUT; + return 1; + case AXYNE_COMMAND_VIEW_PROBLEMS: + out->checked = shown && state->panel_tab == AXYNE_WIN_PANEL_PROBLEMS; + return 1; + case AXYNE_COMMAND_VIEW_TERMINAL: + out->checked = shown && state->panel_tab == AXYNE_WIN_PANEL_TERMINAL; + return 1; + case AXYNE_COMMAND_VIEW_ZOOM_IN: out->enabled = axyne_action_enabled(state, AXYNE_CMD_ZOOM_IN); return 1; + case AXYNE_COMMAND_VIEW_ZOOM_OUT: out->enabled = axyne_action_enabled(state, AXYNE_CMD_ZOOM_OUT); return 1; + case AXYNE_COMMAND_VIEW_ZOOM_RESET: out->enabled = axyne_action_enabled(state, AXYNE_CMD_ZOOM_RESET); return 1; + case AXYNE_COMMAND_VIEW_WORD_WRAP: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_WORD_WRAP); + out->checked = state->preferences.editor.word_wrap != 0; + return 1; + case AXYNE_COMMAND_VIEW_FULL_SCREEN: + out->checked = state->fullscreen != 0; + return 1; + case AXYNE_COMMAND_BUILD_BUILD: case AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING: + out->enabled = document != NULL && state->action_process == NULL && !debugger_active; + return 1; + case AXYNE_COMMAND_BUILD_CANCEL: + out->enabled = state->active_action == 1 && state->action_process != NULL; + return 1; + case AXYNE_COMMAND_DEBUG_START: + out->enabled = axyne_debugger_can_start(debugger_active, state->action_process != NULL, + document != NULL && !document->is_untitled && document->path != NULL && + !document->is_dirty); + return 1; + case AXYNE_COMMAND_DEBUG_STOP: out->enabled = axyne_action_enabled(state, AXYNE_CMD_DEBUG_STOP); return 1; + case AXYNE_COMMAND_DEBUG_PAUSE: case AXYNE_COMMAND_DEBUG_CONTINUE: + case AXYNE_COMMAND_DEBUG_STEP_OVER: + out->enabled = axyne_debugger_can_control(debugger_active); + return 1; + case AXYNE_COMMAND_DEBUG_STEP_INTO: out->enabled = axyne_action_enabled(state, AXYNE_CMD_DEBUG_STEP_INTO); return 1; + case AXYNE_COMMAND_DEBUG_STEP_OUT: out->enabled = axyne_action_enabled(state, AXYNE_CMD_DEBUG_STEP_OUT); return 1; + case AXYNE_COMMAND_DEBUG_TOGGLE_BREAKPOINT: + out->enabled = axyne_debugger_can_toggle_breakpoint( + document != NULL && !document->is_untitled && document->path != NULL); + return 1; + case AXYNE_COMMAND_DEBUG_CLEAR_BREAKPOINTS: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_DEBUG_CLEAR_BREAKPOINTS); + return 1; + case AXYNE_COMMAND_TERMINAL_NEW: + out->enabled = !axyne_terminal_view_running(state->shell_view); + return 1; + case AXYNE_COMMAND_TOOLS_WORKSPACE_SETTINGS: + out->enabled = state->explorer.root != NULL; + return 1; + case AXYNE_COMMAND_TOOLS_OPEN_SETTINGS_JSON: + out->enabled = axyne_action_enabled(state, AXYNE_CMD_OPEN_PREFERENCES_FILE); + return 1; + case AXYNE_COMMAND_GIT_STATUS: case AXYNE_COMMAND_GIT_DIFF: + case AXYNE_COMMAND_GIT_STAGE_ALL: case AXYNE_COMMAND_GIT_UNSTAGE_ALL: + case AXYNE_COMMAND_GIT_COMMIT: case AXYNE_COMMAND_GIT_PUSH: + case AXYNE_COMMAND_GIT_PULL: case AXYNE_COMMAND_GIT_LOG: + out->enabled = axyne_git_available(state); + return 1; + case AXYNE_COMMAND_EXPLORER_NEW_FILE: case AXYNE_COMMAND_EXPLORER_NEW_FOLDER: + case AXYNE_COMMAND_EXPLORER_RENAME: case AXYNE_COMMAND_EXPLORER_DELETE: + out->enabled = state->explorer.root != NULL; + return 1; + default: + out->enabled = 0; + return 0; + } +} + +int axyne_win_base_command(AxyneWindowState *state, AxyneCommandId id) +{ + HWND window = state->main_window; + UINT legacy; + if (window == NULL) return 0; + switch (id) { + case AXYNE_COMMAND_FILE_NEW: legacy = AXYNE_CMD_NEW; break; + case AXYNE_COMMAND_FILE_OPEN: legacy = AXYNE_CMD_OPEN; break; + case AXYNE_COMMAND_FILE_OPEN_FOLDER: legacy = AXYNE_CMD_WORKSPACE; break; + case AXYNE_COMMAND_FILE_SAVE: legacy = AXYNE_CMD_SAVE; break; + case AXYNE_COMMAND_FILE_SAVE_AS: legacy = AXYNE_CMD_SAVE_AS; break; + case AXYNE_COMMAND_FILE_CLOSE: legacy = AXYNE_CMD_CLOSE; break; + case AXYNE_COMMAND_FILE_EXIT: legacy = AXYNE_CMD_EXIT; break; + case AXYNE_COMMAND_EDIT_UNDO: legacy = AXYNE_CMD_UNDO; break; + case AXYNE_COMMAND_EDIT_REDO: legacy = AXYNE_CMD_REDO; break; + case AXYNE_COMMAND_EDIT_CUT: legacy = AXYNE_CMD_CUT; break; + case AXYNE_COMMAND_EDIT_COPY: legacy = AXYNE_CMD_COPY; break; + case AXYNE_COMMAND_EDIT_PASTE: legacy = AXYNE_CMD_PASTE; break; + case AXYNE_COMMAND_EDIT_SELECT_ALL: legacy = AXYNE_CMD_SELECT_ALL; break; + case AXYNE_COMMAND_EDIT_FIND: legacy = AXYNE_CMD_FIND; break; + case AXYNE_COMMAND_EDIT_REPLACE: legacy = AXYNE_CMD_REPLACE; break; + case AXYNE_COMMAND_EDIT_FIND_IN_FILES: legacy = AXYNE_CMD_SEARCH_FOLDER; break; + case AXYNE_COMMAND_EDIT_GO_TO_LINE: legacy = AXYNE_CMD_GOTO_LINE; break; + case AXYNE_COMMAND_EDIT_SELECT_LINE: legacy = AXYNE_CMD_SELECT_LINE; break; + case AXYNE_COMMAND_EDIT_TOGGLE_LINE_COMMENT: legacy = AXYNE_CMD_TOGGLE_COMMENT; break; + case AXYNE_COMMAND_EDIT_DUPLICATE_LINE: legacy = AXYNE_CMD_DUPLICATE_LINE; break; + case AXYNE_COMMAND_EDIT_MOVE_LINE_UP: legacy = AXYNE_CMD_MOVE_LINE_UP; break; + case AXYNE_COMMAND_EDIT_MOVE_LINE_DOWN: legacy = AXYNE_CMD_MOVE_LINE_DOWN; break; + case AXYNE_COMMAND_EDIT_INDENT: legacy = AXYNE_CMD_INDENT; break; + case AXYNE_COMMAND_EDIT_OUTDENT: legacy = AXYNE_CMD_OUTDENT; break; + case AXYNE_COMMAND_EDIT_GO_TO_DEFINITION: legacy = AXYNE_CMD_LSP_DEFINITION; break; + case AXYNE_COMMAND_EDIT_FIND_REFERENCES: legacy = AXYNE_CMD_LSP_REFERENCES; break; + case AXYNE_COMMAND_EDIT_GO_TO_SYMBOL: + axyne_palette_open(window, state, "@"); + return 1; + case AXYNE_COMMAND_VIEW_COMMAND_PALETTE: + axyne_palette_open(window, state, ">"); + return 1; + case AXYNE_COMMAND_VIEW_QUICK_OPEN: legacy = AXYNE_CMD_QUICK_FILE; break; + case AXYNE_COMMAND_VIEW_EXPLORER: legacy = AXYNE_CMD_VIEW_EXPLORER; break; + case AXYNE_COMMAND_VIEW_GIT_PANEL: legacy = AXYNE_CMD_VIEW_GIT; break; + case AXYNE_COMMAND_VIEW_PANEL: legacy = AXYNE_CMD_VIEW_PANEL; break; + case AXYNE_COMMAND_VIEW_OUTPUT: legacy = AXYNE_CMD_PANEL_OUTPUT; break; + case AXYNE_COMMAND_VIEW_PROBLEMS: legacy = AXYNE_CMD_PANEL_PROBLEMS; break; + case AXYNE_COMMAND_VIEW_TERMINAL: legacy = AXYNE_CMD_PANEL_TERMINAL; break; + case AXYNE_COMMAND_VIEW_CLEAR_OUTPUT: + if (state->terminal_output != NULL) SetWindowTextW(state->terminal_output, L""); + return 1; + case AXYNE_COMMAND_VIEW_ZOOM_IN: legacy = AXYNE_CMD_ZOOM_IN; break; + case AXYNE_COMMAND_VIEW_ZOOM_OUT: legacy = AXYNE_CMD_ZOOM_OUT; break; + case AXYNE_COMMAND_VIEW_ZOOM_RESET: legacy = AXYNE_CMD_ZOOM_RESET; break; + case AXYNE_COMMAND_VIEW_WORD_WRAP: legacy = AXYNE_CMD_WORD_WRAP; break; + case AXYNE_COMMAND_VIEW_FULL_SCREEN: legacy = AXYNE_CMD_FULLSCREEN; break; + case AXYNE_COMMAND_BUILD_BUILD: legacy = AXYNE_CMD_BUILD; break; + case AXYNE_COMMAND_BUILD_SELECT_TARGET: legacy = AXYNE_CMD_BUILD_TARGET; break; + case AXYNE_COMMAND_BUILD_CANCEL: legacy = AXYNE_CMD_CANCEL_BUILD; break; + case AXYNE_COMMAND_BUILD_CONFIGURE_RUNNER: legacy = AXYNE_CMD_CONFIGURE_RUNNER; break; + case AXYNE_COMMAND_DEBUG_START: legacy = AXYNE_DEBUG_START; break; + case AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING: legacy = AXYNE_CMD_RUN; break; + case AXYNE_COMMAND_DEBUG_STOP: legacy = AXYNE_CMD_DEBUG_STOP; break; + case AXYNE_COMMAND_DEBUG_PAUSE: legacy = AXYNE_DEBUG_PAUSE; break; + case AXYNE_COMMAND_DEBUG_CONTINUE: legacy = AXYNE_DEBUG_CONTINUE; break; + case AXYNE_COMMAND_DEBUG_STEP_OVER: legacy = AXYNE_DEBUG_STEP_OVER; break; + case AXYNE_COMMAND_DEBUG_STEP_INTO: legacy = AXYNE_CMD_DEBUG_STEP_INTO; break; + case AXYNE_COMMAND_DEBUG_STEP_OUT: legacy = AXYNE_CMD_DEBUG_STEP_OUT; break; + case AXYNE_COMMAND_DEBUG_TOGGLE_BREAKPOINT: legacy = AXYNE_DEBUG_BREAKPOINT; break; + case AXYNE_COMMAND_DEBUG_CLEAR_BREAKPOINTS: legacy = AXYNE_CMD_DEBUG_CLEAR_BREAKPOINTS; break; + case AXYNE_COMMAND_TERMINAL_NEW: legacy = AXYNE_TERMINAL_START; break; + case AXYNE_COMMAND_TOOLS_SETTINGS: legacy = AXYNE_CMD_PREFERENCES; break; + case AXYNE_COMMAND_TOOLS_WORKSPACE_SETTINGS: legacy = AXYNE_CMD_WORKSPACE_PREFERENCES; break; + case AXYNE_COMMAND_TOOLS_OPEN_SETTINGS_JSON: legacy = AXYNE_CMD_OPEN_PREFERENCES_FILE; break; + case AXYNE_COMMAND_GIT_STATUS: legacy = AXYNE_CMD_GIT_STATUS; break; + case AXYNE_COMMAND_GIT_DIFF: legacy = AXYNE_CMD_GIT_DIFF; break; + case AXYNE_COMMAND_GIT_STAGE_ALL: legacy = AXYNE_CMD_GIT_STAGE_ALL; break; + case AXYNE_COMMAND_GIT_UNSTAGE_ALL: legacy = AXYNE_CMD_GIT_UNSTAGE_ALL; break; + case AXYNE_COMMAND_GIT_COMMIT: legacy = AXYNE_CMD_GIT_COMMIT; break; + case AXYNE_COMMAND_GIT_PUSH: legacy = AXYNE_CMD_GIT_PUSH; break; + case AXYNE_COMMAND_GIT_PULL: legacy = AXYNE_CMD_GIT_PULL; break; + case AXYNE_COMMAND_GIT_LOG: legacy = AXYNE_CMD_GIT_LOG; break; + case AXYNE_COMMAND_HELP_KEYBOARD_SHORTCUTS: legacy = AXYNE_CMD_SHORTCUTS; break; + case AXYNE_COMMAND_HELP_REPORT_ISSUE: legacy = AXYNE_CMD_REPORT_ISSUE; break; + case AXYNE_COMMAND_HELP_ABOUT: legacy = AXYNE_CMD_ABOUT; break; + case AXYNE_COMMAND_EXPLORER_NEW_FILE: legacy = AXYNE_CMD_EXPLORER_NEW_FILE; break; + case AXYNE_COMMAND_EXPLORER_NEW_FOLDER: legacy = AXYNE_CMD_EXPLORER_NEW_FOLDER; break; + case AXYNE_COMMAND_EXPLORER_RENAME: legacy = AXYNE_CMD_EXPLORER_RENAME; break; + case AXYNE_COMMAND_EXPLORER_DELETE: legacy = AXYNE_CMD_EXPLORER_REMOVE; break; + default: + return 0; + } + axyne_legacy_command(window, state, legacy); + return 1; +} + +/* ---- exported helpers (ui_windows_internal.h) ---------------------------- */ + +wchar_t *axyne_win_wide(const char *utf8) { return utf8 != NULL ? axyne_wide(utf8) : NULL; } +char *axyne_win_utf8(const wchar_t *wide) { return wide != NULL ? axyne_utf8(wide) : NULL; } + +AxyneDocument *axyne_win_active_document(AxyneWindowState *state) +{ + return state != NULL ? axyne_active_visible(state) : NULL; +} + +int axyne_win_empty_state(AxyneWindowState *state) { return axyne_empty_state(state); } + +int axyne_win_capture_editor(AxyneWindowState *state) +{ + return state != NULL && axyne_capture_editor(state); +} + +int axyne_win_open_document(AxyneWindowState *state, const char *path, + size_t line, size_t column) +{ + AxyneDocument *doc; + if (state == NULL || state->main_window == NULL || path == NULL || path[0] == '\0') + return 0; + if (line == 0) { + doc = axyne_active_visible(state); + if (doc == NULL || doc->path == NULL || !axyne_problems_path_equal(doc->path, path)) + axyne_open_document(state->main_window, state, path); + } else { + axyne_problems_show(state->main_window, state, path, line, column > 0 ? column : 1, 0); + } + doc = axyne_active_visible(state); + if (doc == NULL || doc->path == NULL) return 0; + if (axyne_problems_path_equal(doc->path, path)) return 1; + if (state->explorer.root != NULL) { + char *resolved = axyne_problems_resolve_path(path, state->explorer.root); + int same = resolved != NULL && axyne_problems_path_equal(doc->path, resolved); + free(resolved); + return same; + } + return 0; +} + +void axyne_win_goto(AxyneWindowState *state, size_t line, size_t column, + int utf16_column) +{ + if (state == NULL || axyne_active_visible(state) == NULL) return; + axyne_problems_goto(state, line, column > 0 ? column : 1, utf16_column); +} + +int axyne_win_open_virtual_document(AxyneWindowState *state, const char *title, + const char *text, size_t length) +{ + if (state == NULL || state->main_window == NULL || title == NULL) return 0; + return axyne_open_virtual_document(state->main_window, state, title, + text != NULL ? text : "", text != NULL ? length : 0); +} + +void axyne_win_new_document(AxyneWindowState *state) +{ + if (state != NULL && state->main_window != NULL) + axyne_new_document(state->main_window, state); +} + +int axyne_win_save_active(AxyneWindowState *state) +{ + if (state == NULL || state->main_window == NULL || axyne_empty_state(state)) return 0; + return axyne_save_active(state->main_window, state); +} + +int axyne_win_save_document(AxyneWindowState *state, size_t index) +{ + if (state == NULL || state->main_window == NULL || index >= state->documents.count || + axyne_document_tab_hidden(&state->documents.documents[index])) + return 0; + if (index != state->documents.active_index) { + if (!axyne_capture_editor(state) || !axyne_show_document(state, index)) return 0; + axyne_update_title(state->main_window, state); + } + return axyne_save_active(state->main_window, state); +} + +int axyne_win_close_document(AxyneWindowState *state, size_t index) +{ + size_t before; + if (state == NULL || state->main_window == NULL || index >= state->documents.count) + return 0; + before = state->documents.count; + axyne_close_tab(state->main_window, state, index); + /* The last tab is replaced by the hidden placeholder (same count). */ + return state->documents.count < before || axyne_empty_state(state); +} + +void axyne_win_update_title(AxyneWindowState *state) +{ + if (state != NULL && state->main_window != NULL) + axyne_update_title(state->main_window, state); +} + +int axyne_win_open_folder(AxyneWindowState *state, const char *root) +{ + if (state == NULL || state->main_window == NULL || root == NULL || root[0] == '\0') + return 0; + return axyne_workspace_open_root(state->main_window, state, root); +} + +void axyne_win_output_append(AxyneWindowState *state, const char *utf8, + AxyneProcessStream stream) +{ + if (state != NULL) axyne_terminal_append_utf8(state->terminal_output, utf8, stream); +} + +void axyne_win_output_append_bytes(AxyneWindowState *state, const char *bytes, + size_t length, AxyneProcessStream stream) +{ + if (state != NULL) axyne_terminal_append(state->terminal_output, bytes, length, stream); +} + +void axyne_win_output_clear(AxyneWindowState *state) +{ + if (state != NULL && state->terminal_output != NULL) + SetWindowTextW(state->terminal_output, L""); +} + +int axyne_win_process_busy(AxyneWindowState *state) +{ + return state != NULL && (state->action_process != NULL || + axyne_debugger_is_active(&state->debugger)); +} + +int axyne_win_run_plan_step(AxyneWindowState *state, const AxyneLanguagePlan *plan, + int run_step, int clear) +{ + if (state == NULL || state->main_window == NULL || plan == NULL || + axyne_win_process_busy(state)) + return 0; + return axyne_start_step(state->main_window, state, run_step ? &plan->run : &plan->build, + plan, run_step ? 2 : 1, clear); +} + +void axyne_win_select_panel_tab(AxyneWindowState *state, AxyneWinPanelTab tab, int focus) +{ + if (state != NULL && state->main_window != NULL) + axyne_select_panel_tab(state->main_window, state, tab, focus); +} + +AxyneWinPanelTab axyne_win_panel_tab(const AxyneWindowState *state) +{ + return state != NULL ? state->panel_tab : AXYNE_WIN_PANEL_OUTPUT; +} + +void axyne_win_panel_set_tab_enabled(AxyneWindowState *state, AxyneWinPanelTab tab, + int enabled) +{ + if (state == NULL || (int)tab < 0 || tab >= AXYNE_WIN_PANEL_COUNT || + tab == AXYNE_WIN_PANEL_OUTPUT) + return; + if (enabled) state->panel_tabs_enabled |= 1u << tab; + else { + state->panel_tabs_enabled &= ~(1u << tab); + if (state->panel_tab == tab) state->panel_tab = AXYNE_WIN_PANEL_OUTPUT; + } + if (state->main_window != NULL) axyne_layout(state->main_window, state); +} + +int axyne_win_panel_tab_enabled(const AxyneWindowState *state, AxyneWinPanelTab tab) +{ + return state != NULL && axyne_panel_tab_enabled(state, (int)tab); +} + +int axyne_win_panel_body(const AxyneWindowState *state, RECT *body) +{ + if (state == NULL || body == NULL) return 0; + return axyne_panel_body_rect(state, body); +} + +void axyne_win_layout(AxyneWindowState *state) +{ + if (state != NULL && state->main_window != NULL) axyne_layout(state->main_window, state); +} + +void axyne_win_invalidate(AxyneWindowState *state, const RECT *rect) +{ + if (state != NULL && state->main_window != NULL) + InvalidateRect(state->main_window, rect, FALSE); +} + +int axyne_win_sidebar_width(const AxyneWindowState *state) +{ + return state != NULL ? axyne_sidebar_width(state) : 0; +} + +int axyne_win_panel_height(const AxyneWindowState *state) +{ + return state != NULL ? axyne_panel_height(state) : 0; +} + +void axyne_win_apply_preferences(AxyneWindowState *state) +{ + if (state == NULL) return; + axyne_apply_preferences(state); + if (state->main_window != NULL) InvalidateRect(state->main_window, NULL, FALSE); +} + +COLORREF axyne_win_color(AxyneWinColor which) +{ + switch (which) { + case AXYNE_WIN_COLOR_BACKGROUND: return AXYNE_BG; + case AXYNE_WIN_COLOR_PANEL: return AXYNE_PANEL; + case AXYNE_WIN_COLOR_TOOLBAR: return AXYNE_TOOLBAR_BG; + case AXYNE_WIN_COLOR_BORDER: return AXYNE_BORDER; + case AXYNE_WIN_COLOR_TEXT: return AXYNE_TEXT; + case AXYNE_WIN_COLOR_MUTED: return AXYNE_MUTED; + case AXYNE_WIN_COLOR_ACCENT: return AXYNE_ACCENT; + case AXYNE_WIN_COLOR_OUTPUT_BACKGROUND: return AXYNE_OUTPUT_BG; + case AXYNE_WIN_COLOR_PANEL_HEADER: return AXYNE_PANEL_HEADER_BG; + case AXYNE_WIN_COLOR_STATUS_BACKGROUND: return AXYNE_STATUS_BG; + case AXYNE_WIN_COLOR_SELECTION: return AXYNE_SELECTION_BG; + case AXYNE_WIN_COLOR_INDICATOR: return AXYNE_INDICATOR; + case AXYNE_WIN_COLOR_ERROR: return axyne_problems_severity_color(AXYNE_PROBLEM_ERROR); + case AXYNE_WIN_COLOR_WARNING: return axyne_problems_severity_color(AXYNE_PROBLEM_WARNING); + default: return AXYNE_TEXT; + } +} + +void axyne_win_fill(HDC dc, RECT rect, COLORREF color) +{ + axyne_fill(dc, rect.left, rect.top, rect.right, rect.bottom, color); +} + +void axyne_win_round_fill(HDC dc, RECT rect, int radius, COLORREF fill, COLORREF border) +{ + axyne_round_fill(dc, rect.left, rect.top, rect.right, rect.bottom, radius, fill, border); +} + +void axyne_win_text(HDC dc, HFONT font, COLORREF color, RECT rect, + const wchar_t *text, UINT alignment) +{ + if (text != NULL) axyne_text_rect(dc, font, color, rect, text, alignment); +} + +int axyne_win_measure_text(HFONT font, const wchar_t *text) +{ + return text != NULL ? axyne_measure_text(font, text) : 0; +} + + static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, WPARAM w_param, LPARAM l_param) { AxyneWindowState *state = (AxyneWindowState *)GetWindowLongPtrW( window, GWLP_USERDATA); + if (state != NULL && state->main_window == window && message != WM_NCCREATE && + message != WM_CREATE && message != WM_NCDESTROY) { + LRESULT result = 0; + if (axyne_win_hooks_message(state, window, message, w_param, l_param, &result)) + return result; + } switch (message) { case WM_NCCREATE: { CREATESTRUCTW *create = (CREATESTRUCTW *)l_param; @@ -9252,6 +9523,8 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, state = (AxyneWindowState *)GetWindowLongPtrW(window, GWLP_USERDATA); HINSTANCE instance = (HINSTANCE)GetWindowLongPtrW(window, GWLP_HINSTANCE); axyne_palette_ctl_init(&state->palette, 0, axyne_palette_document, state); + state->panel_tab = AXYNE_WIN_PANEL_OUTPUT; + state->panel_tabs_enabled = AXYNE_PANEL_TABS_DEFAULT; state->build_target = axyne_build_target_default(); state->ui_font = CreateFontW(-12, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, @@ -9288,6 +9561,10 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, axyne_create_terminal_controls(window, state, instance); axyne_git_panel_create_controls(window, state, instance); axyne_problems_create_filter(window, state, instance); + /* Features create their data once the base controls exist and before + * anything (document activation, layout, messages) runs their hooks; + * the fan-out ignores hooks until this has run. */ + axyne_win_hooks_create(state); axyne_problems_refresh(state); if (state->documents.count != 0) axyne_show_document(state, state->documents.active_index); @@ -9354,6 +9631,11 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, if (state != NULL && w_param) axyne_git_panel_schedule_refresh(window, state); break; case WM_TIMER: + if (state != NULL && axyne_win_hooks_timer(state, (UINT_PTR)w_param)) return 0; + if (state != NULL && w_param == AXYNE_WIN_CHORD_TIMER_ID) { + axyne_win_chord_timer(state); + return 0; + } if (state != NULL && w_param == AXYNE_PALETTE_TIMER_ID) { axyne_palette_tick(window, state); return 0; @@ -9389,7 +9671,7 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, case WM_KEYDOWN: if (state != NULL && GetFocus() == window && axyne_problems_key(window, state, w_param)) return 0; - if (axyne_handle_key(window, state, w_param)) return 0; + if (axyne_win_handle_key(state, w_param)) return 0; break; case WM_LBUTTONDBLCLK: { int x = GET_X_LPARAM(l_param); @@ -9445,9 +9727,7 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, state->menu_active = index + 1; InvalidateRect(window, NULL, FALSE); UpdateWindow(window); - if (index == 0) axyne_file_popup(window, state); - else if (index == 1) axyne_edit_popup(window, state); - else axyne_chrome_popup(window, state, index); + axyne_menu_bar_popup(window, state, index); state->menu_active = 0; InvalidateRect(window, NULL, FALSE); return 0; @@ -9463,11 +9743,11 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, if (axyne_toolbar_enabled(state, AXYNE_TOOLBAR_COMMANDS[i])) { /* Toolbar Undo/Redo are the editor's (that is what * enables them), not a focused text field's. */ - if ((AXYNE_TOOLBAR_COMMANDS[i] == AXYNE_CMD_UNDO || - AXYNE_TOOLBAR_COMMANDS[i] == AXYNE_CMD_REDO) && + if ((AXYNE_TOOLBAR_COMMANDS[i] == AXYNE_COMMAND_EDIT_UNDO || + AXYNE_TOOLBAR_COMMANDS[i] == AXYNE_COMMAND_EDIT_REDO) && state->editor != NULL) SetFocus(state->editor); - SendMessageW(window, WM_COMMAND, AXYNE_TOOLBAR_COMMANDS[i], 0); + (void)axyne_win_dispatch(state, AXYNE_TOOLBAR_COMMANDS[i]); } return 0; } @@ -9494,14 +9774,13 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, int panel_top = client.bottom - AXYNE_STATUS - axyne_panel_height(state); if (!state->panel_hidden && y >= panel_top && y < panel_top + AXYNE_UI_PANEL_HEADER) { - static const UINT panel_commands[3] = { - AXYNE_CMD_PANEL_OUTPUT, AXYNE_CMD_PANEL_PROBLEMS, - AXYNE_CMD_PANEL_TERMINAL }; int panel_index; - for (panel_index = 0; panel_index < 3; ++panel_index) { - RECT rect = axyne_panel_tab_rect(state, panel_index, panel_top); + for (panel_index = 0; panel_index < AXYNE_WIN_PANEL_COUNT; ++panel_index) { + RECT rect; + if (!axyne_panel_tab_enabled(state, panel_index)) continue; + rect = axyne_panel_tab_rect(state, panel_index, panel_top); if (x >= rect.left && x < rect.right) { - SendMessageW(window, WM_COMMAND, panel_commands[panel_index], 0); + axyne_select_panel_tab(window, state, (AxyneWinPanelTab)panel_index, 1); break; } } @@ -9714,122 +9993,12 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, axyne_palette_text_changed(window, state); return 0; } - /* Edit commands go to a focused text field first, which works - * without a document too. */ - if (axyne_edit_field_focused(state) != NULL && - axyne_edit_command(state, command)) - return 0; - /* Document commands have nothing to act on in the empty state. */ - if (axyne_empty_state(state) && axyne_command_needs_document(command)) + if (command >= AXYNE_WIN_COMMAND_ID_BASE && + command < AXYNE_WIN_COMMAND_ID_BASE + (UINT)AXYNE_COMMAND_COUNT) { + (void)axyne_win_execute(state, (AxyneCommandId)(command - AXYNE_WIN_COMMAND_ID_BASE)); return 0; - if (command == AXYNE_CMD_PANEL_OUTPUT || command == AXYNE_CMD_PANEL_TERMINAL || - command == AXYNE_CMD_PANEL_PROBLEMS) { - state->terminal_panel_selected = command == AXYNE_CMD_PANEL_TERMINAL; - state->problems_panel_selected = command == AXYNE_CMD_PANEL_PROBLEMS; - state->panel_hidden = 0; - axyne_layout(window, state); - if (state->terminal_panel_selected) axyne_start_terminal_panel(window, state); - else if (state->problems_panel_selected) SetFocus(window); - else if (state->editor != NULL) SetFocus(state->editor); - } - else if (axyne_action_command(window, state, command)) { /* handled */ } - else if (command == AXYNE_CMD_EXIT) PostMessageW(window, WM_CLOSE, 0, 0); - else if (command == AXYNE_CMD_ABOUT) - MessageBoxW(window, - L"Axyne\nLightweight native IDE\n\nhttps://github.com/team-native/Axyne", - L"About Axyne", MB_OK | MB_ICONINFORMATION); - else if (command == AXYNE_CMD_BUILD) axyne_start_action(window, state, 0); - else if (command == AXYNE_CMD_RUN) axyne_start_action(window, state, 1); - else if (command == AXYNE_CMD_BUILD_TARGET) axyne_build_target_popup(window, state); - else if (command == AXYNE_CMD_CONFIGURE_RUNNER) { - (void)axyne_configure_runner(window, &state->action_runner); - InvalidateRect(window, NULL, FALSE); } - else if (command == AXYNE_CMD_PREFERENCES) - (void)axyne_preferences_dialog(window, state, 0); - else if (command == AXYNE_CMD_WORKSPACE_PREFERENCES) { - if (state->workspace_preferences_path == NULL) - MessageBoxA(window, "Open a workspace folder before editing workspace settings.", "Axyne - Preferences", MB_OK | MB_ICONINFORMATION); - else (void)axyne_preferences_dialog(window, state, 1); - } - else if (command == AXYNE_TERMINAL_START) axyne_start_terminal_panel(window, state); - else if (command == AXYNE_TERMINAL_STOP) axyne_terminal_stop(state); - else if (command == AXYNE_CMD_CANCEL_BUILD && state->action_process != NULL) axyne_process_terminate(state->action_process, NULL); - else if (command == AXYNE_DEBUG_START) axyne_windows_debugger_start(window, state); - else if (command == AXYNE_DEBUG_PAUSE) - axyne_debugger_command_ui(state, AXYNE_DEBUGGER_PAUSE); - else if (command == AXYNE_DEBUG_CONTINUE) - axyne_debugger_command_ui(state, AXYNE_DEBUGGER_CONTINUE); - else if (command == AXYNE_DEBUG_STEP_OVER) - axyne_debugger_command_ui(state, AXYNE_DEBUGGER_STEP_OVER); - else if (command == AXYNE_DEBUG_BREAKPOINT) - axyne_debugger_toggle_current_breakpoint(state); - else if (command == AXYNE_CMD_NEW) axyne_new_document(window, state); - else if (command == AXYNE_CMD_OPEN) axyne_open_document(window, state, NULL); - else if (command == AXYNE_CMD_SAVE) (void)axyne_save_active(window, state); - else if (command == AXYNE_CMD_SAVE_AS) { - char *path = NULL; - if (axyne_active(state) == NULL || - !axyne_document_can_save(axyne_active(state)) || - !axyne_editor_ready_for_save(window, state)) return 0; - if (axyne_choose_path(window, 1, &path)) { - if (!axyne_capture_editor(state)) { - free(path); - return 0; - } - AxyneError error; - AxyneDocument *active = axyne_active(state); - char *old_path = active != NULL && !active->is_untitled && - active->path != NULL ? _strdup(active->path) : NULL; - AxyneStatus status = axyne_documents_save_as(&state->documents, - state->documents.active_index, path, &error); - free(path); - if (status == AXYNE_STATUS_OK) { - SendMessageA(state->editor, SCI_SETSAVEPOINT, 0, 0); - axyne_after_save_as(state, old_path); - axyne_refresh_outline(state); - axyne_update_title(window, state); - axyne_refresh_action_controls(state); - } else MessageBoxA(window, error.message, "Axyne - Save failed", - MB_OK | MB_ICONERROR); - free(old_path); - } - } else if (command == AXYNE_CMD_CLOSE) - axyne_close_tab(window, state, state->documents.active_index); - else if (command == AXYNE_CMD_FIND) axyne_find(window, state, 0, 0); - else if (command == AXYNE_CMD_REPLACE) axyne_find(window, state, 1, 0); - else if (command == AXYNE_CMD_SEARCH_FOLDER) axyne_search_folder(window, state, 0); - else if (command == AXYNE_CMD_QUICK_FILE) axyne_search_folder(window, state, 1); - else if (command == AXYNE_CMD_GIT_STATUS) - axyne_git_start(window, state, "No Git status output.", command); - else if (command == AXYNE_CMD_GIT_DIFF) - axyne_git_start(window, state, "No Git differences.", command); - else if (command == AXYNE_CMD_GIT_STAGE_ALL) - axyne_git_start(window, state, "All workspace changes staged.", command); - else if (command == AXYNE_CMD_GIT_UNSTAGE_ALL) - axyne_git_start(window, state, "All changes unstaged.", command); - else if (command == AXYNE_CMD_GIT_COMMIT) - axyne_git_commit_command(window, state); - else if (command == AXYNE_CMD_GIT_PUSH) - axyne_git_batch_start(window, state, command, NULL, 0, 0); - else if (command == AXYNE_CMD_GIT_PULL) - axyne_git_batch_start(window, state, command, NULL, 0, 0); - else if (command == AXYNE_CMD_GIT_LOG) - axyne_git_batch_start(window, state, command, NULL, 0, 0); - else if (command == AXYNE_CMD_VIEW_GIT) - axyne_git_panel_toggle_view(window, state); - else if (command == AXYNE_CMD_LSP_DEFINITION) axyne_lsp_navigate(window, state, 0); - else if (command == AXYNE_CMD_LSP_REFERENCES) axyne_lsp_navigate(window, state, 1); - else if (command == AXYNE_CMD_UNDO || command == AXYNE_CMD_REDO || - command == AXYNE_CMD_CUT || command == AXYNE_CMD_COPY || - command == AXYNE_CMD_PASTE || command == AXYNE_CMD_SELECT_ALL) - (void)axyne_edit_command(state, command); - else if (command >= AXYNE_CMD_WORKSPACE && command <= AXYNE_CMD_EXPLORER_REMOVE) - axyne_workspace_operation(window, state, command); - else if (command >= AXYNE_CMD_RECENT_BASE && - command - AXYNE_CMD_RECENT_BASE < state->documents.recent_count) - axyne_open_document(window, state, - state->documents.recent_paths[command - AXYNE_CMD_RECENT_BASE]); + axyne_legacy_command(window, state, command); return 0; } case WM_CTLCOLOREDIT: @@ -10075,6 +10244,8 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, state->documents.active_index, NULL); axyne_update_title(window, state); } + if (header != NULL && header->hwndFrom == state->editor && !state->loading_editor) + axyne_win_hooks_editor_notify(state, (const SCNotification *)l_param); axyne_refresh_action_controls(state); { RECT client; @@ -10128,6 +10299,7 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, return 0; case WM_NCDESTROY: if (state != NULL) { + axyne_win_hooks_destroy(state); if (state->action_process != NULL) { axyne_process_release(state->action_process); state->action_process = NULL; @@ -10183,6 +10355,7 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, } axyne_git_panel_clear(&state->git_panel); axyne_palette_destroy_ui(state); + axyne_win_keymap_destroy(state); axyne_runner_destroy(&state->terminal_runner); axyne_runner_destroy(&state->action_runner); axyne_clear_pending_run(state); @@ -10227,6 +10400,8 @@ static LRESULT CALLBACK axyne_window_proc(HWND window, UINT message, if (state->font_mono_small != NULL) DeleteObject(state->font_mono_small); free(state->global_preferences_path); free(state->workspace_preferences_path); + free(state->global_settings_write_path); + free(state->workspace_settings_write_path); SetWindowLongPtrW(window, GWLP_USERDATA, 0); HeapFree(GetProcessHeap(), 0, state); } @@ -10298,7 +10473,7 @@ int axyne_ui_run(HINSTANCE instance, int show_command, const char *app_name) if (current != NULL && current->palette.active && message.hwnd == current->palette_edit) { /* only the palette shortcuts apply while typing in the field */ - if (!axyne_palette_handle_open_key(window, current, message.wParam)) { + if (!axyne_win_palette_key(current, message.wParam)) { TranslateMessage(&message); DispatchMessageW(&message); } @@ -10318,7 +10493,7 @@ int axyne_ui_run(HINSTANCE instance, int show_command, const char *app_name) int forward = message.message == WM_SYSKEYDOWN || (ctrl && shift && (message.wParam == 'G' || message.wParam == 'E')) || (ctrl && !shift && message.wParam == 'S'); - if (forward && axyne_handle_key(window, current, message.wParam)) + if (forward && axyne_win_handle_key(current, message.wParam)) continue; } TranslateMessage(&message); @@ -10344,8 +10519,7 @@ int axyne_ui_run(HINSTANCE instance, int show_command, const char *app_name) if (current != NULL && message.hwnd == current->shell_view) { TranslateMessage(&message); DispatchMessageW(&message); continue; } - if (current != NULL && axyne_handle_key(window, current, - message.wParam)) continue; + if (current != NULL && axyne_win_handle_key(current, message.wParam)) continue; } TranslateMessage(&message); DispatchMessageW(&message); diff --git a/src/features/ui/platform/windows/ui_windows_internal.h b/src/features/ui/platform/windows/ui_windows_internal.h new file mode 100644 index 0000000..82ed6fe --- /dev/null +++ b/src/features/ui/platform/windows/ui_windows_internal.h @@ -0,0 +1,614 @@ +#ifndef AXYNE_UI_WINDOWS_INTERNAL_H +#define AXYNE_UI_WINDOWS_INTERNAL_H + +/* Internal interface of the Windows UI (src/features/ui/platform/windows). + * ui_windows.c owns the main window, its state and the base behaviour; the + * feature files (ui__windows.c) build on the state, the exported + * axyne_win_* helpers and the hook table below so that feature categories do + * not need to edit ui_windows.c. Everything here runs on the UI thread. */ + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include + +#include "axyne/build_selector.h" +#include "axyne/debugger.h" +#include "axyne/document.h" +#include "axyne/explorer.h" +#include "axyne/git.h" +#include "axyne/git_panel.h" +#include "axyne/language.h" +#include "axyne/lsp.h" +#include "axyne/outline.h" +#include "axyne/palette_controller.h" +#include "axyne/preferences.h" +#include "axyne/problems.h" +#include "axyne/problems_feed.h" +#include "axyne/process.h" +#include "axyne/runner.h" +#include "axyne/watcher.h" +#include "Scintilla.h" + +#include "axyne/commands.h" +#include "axyne/keymap.h" +#define AXYNE_WIN_COMMAND_PLATFORM AXYNE_COMMAND_PLATFORM_WINDOWS + +/* ---- feature categories ------------------------------------------------- + * Each feature category owns one file ui__windows.c that defines its + * hook table (AxyneWinFeature) and keeps its private data behind one opaque + * pointer in AxyneWindowState. The order of this enum is the order in which + * hooks run. */ +typedef enum AxyneWinFeatureId { + AXYNE_WIN_FEATURE_FILE = 0, + AXYNE_WIN_FEATURE_EDIT, + AXYNE_WIN_FEATURE_VIEW, + AXYNE_WIN_FEATURE_BUILD, + AXYNE_WIN_FEATURE_DEBUG, + AXYNE_WIN_FEATURE_TERMINAL, + AXYNE_WIN_FEATURE_STATUS, + AXYNE_WIN_FEATURE_TOOLS, + AXYNE_WIN_FEATURE_HELP, + AXYNE_WIN_FEATURE_LSP_NAV, + AXYNE_WIN_FEATURE_DIAG, + AXYNE_WIN_FEATURE_RELEASE, + AXYNE_WIN_FEATURE_COUNT +} AxyneWinFeatureId; + +struct AxyneWinFileUi; +struct AxyneWinEditUi; +struct AxyneWinViewUi; +struct AxyneWinBuildUi; +struct AxyneWinDebugUi; +struct AxyneWinTerminalUi; +struct AxyneWinStatusUi; +struct AxyneWinToolsUi; +struct AxyneWinHelpUi; +struct AxyneWinLspNavUi; +struct AxyneWinDiagUi; +struct AxyneWinReleaseUi; + +/* Id ranges reserved per feature so features never collide with the base or + * with each other: WM_COMMAND/control ids, SetTimer ids and private window + * messages. n is 0..stride-1. */ +enum { + AXYNE_WIN_FEATURE_CONTROL_BASE = 20000, + AXYNE_WIN_FEATURE_CONTROL_STRIDE = 256, + AXYNE_WIN_FEATURE_TIMER_BASE = 6000, + AXYNE_WIN_FEATURE_TIMER_STRIDE = 16, + AXYNE_WM_FEATURE_BASE = WM_APP + 0x100, + AXYNE_WM_FEATURE_STRIDE = 16 +}; +#define AXYNE_WIN_FEATURE_CONTROL_ID(feature, n) \ + ((UINT)(AXYNE_WIN_FEATURE_CONTROL_BASE + (int)(feature) * AXYNE_WIN_FEATURE_CONTROL_STRIDE + (int)(n))) +#define AXYNE_WIN_FEATURE_TIMER_ID(feature, n) \ + ((UINT_PTR)(AXYNE_WIN_FEATURE_TIMER_BASE + (int)(feature) * AXYNE_WIN_FEATURE_TIMER_STRIDE + (int)(n))) +#define AXYNE_WM_FEATURE(feature, n) \ + ((UINT)(AXYNE_WM_FEATURE_BASE + (int)(feature) * AXYNE_WM_FEATURE_STRIDE + (int)(n))) + +/* Tabs of the bottom panel, in strip order. DEBUG_CONSOLE is hidden until a + * feature enables it (axyne_win_panel_set_tab_enabled). */ +typedef enum AxyneWinPanelTab { + AXYNE_WIN_PANEL_OUTPUT = 0, + AXYNE_WIN_PANEL_PROBLEMS, + AXYNE_WIN_PANEL_TERMINAL, + AXYNE_WIN_PANEL_DEBUG_CONSOLE, + AXYNE_WIN_PANEL_COUNT +} AxyneWinPanelTab; + +typedef struct AxyneGitUiRun AxyneGitUiRun; +typedef void *(__stdcall *AxyneCreateLexer)(const char *name); + +/* The left column shows either the file explorer or the Git panel. */ +enum { AXYNE_SIDEBAR_TAB_EXPLORER = 0, AXYNE_SIDEBAR_TAB_GIT = 1, + AXYNE_SIDEBAR_TAB_COUNT = 2, AXYNE_SIDEBAR_TAB_PAD = 9 }; + +/* Snapshot of the Git panel's data. Written only on the UI thread: worker + * jobs return their results by window message and are applied there. */ +typedef struct AxyneGitPanelUi { + AxyneGitChanges changes; + AxyneGitGraph graph; /* newest first, at most graph_loaded_limit commits */ + int graph_ok; /* the graph could be read (zero commits is fine) */ + int graph_limit; /* commits requested by refreshes; 0 = default, grown by "더 불러오기" */ + AxyneGitGraphMode graph_mode; + unsigned graph_generation; /* mode request identity, independent of commits/workspace */ + HWND graph_selector; + int graph_loaded_limit; /* the request the shown graph answered (0 = none) */ + int graph_loading; /* a larger graph was requested and has not arrived */ + int loaded; /* a snapshot of the current workspace arrived */ + int repo_ok; /* the change list could be read */ + char error[512]; /* Git's message when !repo_ok */ + int has_staged; + int job_busy; /* a refresh or stage job runs on a worker thread */ + int refresh_pending; /* another refresh was requested meanwhile */ + unsigned generation; /* bumped per workspace; stale results are dropped */ + int changes_scroll; /* first visible change row */ + int graph_scroll; /* first visible graph row */ + int graph_hscroll; /* pixels the graph rows are scrolled to the left */ + int graph_width_base; /* widest graph item without ref chips */ + int graph_width_valid; /* graph_width_base matches the rows and expansion */ + int graph_width; /* scrollable width of the graph rows for graph_width_list */ + int graph_width_list; /* list width graph_width was measured for */ + int wheel_remainder; + int hwheel_remainder; + unsigned diff_serial; /* newest diff request; older results are dropped */ + char *selected_path; /* selected change (repository path) or NULL */ + char *selected_hash; /* selected commit or NULL (one selection at a time) */ + char *selected_cfile; /* selected file row under the expanded commit */ + /* The commit whose changed files are listed inline below its row (only + * one at a time). expanded_state: 0 none, 1 loading, 2 ready, 3 failed. */ + char *expanded_hash; + size_t expanded_row; /* index of that commit in graph, (size_t)-1 = none */ + AxyneGitChanges expanded_files; + int expanded_state; + char expanded_error[256]; + unsigned expand_serial; /* newest file-list request; older results are dropped */ + HWND message_edit; /* multi-line commit message */ + int message_filled; /* the message has a non-blank character */ + int message_focus; +} AxyneGitPanelUi; + +typedef struct AxyneWindowState { + HMODULE scintilla_module; + HMODULE lexilla_module; + AxyneCreateLexer create_lexer; + HWND editor; + HFONT ui_font; + HFONT ui_font_italic; /* 12px italic: preview tab titles */ + HFONT code_font; + HFONT badge_font; + HFONT tab_badge_font; + HFONT font_small; /* 11px: toolbar chips, explorer header, panel tabs */ + HFONT font_tiny; /* 10px: shortcut hints */ + HFONT font_bold; /* 11px bold: accent run label */ + HFONT font_glyph13; /* 13px: tab close glyph, toolbar glyphs */ + HFONT font_glyph14; /* 14px: toolbar glyphs */ + HFONT font_dot; /* 7px: dirty marker */ + HFONT font_output; /* 12px mono: output and terminal panel text */ + HFONT font_mono_small; /* 10px mono: problems file name and position */ + HWND main_window; + AxyneDocumentSet documents; + AxyneExplorer explorer; + AxyneWatcher *watcher; + size_t explorer_selection; + int explorer_has_selection; + size_t explorer_scroll; + int explorer_wheel_remainder; + /* Outline (개요) section at the bottom of the explorer tab. It is scanned + * only while that tab is shown; otherwise it is marked stale and rescanned + * when the tab appears. */ + AxyneOutline outline; + int outline_timer_active; + int outline_stale; + int outline_wheel_remainder; + size_t first_visible_tab; + size_t tab_reveal_index; + int tab_wheel_remainder; + /* Bottom panel: the selected tab and the enabled tabs (bit per + * AxyneWinPanelTab; DEBUG_CONSOLE starts disabled). */ + AxyneWinPanelTab panel_tab; + unsigned panel_tabs_enabled; + int closing; + int loading_editor; + int empty_shown; /* shortcut guide replaces the editor (no open document) */ + AxyneRunnerConfig terminal_runner; + AxyneRunnerConfig action_runner; + /* Build target selector. Kept in memory for the session; runtimes are + * discovered lazily (first editor file, click or build), never at startup. */ + AxyneBuildTarget build_target; + AxyneRuntimeList runtimes; + int runtimes_discovered; + int build_menu_open; /* chevron points up while the popup is open */ + int runtime_discovery_posted; + AxyneLanguagePlan pending_plan; /* run step that follows a successful build */ + int pending_run; + AxyneDebugger debugger; + /* Build/run/single-step process (output panel); the interactive shell + * lives in shell_view (native terminal, terminal_view_windows.c). */ + AxyneProcess *action_process; + AxyneProcess *git_process; + AxyneGitUiRun *git_run; + int git_batch_busy; /* a commit, push or pull runs on a worker thread */ + HWND terminal_output; + HWND shell_view; + HWND terminal_start; + HWND terminal_stop; + HWND debug_start; + HWND debug_pause; + HWND debug_continue; + HWND debug_step_over; + HWND debug_breakpoint; + int active_action; + int last_exit_code; + int last_exit_failed; + int has_exit_status; + AxynePreferences global_preferences; + AxynePreferences preferences; + /* Settings files (D3): *_preferences_path is the file read (settings.json, + * else the old preferences.json); *_settings_write_path is settings.json. */ + char *global_preferences_path; + char *workspace_preferences_path; + char *global_settings_write_path; + char *workspace_settings_write_path; + unsigned char workspace_binding_present[AXYNE_ACTION_COUNT]; + AxyneLspClient *lsp; + char lsp_status[192]; + /* Problems panel: the model (LSP diagnostics + build output), the rows + * built from it for painting and the panel's view state. */ + AxyneProblemList problems; + AxyneProblemCollapsed problems_collapsed; + AxyneBuildFeed build_feed; + int build_feed_active; + /* Process output is in the ANSI code page; a DBCS lead byte cut off from + * its trail byte at a chunk end waits here per stream (0 out, 1 err). */ + unsigned char build_feed_lead[2]; + int build_feed_has_lead[2]; + AxyneProblemRow *problem_rows; + size_t problem_row_count; + AxyneProblemCounts problem_counts; + char problem_summary[160]; + wchar_t problems_tab_label[32]; + int problem_selected; /* row index or -1 */ + int problem_scroll; /* pixels */ + int problem_wheel_remainder; + int problem_refresh_pending; + int problem_has_sel; /* selection identity kept across rebuilds */ + int problem_sel_kind; + size_t problem_sel_line; + size_t problem_sel_column; + char *problem_sel_path; + char *problem_sel_message; + HWND problems_filter; + int menu_active; /* 1-based index of the menu bar item whose popup is open */ + int explorer_hidden; /* View > Explorer */ + int sidebar_tab; /* AXYNE_SIDEBAR_TAB_*: what the sidebar shows */ + AxyneGitPanelUi git_panel; + int panel_hidden; /* View > Bottom Panel */ + int sidebar_size; /* dragged explorer width; 0 = default, session only */ + int panel_size; /* dragged bottom panel height; 0 = default */ + int client_width; /* client size cached by axyne_layout and paint */ + int client_height; + int splitter_drag; /* 0 none, 1 explorer border, 2 panel border */ + int splitter_start; /* pointer coordinate when the drag began */ + int splitter_start_size; + int fullscreen; + LONG_PTR saved_style; + WINDOWPLACEMENT saved_placement; + AxynePaletteController palette; + HWND palette_edit; + HWND palette_popup; + HWND palette_dim; + WNDPROC palette_edit_proc; + HBRUSH palette_edit_brush; + HFONT palette_fonts[7]; + int palette_updating; + int palette_wheel_remainder; + /* Per-feature state owned by ui__windows.c: created lazily by + * the feature (never at startup unless the feature needs it) and released + * by its destroy hook. */ + /* Effective key map: registry defaults + user keybindings (rebuilt by + * axyne_win_apply_preferences). */ + AxyneKeymap keymap; + int keymap_ready; + int features_created; /* feature create hooks have run (hooks live) */ + /* Set by the Debug category while its session runs: debug-context keys + * (F5 계속, F10/F11 steps) apply. The base debugger counts as well. */ + int debug_context; + /* Transient status-bar text (chord hint, feature notices), UTF-8. */ + char status_text[192]; + int chord_hint_shown; /* status_text holds the chord hint */ + struct AxyneWinFileUi *file_ui; + struct AxyneWinEditUi *edit_ui; + struct AxyneWinViewUi *view_ui; + struct AxyneWinBuildUi *build_ui; + struct AxyneWinDebugUi *debug_ui; + struct AxyneWinTerminalUi *terminal_ui; + struct AxyneWinStatusUi *status_ui; + struct AxyneWinToolsUi *tools_ui; + struct AxyneWinHelpUi *help_ui; + struct AxyneWinLspNavUi *lsp_nav_ui; + struct AxyneWinDiagUi *diag_ui; + struct AxyneWinReleaseUi *release_ui; +} AxyneWindowState; + +/* ---- hook table ---------------------------------------------------------- + * Every member may be NULL. Hooks run in AxyneWinFeatureId order; for the + * "first wins" hooks (message, command, command_state, timer, status_paint, + * panel_paint) the first feature returning non-zero stops the walk and the + * base behaviour is skipped. */ + +/* Menu/palette/key state of a command. */ +typedef struct AxyneWinCommandState { + int enabled; + int checked; /* check mark (CHECKABLE) or dot (RADIO) */ +} AxyneWinCommandState; + +/* Places in the menus where features add dynamic items (with ids from their + * AXYNE_WIN_FEATURE_CONTROL_ID range, handled through the message hook's + * WM_COMMAND). */ +typedef enum AxyneWinMenuSlot { + AXYNE_WIN_MENU_SLOT_RECENT_FOLDERS = 0, /* File > 최근 항목, before the files */ + AXYNE_WIN_MENU_SLOT_EXTERNAL_TOOLS, /* Tools > 외부 도구, before 외부 도구 구성… */ + AXYNE_WIN_MENU_SLOT_COUNT +} AxyneWinMenuSlot; + +typedef struct AxyneWinMenu AxyneWinMenu; + +/* WM_COMMAND id of registry command `id` in the menus. */ +enum { AXYNE_WIN_COMMAND_ID_BASE = 3000 }; +/* Base timer that clears the chord hint (Ctrl+K ...). */ +enum { AXYNE_WIN_CHORD_TIMER_ID = 5123 }; +#define AXYNE_WIN_COMMAND_MENU_ID(id) ((UINT)(AXYNE_WIN_COMMAND_ID_BASE + (int)(id))) + +typedef struct AxyneWinFeature { + const char *name; + /* WM_CREATE, after the base controls exist / WM_NCDESTROY, before the + * state is freed (child windows are already gone). */ + void (*create)(AxyneWindowState *state); + void (*destroy)(AxyneWindowState *state); + /* Any window message of the main window except WM_NCCREATE, WM_CREATE + * and WM_NCDESTROY, before the base handles it. Return 1 and set + * *result to consume it (private WM_APP messages, WM_COMMAND from the + * feature's controls or menu items, WM_CTLCOLOR* of its controls). */ + int (*message)(AxyneWindowState *state, HWND window, UINT message, + WPARAM w_param, LPARAM l_param, LRESULT *result); + /* Runs a registry command; 1 = handled (the base does not run it). */ + int (*command)(AxyneWindowState *state, AxyneCommandId id); + /* 1 = this feature implements `id` and filled *out. A command nobody + * implements is shown disabled and is never dispatched. */ + int (*command_state)(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out); + void (*menu_populate)(AxyneWindowState *state, AxyneWinMenuSlot slot, + AxyneWinMenu *menu); + /* WM_TIMER; 1 = the timer id was the feature's. */ + int (*timer)(AxyneWindowState *state, UINT_PTR timer_id); + /* Scintilla notifications of the editor (not while a document loads). */ + void (*editor_notify)(AxyneWindowState *state, + const SCNotification *notification); + /* A document became the visible one / is about to be released (tab + * closed or preview replaced; the native document still exists) / was + * written to disk (Save, Save As). */ + void (*document_activated)(AxyneWindowState *state, AxyneDocument *document); + void (*document_closed)(AxyneWindowState *state, const AxyneDocument *document); + void (*document_saved)(AxyneWindowState *state, AxyneDocument *document); + /* Paints the status bar `area`; 1 = the base paints no status text. */ + int (*status_paint)(AxyneWindowState *state, HDC dc, RECT area); + /* Paints the body of panel tab `tab` (below the tab strip); 1 = done. */ + int (*panel_paint)(AxyneWindowState *state, HDC dc, AxyneWinPanelTab tab, + RECT body); + /* Positions the feature's panel controls for the selected tab; + * `visible` is 0 while the panel is hidden. */ + void (*panel_layout)(AxyneWindowState *state, AxyneWinPanelTab tab, + RECT body, int visible); + /* After the base layout, with the client size. */ + void (*layout)(AxyneWindowState *state, int width, int height); +} AxyneWinFeature; + +extern const AxyneWinFeature axyne_win_feature_file; +extern const AxyneWinFeature axyne_win_feature_edit; +extern const AxyneWinFeature axyne_win_feature_view; +extern const AxyneWinFeature axyne_win_feature_build; +extern const AxyneWinFeature axyne_win_feature_debug; +extern const AxyneWinFeature axyne_win_feature_terminal; +extern const AxyneWinFeature axyne_win_feature_status; +extern const AxyneWinFeature axyne_win_feature_tools; +extern const AxyneWinFeature axyne_win_feature_help; +extern const AxyneWinFeature axyne_win_feature_lsp_nav; +extern const AxyneWinFeature axyne_win_feature_diag; +extern const AxyneWinFeature axyne_win_feature_release; + +/* Hook fan-out (ui_commands_windows.c), called by ui_windows.c at fixed + * points. */ +const AxyneWinFeature *axyne_win_feature(AxyneWinFeatureId id); +void axyne_win_hooks_create(AxyneWindowState *state); +void axyne_win_hooks_destroy(AxyneWindowState *state); +int axyne_win_hooks_message(AxyneWindowState *state, HWND window, UINT message, + WPARAM w_param, LPARAM l_param, LRESULT *result); +int axyne_win_hooks_command(AxyneWindowState *state, AxyneCommandId id); +int axyne_win_hooks_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out); +void axyne_win_hooks_menu_populate(AxyneWindowState *state, AxyneWinMenuSlot slot, + AxyneWinMenu *menu); +int axyne_win_hooks_timer(AxyneWindowState *state, UINT_PTR timer_id); +void axyne_win_hooks_editor_notify(AxyneWindowState *state, + const SCNotification *notification); +void axyne_win_hooks_document_activated(AxyneWindowState *state, AxyneDocument *document); +void axyne_win_hooks_document_closed(AxyneWindowState *state, const AxyneDocument *document); +void axyne_win_hooks_document_saved(AxyneWindowState *state, AxyneDocument *document); +int axyne_win_hooks_status_paint(AxyneWindowState *state, HDC dc, RECT area); +int axyne_win_hooks_panel_paint(AxyneWindowState *state, HDC dc, AxyneWinPanelTab tab, + RECT body); +void axyne_win_hooks_panel_layout(AxyneWindowState *state, AxyneWinPanelTab tab, + RECT body, int visible); +void axyne_win_hooks_layout(AxyneWindowState *state, int width, int height); + +/* ---- registry commands (ui_commands_windows.c) --------------------------- + * command_state asks the features first, then the base; it returns 1 when + * somebody implements the command (unimplemented commands report disabled). + * execute runs an enabled command (0 when disabled or unimplemented); + * dispatch runs it without the enabled check (callers that checked). */ +int axyne_win_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out); +int axyne_win_command_enabled(AxyneWindowState *state, AxyneCommandId id); +int axyne_win_execute(AxyneWindowState *state, AxyneCommandId id); +int axyne_win_dispatch(AxyneWindowState *state, AxyneCommandId id); +/* Menu text of the command's primary key binding ("Ctrl+K Ctrl+O"), "" when + * unbound; snprintf-like. */ +size_t axyne_win_command_key_text(const AxyneWindowState *state, AxyneCommandId id, + char *buffer, size_t capacity); +/* Rebuilds state->keymap from the registry defaults and the effective + * preferences (called by axyne_win_apply_preferences). */ +void axyne_win_keymap_rebuild(AxyneWindowState *state); +void axyne_win_keymap_destroy(AxyneWindowState *state); +/* Key-down routing (WM_KEYDOWN/WM_SYSKEYDOWN virtual key): feeds the key map + * and runs the bound command; 1 = consumed. Chords show the hint + * "(Ctrl+K) 두 번째 키를 기다리는 중…" for 1.5 s. AltGr (right Alt reported as + * Ctrl+Alt) is text input. Commands that are disabled leave the key to the + * focused control, except the 11 legacy-action shortcuts (consumed as + * before) and the second stroke of a chord. */ +int axyne_win_handle_key(AxyneWindowState *state, WPARAM virtual_key); +/* Palette text field: only the palette shortcuts (파일 이동, 명령 팔레트). */ +int axyne_win_palette_key(AxyneWindowState *state, WPARAM virtual_key); +void axyne_win_chord_timer(AxyneWindowState *state); +/* Non-zero while debug-context keys apply. */ +int axyne_win_debugging(AxyneWindowState *state); +/* Sets (NULL or "" clears) the transient status-bar text. */ +void axyne_win_show_status_text(AxyneWindowState *state, const char *utf8); + +/* Command palette ">" mode lists the registry: Windows commands flagged + * PALETTE that somebody implements, titles without the trailing "...", + * key text from the key map. Rows of the 25 palette-table commands carry + * their AxynePaletteCommandId (so 파일/줄/심볼 이동 switch the mode as before); + * the others carry AXYNE_WIN_PALETTE_REGISTRY_PAYLOAD + AxyneCommandId. + * Called after the controller rebuilt its list (open, input change). */ +enum { AXYNE_WIN_PALETTE_REGISTRY_PAYLOAD = 1000 }; +void axyne_win_palette_commands(AxyneWindowState *state); +/* Help > 키보드 단축키 참조: every bound command per menu group, from the + * effective key map. */ +void axyne_win_show_shortcuts(AxyneWindowState *state); +/* Fills the popup of menu-bar item `index` (0 파일 ... 6 도움말). */ +void axyne_win_menu_bar_fill(AxyneWindowState *state, int index, AxyneWinMenu *menu); + +/* Commands implemented by the base (ui_windows.c); features take a command + * over by implementing it in their own hooks (features run first). */ +int axyne_win_base_command_state(AxyneWindowState *state, AxyneCommandId id, + AxyneWinCommandState *out); +int axyne_win_base_command(AxyneWindowState *state, AxyneCommandId id); + +/* ---- owner-drawn popup menus (ui_windows.c) ------------------------------ + * create -> add/separator/submenu -> track -> destroy (destroy frees the + * whole tree; submenus belong to their root). Labels and shortcut text are + * UTF-8. track takes client coordinates of the main window; with + * return_command it returns the picked id (0 = none) instead of sending + * WM_COMMAND. */ +enum { + AXYNE_WIN_MENU_DISABLED = 1u << 0, + AXYNE_WIN_MENU_CHECKED = 1u << 1, + AXYNE_WIN_MENU_RADIO = 1u << 2 /* the check is drawn as a dot */ +}; +AxyneWinMenu *axyne_win_menu_create(void); +void axyne_win_menu_add(AxyneWinMenu *menu, UINT id, const char *label, + const char *shortcut, unsigned flags); +void axyne_win_menu_separator(AxyneWinMenu *menu); +AxyneWinMenu *axyne_win_menu_submenu(AxyneWinMenu *menu, const char *label, + unsigned flags); +int axyne_win_menu_count(const AxyneWinMenu *menu); +int axyne_win_menu_has_enabled(const AxyneWinMenu *menu); +void axyne_win_menu_enable_at(AxyneWinMenu *menu, int position, int enabled); +UINT axyne_win_menu_track(AxyneWindowState *state, AxyneWinMenu *menu, POINT point, + int return_command); +void axyne_win_menu_destroy(AxyneWinMenu *menu); +/* Adds the recent files (File > 최근 항목); returns how many. */ +int axyne_win_menu_add_recent_files(AxyneWindowState *state, AxyneWinMenu *menu); + +/* ---- exported helpers (implemented in ui_windows.c) ---------------------- */ + +/* UTF-8 <-> UTF-16 conversions; malloc'd results (free()), NULL on invalid + * input or out of memory. */ +wchar_t *axyne_win_wide(const char *utf8); +char *axyne_win_utf8(const wchar_t *wide); + +/* The active document the user can act on; NULL while only the hidden empty + * placeholder exists (empty state). */ +AxyneDocument *axyne_win_active_document(AxyneWindowState *state); +int axyne_win_empty_state(AxyneWindowState *state); +/* Copies the Scintilla text into the active document model (shows an error + * and returns 0 when that fails). Call before reading document contents. */ +int axyne_win_capture_editor(AxyneWindowState *state); +/* Opens `path` in a normal tab (or activates its tab) and, when `line` is + * non-zero, moves the caret to the 1-based line/column (byte column, 0 = 1) + * and centres the line. Relative paths resolve below the workspace root. + * Returns 1 when the document is active afterwards. */ +int axyne_win_open_document(AxyneWindowState *state, const char *path, + size_t line, size_t column); +/* Moves the caret of the active document (1-based line, byte or UTF-16 + * column) and focuses the editor. */ +void axyne_win_goto(AxyneWindowState *state, size_t line, size_t column, + int utf16_column); +/* Read-only document (diff, help text, ...); `text` is copied. */ +int axyne_win_open_virtual_document(AxyneWindowState *state, const char *title, + const char *text, size_t length); +void axyne_win_new_document(AxyneWindowState *state); +/* Saves the active document (Save As dialog for untitled ones); 1 = saved. */ +int axyne_win_save_active(AxyneWindowState *state); +/* Activates tab `index` and saves it; 1 = saved. */ +int axyne_win_save_document(AxyneWindowState *state, size_t index); +/* Closes tab `index` (asks to save a dirty document); 1 = closed. */ +int axyne_win_close_document(AxyneWindowState *state, size_t index); +void axyne_win_update_title(AxyneWindowState *state); + +/* Opens `root` as the workspace folder (explorer, watcher, workspace + * settings, Git panel); 1 on success, errors are shown to the user. */ +int axyne_win_open_folder(AxyneWindowState *state, const char *root); + +/* Output panel (the shared output/terminal text control). `utf8` text is + * converted for the ANSI control; `bytes` are process output in the ANSI + * code page. STDERR output gets a "[stderr] " prefix. */ +void axyne_win_output_append(AxyneWindowState *state, const char *utf8, + AxyneProcessStream stream); +void axyne_win_output_append_bytes(AxyneWindowState *state, const char *bytes, + size_t length, AxyneProcessStream stream); +void axyne_win_output_clear(AxyneWindowState *state); + +/* Non-zero while a build/run/terminal process or the debugger runs. */ +int axyne_win_process_busy(AxyneWindowState *state); +/* Starts the build step (run_step 0) or the run step (1) of `plan` with its + * output in the Output panel and build problems parsed into the Problems + * panel; `clear` empties the panel first. 1 when the process started. The + * plan is not retained. */ +int axyne_win_run_plan_step(AxyneWindowState *state, const AxyneLanguagePlan *plan, + int run_step, int clear); + +/* Bottom panel tabs. select shows the panel with `tab` (ignored while the + * tab is disabled); `focus` moves the keyboard to the tab's control. + * set_tab_enabled shows or hides a tab in the strip (OUTPUT is always + * enabled; hiding the selected tab selects OUTPUT). panel_body gives the + * area below the strip and returns 0 while the panel is hidden. */ +void axyne_win_select_panel_tab(AxyneWindowState *state, AxyneWinPanelTab tab, + int focus); +AxyneWinPanelTab axyne_win_panel_tab(const AxyneWindowState *state); +void axyne_win_panel_set_tab_enabled(AxyneWindowState *state, AxyneWinPanelTab tab, + int enabled); +int axyne_win_panel_tab_enabled(const AxyneWindowState *state, AxyneWinPanelTab tab); +int axyne_win_panel_body(const AxyneWindowState *state, RECT *body); + +/* Re-runs the layout of every child window and repaints. */ +void axyne_win_layout(AxyneWindowState *state); +/* Repaints `rect` of the main window (NULL = everything). */ +void axyne_win_invalidate(AxyneWindowState *state, const RECT *rect); +/* Current explorer column width and bottom panel height (0 when hidden). */ +int axyne_win_sidebar_width(const AxyneWindowState *state); +int axyne_win_panel_height(const AxyneWindowState *state); +/* Re-applies the effective preferences (theme, editor, key map). */ +void axyne_win_apply_preferences(AxyneWindowState *state); + +/* Theme colours of the shell for feature painting. */ +typedef enum AxyneWinColor { + AXYNE_WIN_COLOR_BACKGROUND = 0, + AXYNE_WIN_COLOR_PANEL, + AXYNE_WIN_COLOR_TOOLBAR, + AXYNE_WIN_COLOR_BORDER, + AXYNE_WIN_COLOR_TEXT, + AXYNE_WIN_COLOR_MUTED, + AXYNE_WIN_COLOR_ACCENT, + AXYNE_WIN_COLOR_OUTPUT_BACKGROUND, + AXYNE_WIN_COLOR_PANEL_HEADER, + AXYNE_WIN_COLOR_STATUS_BACKGROUND, + AXYNE_WIN_COLOR_SELECTION, + AXYNE_WIN_COLOR_INDICATOR, + AXYNE_WIN_COLOR_ERROR, + AXYNE_WIN_COLOR_WARNING, + AXYNE_WIN_COLOR_COUNT +} AxyneWinColor; +COLORREF axyne_win_color(AxyneWinColor which); +/* Painting helpers shared with the shell (right/bottom edges exclusive). */ +void axyne_win_fill(HDC dc, RECT rect, COLORREF color); +void axyne_win_round_fill(HDC dc, RECT rect, int radius, COLORREF fill, + COLORREF border); +void axyne_win_text(HDC dc, HFONT font, COLORREF color, RECT rect, + const wchar_t *text, UINT alignment); +int axyne_win_measure_text(HFONT font, const wchar_t *text); + +#endif diff --git a/tests/test_commands.c b/tests/test_commands.c index 499f0f1..72dfd10 100644 --- a/tests/test_commands.c +++ b/tests/test_commands.c @@ -6,7 +6,7 @@ #include "axyne/palette.h" #include "axyne/preferences.h" -static int axyne_test_keys_duplicate(AxynePlatform platform) +static int axyne_test_keys_duplicate(AxyneCommandPlatform platform) { size_t count = axyne_command_count(); for (size_t i = 0; i < count; ++i) { @@ -58,7 +58,7 @@ int axyne_test_commands(const char *root) AXYNE_TEST_CHECK(info->keywords != NULL && info->keywords[0] != '\0'); AXYNE_TEST_CHECK(info->group < AXYNE_COMMAND_GROUP_COUNT); AXYNE_TEST_CHECK(info->platforms != 0 && - (info->platforms & ~(unsigned)AXYNE_PLATFORM_BOTH) == 0); + (info->platforms & ~(unsigned)AXYNE_COMMAND_PLATFORM_BOTH) == 0); AXYNE_TEST_CHECK(axyne_command_find(info->name) == info); AXYNE_TEST_EQ_INT(axyne_command_id(info->name), info->id); /* Ids are unique: the lookup above finds this entry, not another. */ @@ -73,19 +73,19 @@ int axyne_test_commands(const char *root) AXYNE_TEST_CHECK(info->legacy_action < 0 || info->legacy_action != other->legacy_action); } - AXYNE_TEST_CHECK(axyne_command_title(info->id, AXYNE_PLATFORM_WINDOWS) == + AXYNE_TEST_CHECK(axyne_command_title(info->id, AXYNE_COMMAND_PLATFORM_WINDOWS) == info->title); if (info->mac_title != NULL) - AXYNE_TEST_CHECK(axyne_command_title(info->id, AXYNE_PLATFORM_MACOS) == + AXYNE_TEST_CHECK(axyne_command_title(info->id, AXYNE_COMMAND_PLATFORM_MACOS) == info->mac_title); } AXYNE_TEST_CHECK(axyne_command_find("file.unknown") == NULL); AXYNE_TEST_CHECK(axyne_command_find(NULL) == NULL); AXYNE_TEST_STREQ(axyne_command_name(AXYNE_COMMAND_FILE_SAVE_ALL), "file.saveAll"); AXYNE_TEST_STREQ(axyne_command_title(AXYNE_COMMAND_FILE_SAVE_ALL, - AXYNE_PLATFORM_WINDOWS), "모두 저장"); + AXYNE_COMMAND_PLATFORM_WINDOWS), "모두 저장"); AXYNE_TEST_STREQ(axyne_command_title(AXYNE_COMMAND_FILE_EXIT, - AXYNE_PLATFORM_MACOS), "Axyne 종료"); + AXYNE_COMMAND_PLATFORM_MACOS), "Axyne 종료"); AXYNE_TEST_STREQ(axyne_command_name(AXYNE_COMMAND_COUNT), ""); /* Every palette command maps to exactly one registry command. */ @@ -111,43 +111,43 @@ int axyne_test_commands(const char *root) /* Defaults from the gap report (D1/D2) and Figma. */ AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_DEBUG_START, - AXYNE_PLATFORM_WINDOWS, 0), "F5"); + AXYNE_COMMAND_PLATFORM_WINDOWS, 0), "F5"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING, - AXYNE_PLATFORM_WINDOWS, 0), "Ctrl+F5"); + AXYNE_COMMAND_PLATFORM_WINDOWS, 0), "Ctrl+F5"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_DEBUG_STOP, - AXYNE_PLATFORM_MACOS, 0), "Shift+F5"); + AXYNE_COMMAND_PLATFORM_MACOS, 0), "Shift+F5"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_FILE_OPEN_FOLDER, - AXYNE_PLATFORM_WINDOWS, 0), "Ctrl+K Ctrl+O"); + AXYNE_COMMAND_PLATFORM_WINDOWS, 0), "Ctrl+K Ctrl+O"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_FILE_OPEN_FOLDER, - AXYNE_PLATFORM_WINDOWS, 1), "Ctrl+Shift+O"); + AXYNE_COMMAND_PLATFORM_WINDOWS, 1), "Ctrl+Shift+O"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_FILE_OPEN_FOLDER, - AXYNE_PLATFORM_MACOS, 0), "Cmd+K Cmd+O"); + AXYNE_COMMAND_PLATFORM_MACOS, 0), "Cmd+K Cmd+O"); AXYNE_TEST_STREQ(axyne_command_default_keys(AXYNE_COMMAND_HELP_KEYBOARD_SHORTCUTS, - AXYNE_PLATFORM_WINDOWS, 0), "Ctrl+K Ctrl+R"); + AXYNE_COMMAND_PLATFORM_WINDOWS, 0), "Ctrl+K Ctrl+R"); AXYNE_TEST_CHECK(axyne_command_default_keys(AXYNE_COMMAND_FILE_SAVE, - AXYNE_PLATFORM_WINDOWS, 2) == NULL); + AXYNE_COMMAND_PLATFORM_WINDOWS, 2) == NULL); /* Platform availability. */ AXYNE_TEST_CHECK(axyne_command_available(AXYNE_COMMAND_DEBUG_EXTERNAL_WINDBG, - AXYNE_PLATFORM_WINDOWS)); + AXYNE_COMMAND_PLATFORM_WINDOWS)); AXYNE_TEST_CHECK(!axyne_command_available(AXYNE_COMMAND_DEBUG_EXTERNAL_WINDBG, - AXYNE_PLATFORM_MACOS)); + AXYNE_COMMAND_PLATFORM_MACOS)); AXYNE_TEST_CHECK(axyne_command_available(AXYNE_COMMAND_TERMINAL_NEW_ZSH, - AXYNE_PLATFORM_MACOS)); + AXYNE_COMMAND_PLATFORM_MACOS)); AXYNE_TEST_CHECK(axyne_command_default_keys(AXYNE_COMMAND_HELP_CONTENTS, - AXYNE_PLATFORM_WINDOWS, 0) == NULL); + AXYNE_COMMAND_PLATFORM_WINDOWS, 0) == NULL); /* No two commands share a default key text per platform (debug-context * bindings excepted). The keymap test repeats this on parsed keys. */ - AXYNE_TEST_CHECK(!axyne_test_keys_duplicate(AXYNE_PLATFORM_WINDOWS)); - AXYNE_TEST_CHECK(!axyne_test_keys_duplicate(AXYNE_PLATFORM_MACOS)); + AXYNE_TEST_CHECK(!axyne_test_keys_duplicate(AXYNE_COMMAND_PLATFORM_WINDOWS)); + AXYNE_TEST_CHECK(!axyne_test_keys_duplicate(AXYNE_COMMAND_PLATFORM_MACOS)); AXYNE_TEST_STREQ(axyne_command_group_title(AXYNE_COMMAND_GROUP_BUILD), "빌드"); AXYNE_TEST_STREQ(axyne_command_group_title(AXYNE_COMMAND_GROUP_COUNT), ""); #ifdef __APPLE__ - AXYNE_TEST_EQ_INT(axyne_platform_current(), AXYNE_PLATFORM_MACOS); + AXYNE_TEST_EQ_INT(axyne_command_platform_current(), AXYNE_COMMAND_PLATFORM_MACOS); #else - AXYNE_TEST_EQ_INT(axyne_platform_current(), AXYNE_PLATFORM_WINDOWS); + AXYNE_TEST_EQ_INT(axyne_command_platform_current(), AXYNE_COMMAND_PLATFORM_WINDOWS); #endif return 1; } diff --git a/tests/test_keymap.c b/tests/test_keymap.c index 29b777c..9375f06 100644 --- a/tests/test_keymap.c +++ b/tests/test_keymap.c @@ -1,7 +1,10 @@ #include "test_support.h" +#include #include +#include "axyne/preferences.h" + #include "axyne/keymap.h" static AxyneKeyStroke stroke_of(const char *text) @@ -125,6 +128,65 @@ static int test_parse_format(void) return 1; } +/* User bindings win over registry defaults (legacy action bindings from + * the preferences included); hard-native keys (Tab) stay native. */ +static int test_user_bindings_win(void) +{ + AxyneKeymap map; + AxynePreferences *preferences = (AxynePreferences *)malloc(sizeof(*preferences)); + AxyneKeyBinding *binding; + AXYNE_TEST_CHECK(preferences != NULL); + axyne_preferences_defaults(preferences); + binding = (AxyneKeyBinding *)axyne_preferences_find_binding(preferences, AXYNE_ACTION_RUN); + AXYNE_TEST_CHECK(binding != NULL); + binding->modifiers = AXYNE_KEY_MODIFIER_CONTROL; + (void)snprintf(binding->key, sizeof(binding->key), "%s", "D"); + binding->enabled = 1; + binding = (AxyneKeyBinding *)axyne_preferences_find_binding(preferences, AXYNE_ACTION_NEW); + AXYNE_TEST_CHECK(binding != NULL); + binding->modifiers = AXYNE_KEY_MODIFIER_CONTROL | AXYNE_KEY_MODIFIER_SHIFT; + (void)snprintf(binding->key, sizeof(binding->key), "%s", "N"); + AXYNE_TEST_STATUS(axyne_keymap_build(&map, AXYNE_COMMAND_PLATFORM_WINDOWS, preferences), + AXYNE_STATUS_OK); + /* Run = Ctrl+D beats 줄 복제; New = Ctrl+Shift+N beats 새 프로젝트. */ + AXYNE_TEST_CHECK(feed_is(&map, "Ctrl+D", 0, 0, AXYNE_KEYMAP_COMMAND, + AXYNE_COMMAND_DEBUG_RUN_WITHOUT_DEBUGGING)); + AXYNE_TEST_EQ_INT(axyne_keymap_bindings(&map, AXYNE_COMMAND_EDIT_DUPLICATE_LINE, NULL, 0), 0); + AXYNE_TEST_CHECK(feed_is(&map, "Ctrl+Shift+N", 0, 0, AXYNE_KEYMAP_COMMAND, + AXYNE_COMMAND_FILE_NEW)); + AXYNE_TEST_EQ_INT(axyne_keymap_bindings(&map, AXYNE_COMMAND_FILE_NEW_PROJECT, NULL, 0), 0); + /* The custom binding replaced New's own default key. */ + AXYNE_TEST_CHECK(feed_is(&map, "Ctrl+N", 0, 0, AXYNE_KEYMAP_NONE, AXYNE_COMMAND_NONE)); + AXYNE_TEST_EQ_INT(axyne_keymap_conflicts(&map, NULL, 0), 0); + { + const char *undo[] = {"Ctrl+Z"}; + const char *tab[] = {"Tab"}; + /* A native key the user chose explicitly works ... */ + AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "file.save", undo, 1), + AXYNE_STATUS_OK); + AXYNE_TEST_CHECK(feed_is(&map, "Ctrl+Z", 0, 0, AXYNE_KEYMAP_COMMAND, + AXYNE_COMMAND_FILE_SAVE)); + /* ... but Tab stays with the text control. */ + AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "file.close", tab, 1), + AXYNE_STATUS_OK); + AXYNE_TEST_CHECK(feed_is(&map, "Tab", 0, 0, AXYNE_KEYMAP_NONE, AXYNE_COMMAND_NONE)); + AXYNE_TEST_EQ_INT(axyne_keymap_bindings(&map, AXYNE_COMMAND_EDIT_INDENT, NULL, 0), 1); + } + /* Debug-context defaults are not taken by plain user bindings. */ + { + const char *f10[] = {"F10"}; + AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "view.outline", f10, 1), + AXYNE_STATUS_OK); + AXYNE_TEST_CHECK(feed_is(&map, "F10", 0, AXYNE_KEYMAP_CONTEXT_DEBUGGING, + AXYNE_KEYMAP_COMMAND, AXYNE_COMMAND_DEBUG_STEP_OVER)); + AXYNE_TEST_CHECK(feed_is(&map, "F10", 0, 0, AXYNE_KEYMAP_COMMAND, + AXYNE_COMMAND_VIEW_OUTLINE)); + } + axyne_keymap_destroy(&map); + free(preferences); + return 1; +} + static int test_windows_map(void) { AxyneKeymap map; @@ -134,7 +196,7 @@ static int test_windows_map(void) char buffer[96]; size_t conflict_count; - AXYNE_TEST_STATUS(axyne_keymap_init(&map, AXYNE_PLATFORM_WINDOWS), AXYNE_STATUS_OK); + AXYNE_TEST_STATUS(axyne_keymap_init(&map, AXYNE_COMMAND_PLATFORM_WINDOWS), AXYNE_STATUS_OK); conflict_count = axyne_keymap_conflicts(&map, conflicts, 8); for (size_t i = 0; i < conflict_count && i < 8; ++i) fprintf(stderr, "default conflict: %s / %s\n", @@ -252,9 +314,16 @@ static int test_windows_map(void) AXYNE_COMMAND_FILE_SAVE)); AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "file.nope", keys, 1), AXYNE_STATUS_NOT_FOUND); - /* Binding Save to Ctrl+O collides with Open. */ + /* Binding Save to Ctrl+O takes the key from Open's default. */ AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "file.save", open_keys, 1), AXYNE_STATUS_OK); + AXYNE_TEST_EQ_INT(axyne_keymap_conflicts(&map, NULL, 0), 0); + AXYNE_TEST_EQ_INT(axyne_keymap_bindings(&map, AXYNE_COMMAND_FILE_OPEN, NULL, 0), 0); + AXYNE_TEST_CHECK(feed_is(&map, "Ctrl+O", 0, 0, AXYNE_KEYMAP_COMMAND, + AXYNE_COMMAND_FILE_SAVE)); + /* Two user bindings on the same keys collide. */ + AXYNE_TEST_STATUS(axyne_keymap_set_command_text(&map, "file.open", open_keys, 1), + AXYNE_STATUS_OK); conflict_count = axyne_keymap_conflicts(&map, conflicts, 8); AXYNE_TEST_EQ_INT(conflict_count, 1); AXYNE_TEST_CHECK(!conflicts[0].prefix && @@ -299,6 +368,7 @@ static int test_windows_map(void) } axyne_keymap_destroy(&map); axyne_keymap_destroy(&map); /* safe twice */ + AXYNE_TEST_CHECK(test_user_bindings_win()); return 1; } @@ -306,7 +376,7 @@ static int test_macos_map(void) { AxyneKeymap map; char buffer[96]; - AXYNE_TEST_STATUS(axyne_keymap_init(&map, AXYNE_PLATFORM_MACOS), AXYNE_STATUS_OK); + AXYNE_TEST_STATUS(axyne_keymap_init(&map, AXYNE_COMMAND_PLATFORM_MACOS), AXYNE_STATUS_OK); AXYNE_TEST_EQ_INT(axyne_keymap_conflicts(&map, NULL, 0), 0); AXYNE_TEST_CHECK(map.altgr_exclusion == 0); AXYNE_TEST_CHECK(feed_is(&map, "F5", 0, 0, AXYNE_KEYMAP_COMMAND, AXYNE_COMMAND_DEBUG_START)); diff --git a/tests/test_menu_consistency.c b/tests/test_menu_consistency.c index d1915d1..ecdf7ae 100644 --- a/tests/test_menu_consistency.c +++ b/tests/test_menu_consistency.c @@ -1,12 +1,19 @@ /* Static consistency checks between the menu definitions and their handlers. * The native adapters cannot be built or run on every platform, so this reads * both adapter sources as text and verifies, for each menu entry: - * macOS - every action selector installed in axyne_install_menu has an - * implementation in AxyneWorkspaceView (or is a system selector); - * Windows - every command id passed to axyne_menu_add is unique, stays out - * of the Open Recent id range, and is handled by WM_COMMAND or - * axyne_action_command; - * both - the shared menu entries exist on both platforms. + * macOS - every action selector installed in axyne_install_menu and every + * base handler of the registry commands (ui_commands_macos.m) has + * an implementation in AxyneWorkspaceView, in ui_macos.m or a + * category file (or is a system selector); the menu labels come + * from the command registry and the macOS menu layout; + * Windows - the menus (ui_commands_windows.c menu table) list every + * registry command available on Windows; the base and every + * feature file (ui__windows.c) report a state only for + * commands they handle and vice versa (no enabled entry that does + * nothing); legacy ids stay unique and are handled; + * both - the shared menu entries exist on both platforms (Windows: + * registry titles of menu commands; macOS: registry titles placed + * by the macOS menu layout). * The repository root is the first argument. Portable C17 only (no POSIX * string helpers) so it runs on the MSVC, clang and gcc CI builds. */ #include @@ -99,13 +106,79 @@ static int selector_is_system(const char *name) strcmp(name, "terminate:") == 0; } +/* macOS sources that implement AxyneWorkspaceView methods besides the main + * @implementation in ui_macos.m: the command routing and the feature + * category files (one per feature category). */ +static const char *const macos_category_files[] = { + "src/features/ui/platform/macos/ui_commands_macos.m", + "src/features/ui/platform/macos/ui_file_macos.m", + "src/features/ui/platform/macos/ui_edit_macos.m", + "src/features/ui/platform/macos/ui_view_macos.m", + "src/features/ui/platform/macos/ui_build_macos.m", + "src/features/ui/platform/macos/ui_debug_macos.m", + "src/features/ui/platform/macos/ui_terminal_macos.m", + "src/features/ui/platform/macos/ui_status_macos.m", + "src/features/ui/platform/macos/ui_tools_macos.m", + "src/features/ui/platform/macos/ui_help_macos.m", + "src/features/ui/platform/macos/ui_lsp_nav_macos.m", + "src/features/ui/platform/macos/ui_diag_macos.m", + "src/features/ui/platform/macos/ui_release_macos.m" +}; +#define MACOS_CATEGORY_FILE_COUNT \ + (sizeof(macos_category_files) / sizeof(macos_category_files[0])) + +/* True when `signature` ("\n- (void)name:(") is implemented by the view: + * in the main @implementation of ui_macos.m or in a category file. */ +static int macos_view_implements(const char *implementation, + char *const *categories, const char *signature) +{ + size_t i; + if (implementation != NULL && strstr(implementation, signature) != NULL) return 1; + for (i = 0; i < MACOS_CATEGORY_FILE_COUNT; ++i) + if (categories[i] != NULL && strstr(categories[i], signature) != NULL) return 1; + return 0; +} + +/* Every @selector(...) in `text` must be a system action or an action + * method (one sender argument) implemented by the workspace view. */ +static size_t check_macos_selector_text(const char *text, const char *where, + const char *implementation, + char *const *categories) +{ + const char *cursor = text; + size_t count = 0; + while ((cursor = strstr(cursor, "@selector(")) != NULL) { + char name[96]; + char signature[128]; + const char *close; + size_t length; + cursor += strlen("@selector("); + close = strchr(cursor, ')'); + if (close == NULL) break; + length = (size_t)(close - cursor); + if (length == 0 || length >= sizeof(name)) { FAIL("bad selector text"); break; } + memcpy(name, cursor, length); + name[length] = '\0'; + ++count; + if (selector_is_system(name)) continue; + if (name[length - 1] != ':') { FAIL("menu selector %s takes no sender", name); continue; } + (void)snprintf(signature, sizeof(signature), "\n- (void)%s(", name); + if (!macos_view_implements(implementation, categories, signature)) + FAIL("macOS %s selector %s has no implementation", where, name); + } + return count; +} + static void check_macos_selectors(void) { char *source = read_file("src/features/ui/platform/macos/ui_macos.m"); - char *install, *implementation; - const char *cursor; - size_t count = 0; - if (source == NULL) return; + char *categories[MACOS_CATEGORY_FILE_COUNT]; + char *install = NULL, *implementation = NULL, *base = NULL; + size_t i, count = 0; + for (i = 0; i < MACOS_CATEGORY_FILE_COUNT; ++i) + categories[i] = read_file(macos_category_files[i]); + if (source == NULL || categories[0] == NULL) goto done; + /* The application menu installed by hand. */ install = region(source, "static void axyne_install_menu(NSApplication *application,\n" " AxyneWorkspaceView *workspace)\n{", "int axyne_ui_run"); @@ -113,30 +186,118 @@ static void check_macos_selectors(void) * that the region already contains. */ implementation = region(source, "@implementation AxyneWorkspaceView\n", "@interface AxyneApplicationDelegate"); - if (install != NULL && implementation != NULL) { - cursor = install; - while ((cursor = strstr(cursor, "@selector(")) != NULL) { - char name[96]; - char signature[128]; - const char *close; - size_t length; - cursor += strlen("@selector("); - close = strchr(cursor, ')'); - if (close == NULL) break; - length = (size_t)(close - cursor); - if (length == 0 || length >= sizeof(name)) { FAIL("bad selector text"); break; } - memcpy(name, cursor, length); - name[length] = '\0'; - ++count; - if (selector_is_system(name)) continue; - if (name[length - 1] != ':') { FAIL("menu selector %s takes no sender", name); continue; } - (void)snprintf(signature, sizeof(signature), "\n- (void)%s(", name); - if (strstr(implementation, signature) == NULL) - FAIL("macOS menu selector %s has no implementation", name); - } + /* Registry menu items run -performCommand:, whose base handlers are the + * selectors returned by axyne_macos_base_action. */ + base = region(categories[0], "static SEL axyne_macos_base_action(", + "/* ---- key map"); + if (install != NULL && implementation != NULL && base != NULL) { + count += check_macos_selector_text(install, "menu", implementation, categories); + count += check_macos_selector_text(base, "command", implementation, categories); + CHECK(macos_view_implements(implementation, categories, "\n- (void)performCommand:(")); CHECK(count >= 40); } - free(install); free(implementation); free(source); +done: + free(install); free(implementation); free(base); free(source); + for (i = 0; i < MACOS_CATEGORY_FILE_COUNT; ++i) free(categories[i]); +} + +/* ---- macOS: menu labels from the command registry --------------------- */ + +/* Copies the C string literal at *cursor (blanks skipped) and advances past + * it. Returns 0 when there is none. */ +static int read_literal(const char **cursor, char *out, size_t capacity) +{ + const char *p = *cursor; + const char *end; + size_t length; + while (isspace((unsigned char)*p)) ++p; + if (*p != '"') return 0; + end = strchr(p + 1, '"'); + if (end == NULL) return 0; + length = (size_t)(end - p - 1); + if (length >= capacity) length = capacity - 1; + memcpy(out, p + 1, length); + out[length] = '\0'; + *cursor = end + 1; + return 1; +} + +static int label_matches(const char *title, const char *label) +{ + size_t length = strlen(label); + if (strcmp(title, label) == 0) return 1; + if (strncmp(title, label, length) != 0) return 0; + return strcmp(title + length, "\xe2\x80\xa6") == 0 || strcmp(title + length, "...") == 0; +} + +/* True when the macOS menus show `label`: a command of the registry table + * (src/features/commands/commands.c; its macOS title when it has one) whose + * id the macOS menu layout (ui_commands_macos.m, ITEM(...)) lists. */ +static int macos_menu_has_label(const char *registry, const char *layout, + const char *label) +{ + const char *cursor = registry; + while ((cursor = strstr(cursor, "\n E(")) != NULL) { + char id[80], name[80], title[160], mac[160], item[96]; + const char *p = cursor + strlen("\n E("); + size_t n = 0; + cursor = p; + while (is_identifier_char((unsigned char)*p) && n + 1 < sizeof(id)) id[n++] = *p++; + id[n] = '\0'; + if (*p != ',') continue; + ++p; + if (!read_literal(&p, name, sizeof(name))) continue; + while (isspace((unsigned char)*p) || *p == ',') ++p; + if (!read_literal(&p, title, sizeof(title))) continue; + while (isspace((unsigned char)*p) || *p == ',') ++p; + mac[0] = '\0'; + if (*p == '"') (void)read_literal(&p, mac, sizeof(mac)); + if (!label_matches(mac[0] != '\0' ? mac : title, label)) continue; + (void)snprintf(item, sizeof(item), "ITEM(%s)", id); + if (strstr(layout, item) != NULL) return 1; + } + return 0; +} + +/* Shared labels whose macOS menu entry now shows the registry title. */ +static const char *macos_label_alias(const char *label) +{ + if (strcmp(label, "preferences.json 열기") == 0) return "settings.json 열기"; + if (strcmp(label, "실행") == 0) return "디버깅하지 않고 실행"; + return label; +} + +static char *macos_registry, *macos_layout; + +static void load_macos_labels(void) +{ + char *commands = read_file("src/features/commands/commands.c"); + char *routing = read_file("src/features/ui/platform/macos/ui_commands_macos.m"); + if (commands != NULL) + macos_registry = region(commands, "static const AxyneCommandInfo command_table[] = {", + "\n};"); + if (routing != NULL) + macos_layout = region(routing, "static const AxyneMacMenuEntry axyne_macos_file_menu[]", + "/* ---- base handlers"); + free(commands); free(routing); +} + +static void free_macos_labels(void) +{ + free(macos_registry); free(macos_layout); + macos_registry = macos_layout = NULL; +} + +static int macos_has_label(const char *label) +{ + static int loaded; + if (!loaded) { + loaded = 1; + load_macos_labels(); + (void)atexit(free_macos_labels); + } + if (macos_registry == NULL || macos_layout == NULL) return 0; + return macos_menu_has_label(macos_registry, macos_layout, macos_label_alias(label)); } /* ---- Windows: command ids ---------------------------------------------- */ @@ -224,64 +385,286 @@ static const EnumEntry *find_entry(const char *name) return NULL; } +/* Body of the function whose definition starts with `signature` (up to the + * first line that is a lone "}"); NULL when missing. */ +static char *function_body(const char *text, const char *signature) +{ + const char *begin = strstr(text, signature); + const char *end; + if (begin == NULL) { FAIL("missing function: %s", signature); return NULL; } + end = strstr(begin, "\n}\n"); + if (end == NULL) { FAIL("unterminated function: %s", signature); return NULL; } + return copy_text(begin, (size_t)(end - begin) + 2); +} + +/* ---- Windows: registry commands ---------------------------------------- */ + +#define MAX_COMMANDS 256 +typedef struct { + char name[96]; /* AXYNE_COMMAND_... */ + char title[160]; /* Windows title (UTF-8) */ + int windows; /* available on Windows */ + int context; /* explorer context menu group */ +} RegistryCommand; +static RegistryCommand registry[MAX_COMMANDS]; +static size_t registry_count; + +static RegistryCommand *find_command(const char *name) +{ + size_t i; + for (i = 0; i < registry_count; ++i) + if (strcmp(registry[i].name, name) == 0) return ®istry[i]; + return NULL; +} + +/* Splits the argument list starting after "E(" into at most `max` trimmed + * arguments; commas inside string literals and parentheses do not split. + * Returns the count, 0 on malformed text. */ +static size_t split_arguments(const char *p, char args[][192], size_t max) +{ + size_t count = 0, n = 0; + int depth = 0, quoted = 0; + for (; *p != '\0'; ++p) { + char c = *p; + if (quoted) { + if (c == '\\' && p[1] != '\0') { if (n + 2 < 192) { args[count][n++] = c; args[count][n++] = p[1]; } ++p; continue; } + if (c == '"') quoted = 0; + } else if (c == '"') quoted = 1; + else if (c == '(') ++depth; + else if (c == ')') { + if (depth == 0) break; + --depth; + } else if (c == ',' && depth == 0) { + args[count][n] = '\0'; + if (++count >= max) return 0; + n = 0; + continue; + } + if (n + 1 < 192 && !(n == 0 && isspace((unsigned char)c))) args[count][n++] = c; + } + if (*p != ')') return 0; + args[count][n] = '\0'; + ++count; + for (n = 0; n < count; ++n) { + size_t length = strlen(args[n]); + while (length > 0 && isspace((unsigned char)args[n][length - 1])) args[n][--length] = '\0'; + } + return count; +} + +/* Reads the AxyneCommandId enum and the registry table (titles, platforms, + * groups) from the shared sources. */ +static void load_registry(void) +{ + char *header = read_file("include/axyne/commands.h"); + char *table = read_file("src/features/commands/commands.c"); + char *ids = header != NULL ? region(header, "typedef enum AxyneCommandId {", "} AxyneCommandId;") : NULL; + const char *cursor; + if (ids != NULL) { + strip_comments(ids); + cursor = ids; + while ((cursor = strstr(cursor, "AXYNE_COMMAND_")) != NULL) { + size_t n = 0; + char name[96]; + while (is_identifier_char((unsigned char)cursor[n]) && n + 1 < sizeof(name)) { + name[n] = cursor[n]; ++n; + } + name[n] = '\0'; + cursor += n; + if (strcmp(name, "AXYNE_COMMAND_NONE") == 0 || strcmp(name, "AXYNE_COMMAND_COUNT") == 0 || + registry_count >= MAX_COMMANDS) + continue; + memcpy(registry[registry_count].name, name, n + 1); + ++registry_count; + } + } + CHECK(registry_count > 100); + if (table != NULL) { + char *rows = region(table, "static const AxyneCommandInfo command_table[] = {", "\n};"); + size_t parsed = 0; + cursor = rows; + while (cursor != NULL && (cursor = strstr(cursor, " E(")) != NULL) { + char args[14][192]; + char name[224]; + RegistryCommand *command; + cursor += strlen(" E("); + if (split_arguments(cursor, args, 14) != 14) { FAIL("cannot parse registry row"); continue; } + (void)snprintf(name, sizeof(name), "AXYNE_COMMAND_%s", args[0]); + command = find_command(name); + if (command == NULL) { FAIL("registry row %s is not in AxyneCommandId", name); continue; } + ++parsed; + if (args[2][0] == '"') { + size_t length = strlen(args[2]); + if (length >= 2 && length - 2 < sizeof(command->title)) { + memcpy(command->title, args[2] + 1, length - 2); + command->title[length - 2] = '\0'; + } + } + command->windows = strcmp(args[7], "BOTH") == 0 || strcmp(args[7], "WIN") == 0; + command->context = strcmp(args[5], "G(CONTEXT)") == 0; + } + CHECK(parsed == registry_count); + free(rows); + } + free(ids); free(header); free(table); +} + +/* Collects the `case AXYNE_COMMAND_X:` labels of a function body. */ +static size_t collect_cases(const char *body, char names[][96], size_t max) +{ + size_t count = 0; + const char *cursor = body; + while (cursor != NULL && (cursor = strstr(cursor, "case AXYNE_COMMAND_")) != NULL) { + size_t n = 0; + cursor += strlen("case "); + while (is_identifier_char((unsigned char)cursor[n]) && n + 1 < 96) { + if (count < max) names[count][n] = cursor[n]; + ++n; + } + if (count < max) { names[count][n] = '\0'; ++count; } + cursor += n; + } + return count; +} + +static int has_name(char names[][96], size_t count, const char *name) +{ + size_t i; + for (i = 0; i < count; ++i) if (strcmp(names[i], name) == 0) return 1; + return 0; +} + +/* A command whose state is reported (enabled = shown as implemented) must + * have a handler and vice versa: no enabled entry that does nothing. */ +static void check_state_matches_handler(const char *file, const char *source, + const char *state_signature, + const char *command_signature) +{ + static char states[MAX_COMMANDS][96], handlers[MAX_COMMANDS][96]; + char *state_body = function_body(source, state_signature); + char *command_body = function_body(source, command_signature); + size_t state_count, handler_count, i; + if (state_body != NULL && command_body != NULL) { + state_count = collect_cases(state_body, states, MAX_COMMANDS); + handler_count = collect_cases(command_body, handlers, MAX_COMMANDS); + for (i = 0; i < state_count; ++i) { + if (find_command(states[i]) == NULL) FAIL("%s: unknown command %s", file, states[i]); + if (!has_name(handlers, handler_count, states[i])) + FAIL("%s: %s has a state but no handler", file, states[i]); + } + for (i = 0; i < handler_count; ++i) + if (!has_name(states, state_count, handlers[i])) + FAIL("%s: %s has a handler but no state (it would stay disabled)", file, handlers[i]); + } + free(state_body); free(command_body); +} + +static const char *const windows_features[] = { + "file", "edit", "view", "build", "debug", "terminal", "status", "tools", "help", + "lsp_nav", "diag", "release" +}; + static void check_windows_commands(void) { char *source = read_file("src/features/ui/platform/windows/ui_windows.c"); - char *popups, *dispatch, *actions; + char *glue = read_file("src/features/ui/platform/windows/ui_commands_windows.c"); + char *cmake = read_file("src/features/ui/CMakeLists.txt"); + char *menus = NULL, *legacy = NULL, *actions = NULL, *base = NULL; const char *cursor; size_t i, j, menu_ids = 0; - if (source == NULL) return; + if (source == NULL || glue == NULL || cmake == NULL) goto done; { char *copy = copy_text(source, strlen(source)); if (copy != NULL) { strip_comments(copy); parse_enums(copy); free(copy); } } CHECK(entry_count > 60); - /* Ids must be unique, and plain command ids must not land in the Open - * Recent range [AXYNE_CMD_RECENT_BASE, +AXYNE_RECENT_FILES_LIMIT). */ + /* Legacy ids (toolbar, explorer context menu, recent files, panel + * buttons) stay unique, out of the Open Recent range and out of the + * registry menu id range [3000, 3000 + commands). */ { - const EnumEntry *base = find_entry("AXYNE_CMD_RECENT_BASE"); - CHECK(base != NULL); + const EnumEntry *recent = find_entry("AXYNE_CMD_RECENT_BASE"); + CHECK(recent != NULL); + CHECK(strstr(source, "#include \"ui_windows_internal.h\"") != NULL); for (i = 0; i < entry_count; ++i) { for (j = i + 1; j < entry_count; ++j) if (entries[i].value == entries[j].value) FAIL("duplicate command id %ld: %s and %s", entries[i].value, entries[i].name, entries[j].name); - if (base != NULL && strcmp(entries[i].name, "AXYNE_CMD_RECENT_BASE") != 0 && - entries[i].value >= base->value && - entries[i].value < base->value + AXYNE_RECENT_FILES_LIMIT) + if (recent != NULL && strcmp(entries[i].name, "AXYNE_CMD_RECENT_BASE") != 0 && + entries[i].value >= recent->value && + entries[i].value < recent->value + AXYNE_RECENT_FILES_LIMIT) FAIL("%s collides with the Open Recent id range", entries[i].name); + if (entries[i].value >= 3000 && entries[i].value < 3000 + (long)MAX_COMMANDS) + FAIL("%s collides with the registry menu id range", entries[i].name); } } - popups = region(source, "static void axyne_file_popup(HWND window, AxyneWindowState *state)\n{", - "static void axyne_fill("); - dispatch = region(source, " case WM_COMMAND: {", " case WM_CTLCOLOREDIT:"); - actions = region(source, "static int axyne_action_command(HWND window, AxyneWindowState *state, UINT command)\n{", - "static int axyne_action_key("); - if (popups == NULL || dispatch == NULL || actions == NULL) goto done; - cursor = popups; - while ((cursor = strstr(cursor, "axyne_menu_add(")) != NULL) { - char id[64]; - const char *p = strchr(cursor, ','); - size_t n = 0; - cursor += strlen("axyne_menu_add("); - if (p == NULL) break; - p = strchr(p + 1, ','); /* skip "menu, &pool," */ - if (p == NULL) break; - ++p; - while (isspace((unsigned char)*p)) ++p; - while (is_identifier_char((unsigned char)*p) && n + 1 < sizeof(id)) id[n++] = *p++; - id[n] = '\0'; - if (strcmp(id, "AXYNE_CMD_RECENT_BASE") == 0 || strcmp(id, "0") == 0) continue; - ++menu_ids; - if (strncmp(id, "AXYNE_CMD_", 10) == 0 || strncmp(id, "AXYNE_TERMINAL_", 15) == 0 || - strncmp(id, "AXYNE_DEBUG_", 12) == 0) { - if (strncmp(id, "AXYNE_DEBUG_", 12) != 0 && find_entry(id) == NULL) - FAIL("menu id %s is not defined by a parsed enum", id); - if (!has_word(dispatch, id) && !has_word(actions, id)) - FAIL("Windows menu command %s is never handled", id); - } else FAIL("unexpected menu id expression: %s", id); + /* Every registry id used by the Windows menus exists, and every Windows + * command (except the explorer context menu) is in the menus. */ + menus = region(glue, "/* ---- menu table", "/* ---- end of menu table"); + if (menus == NULL) goto done; + { + char *code = copy_text(menus, strlen(menus)); + if (code != NULL) strip_comments(code); + cursor = code; + while (cursor != NULL && (cursor = strstr(cursor, "AXYNE_COMMAND_")) != NULL) { + char name[96]; + size_t n = 0; + while (is_identifier_char((unsigned char)cursor[n]) && n + 1 < sizeof(name)) { + name[n] = cursor[n]; ++n; + } + name[n] = '\0'; + cursor += n; + if (strcmp(name, "AXYNE_COMMAND_NONE") == 0 || strncmp(name, "AXYNE_COMMAND_FLAG", 18) == 0) + continue; + ++menu_ids; + if (find_command(name) == NULL) FAIL("Windows menu id %s is not a registry command", name); + } + for (i = 0; i < registry_count; ++i) + if (registry[i].windows && !registry[i].context && code != NULL && + !has_word(code, registry[i].name)) + FAIL("Windows menus lack registry command %s", registry[i].name); + free(code); + } + CHECK(menu_ids >= 100); + /* The base and every feature report state only for commands they run. */ + check_state_matches_handler("ui_windows.c", source, + "int axyne_win_base_command_state(AxyneWindowState *state, AxyneCommandId id,", + "int axyne_win_base_command(AxyneWindowState *state, AxyneCommandId id)\n{"); + for (i = 0; i < sizeof(windows_features) / sizeof(windows_features[0]); ++i) { + char relative[160], file[96], state_signature[160], command_signature[160], table[96]; + char *feature; + (void)snprintf(file, sizeof(file), "ui_%s_windows.c", windows_features[i]); + (void)snprintf(relative, sizeof(relative), "src/features/ui/platform/windows/%s", file); + feature = read_file(relative); + if (feature == NULL) continue; + if (strstr(cmake, file) == NULL) FAIL("%s is not in src/features/ui/CMakeLists.txt", file); + (void)snprintf(table, sizeof(table), "&axyne_win_feature_%s", windows_features[i]); + if (strstr(glue, table) == NULL) FAIL("feature table lacks %s", table); + (void)snprintf(state_signature, sizeof(state_signature), + "static int axyne_win_%s_command_state(AxyneWindowState *state", + windows_features[i]); + (void)snprintf(command_signature, sizeof(command_signature), + "static int axyne_win_%s_command(AxyneWindowState *state, AxyneCommandId id)\n{", + windows_features[i]); + if (strstr(feature, state_signature) != NULL || strstr(feature, command_signature) != NULL) + check_state_matches_handler(file, feature, state_signature, command_signature); + free(feature); + } + /* Every legacy id the base maps a registry command onto is handled by the + * legacy dispatcher or the action switch. */ + base = function_body(source, "int axyne_win_base_command(AxyneWindowState *state, AxyneCommandId id)\n{"); + legacy = function_body(source, "static void axyne_legacy_command(HWND window, AxyneWindowState *state, UINT command)\n{"); + actions = function_body(source, "static int axyne_action_command(HWND window, AxyneWindowState *state, UINT command)\n{"); + if (base == NULL || legacy == NULL || actions == NULL) goto done; + for (i = 0; i < entry_count; ++i) { + if (!has_word(base, entries[i].name)) continue; + if (!has_word(legacy, entries[i].name) && !has_word(actions, entries[i].name) && + strncmp(entries[i].name, "AXYNE_CMD_EXPLORER_", 19) != 0 && + strcmp(entries[i].name, "AXYNE_CMD_WORKSPACE") != 0) + FAIL("legacy command %s used by the base is never handled", entries[i].name); } - CHECK(menu_ids >= 60); + /* Explorer and folder commands go through axyne_workspace_operation. */ + CHECK(strstr(legacy, "axyne_workspace_operation(window, state, command)") != NULL); /* Every id handled by the action switch must be inside the id range that * axyne_action_command accepts, and every id in that enum needs a case. */ { @@ -299,7 +682,8 @@ static void check_windows_commands(void) } } done: - free(popups); free(dispatch); free(actions); free(source); + free(menus); free(legacy); free(actions); free(base); + free(source); free(glue); free(cmake); } /* ---- both platforms ---------------------------------------------------- */ @@ -315,7 +699,52 @@ static int source_has_label(const char *source, char prefix, const char *label) return strstr(source, needle) != NULL || strstr(source, alternate) != NULL; } -static void check_shared_labels(void) +/* Menu entries both platforms offer. Windows builds its menus from the + * registry, so a Windows label must be the registry title (with or without + * "..."/"…") of a command in the Windows menu table. */ +static const char *const shared_labels[] = { + "새 파일", "열기", "폴더 열기", "저장", "다른 이름으로 저장", "닫기", + "실행 취소", "다시 실행", "잘라내기", "복사", "붙여넣기", "모두 선택", + "찾기", "바꾸기", "파일에서 찾기", "파일 이동", "줄로 이동", "줄 선택", + "줄 주석 토글", "줄 복제", "줄 위로 이동", "줄 아래로 이동", "들여쓰기", + "내어쓰기", "정의로 이동", "참조 찾기", "탐색기", "하단 패널", "출력", + "문제", "터미널", "확대", "축소", "기본 크기", "자동 줄 바꿈", "빌드", + "디버깅하지 않고 실행", "Runner 설정", "빌드 취소", "디버깅 시작", "중지", + "일시 중지", "계속", "프로시저 단위 실행", "한 단계씩 코드 실행", + "프로시저 나가기", "중단점 토글", "모든 중단점 삭제", "새 터미널", + "작업 영역 설정", "settings.json 열기", "Git 상태", "Git 변경 사항", + "모두 스테이지", "모두 스테이지 해제", "키보드 단축키 참조", "문제 보고", + "Axyne 정보" +}; + +static int title_matches(const char *title, const char *label) +{ + size_t length = strlen(label); + if (strncmp(title, label, length) != 0) return 0; + return title[length] == '\0' || strcmp(title + length, "...") == 0 || + strcmp(title + length, "\xe2\x80\xa6") == 0; +} + +static void check_windows_labels(void) +{ + char *glue = read_file("src/features/ui/platform/windows/ui_commands_windows.c"); + char *menus = glue != NULL ? region(glue, "/* ---- menu table", "/* ---- end of menu table") : NULL; + size_t i, k; + if (menus != NULL) { + for (i = 0; i < sizeof(shared_labels) / sizeof(shared_labels[0]); ++i) { + int found = 0; + for (k = 0; k < registry_count && !found; ++k) + found = registry[k].windows && title_matches(registry[k].title, shared_labels[i]) && + has_word(menus, registry[k].name); + if (!found) FAIL("Windows menu lacks shared entry: %s", shared_labels[i]); + } + } + free(menus); free(glue); +} + +/* macOS menu labels come from the registry titles placed by the macOS layout + * table (ui_commands_macos.m); macos_has_label maps renamed entries. */ +static void check_macos_labels(void) { static const char *const labels[] = { "새 파일", "열기", "폴더 열기", "저장", "다른 이름으로 저장", "닫기", @@ -331,17 +760,13 @@ static void check_shared_labels(void) "모두 스테이지 해제", "키보드 단축키 참조", "문제 보고", "Axyne 정보" }; char *mac = read_file("src/features/ui/platform/macos/ui_macos.m"); - char *win = read_file("src/features/ui/platform/windows/ui_windows.c"); size_t i; - if (mac != NULL && win != NULL) { - for (i = 0; i < sizeof(labels) / sizeof(labels[0]); ++i) { - if (!source_has_label(mac, '@', labels[i])) + if (mac != NULL) { + for (i = 0; i < sizeof(labels) / sizeof(labels[0]); ++i) + if (!macos_has_label(labels[i])) FAIL("macOS menu lacks shared entry: %s", labels[i]); - if (!source_has_label(win, 'L', labels[i])) - FAIL("Windows menu lacks shared entry: %s", labels[i]); - } } - free(mac); free(win); + free(mac); } int main(int argc, char **argv) @@ -351,9 +776,11 @@ int main(int argc, char **argv) return 2; } source_root = argv[1]; + load_registry(); check_macos_selectors(); + check_macos_labels(); check_windows_commands(); - check_shared_labels(); + check_windows_labels(); if (failures == 0) puts("menu consistency ok"); return failures == 0 ? 0 : 1; } diff --git a/tests/test_settings_preferences.c b/tests/test_settings_preferences.c index c052997..ce83631 100644 --- a/tests/test_settings_preferences.c +++ b/tests/test_settings_preferences.c @@ -2,6 +2,7 @@ #include +#include "axyne/filesystem.h" #include "axyne/keymap.h" #include "axyne/log.h" #include "axyne/preferences.h" @@ -174,7 +175,7 @@ static int axyne_test_settings_v2(const char *root) V2_CHECK(axyne_test_json_string_is(path, "/debugger/backend", "custom")); /* The key map honours both binding views. */ - V2_CHECK(axyne_keymap_build(&map, AXYNE_PLATFORM_WINDOWS, loaded) == AXYNE_STATUS_OK); + V2_CHECK(axyne_keymap_build(&map, AXYNE_COMMAND_PLATFORM_WINDOWS, loaded) == AXYNE_STATUS_OK); V2_CHECK(axyne_keymap_feed(&map, axyne_test_stroke("Ctrl+Q"), 0, 0, &command) == AXYNE_KEYMAP_COMMAND && command == AXYNE_COMMAND_FILE_SAVE); V2_CHECK(axyne_keymap_feed(&map, axyne_test_stroke("Ctrl+S"), 0, 0, &command) == AXYNE_KEYMAP_NONE); @@ -344,7 +345,7 @@ static int axyne_test_settings_v2(const char *root) { AxyneKeyStroke run; V2_CHECK(binding != NULL && axyne_keymap_stroke_from_legacy(binding->modifiers, binding->key, - axyne_platform_current(), &run)); + axyne_command_platform_current(), &run)); V2_CHECK(axyne_key_stroke_equal(run, axyne_test_stroke("Ctrl+F5"))); } V2_CHECK(axyne_preferences_migrate_run_binding(loaded) == 0); @@ -421,6 +422,27 @@ static int axyne_test_settings_v2(const char *root) resolved = axyne_preferences_read_path(directory); V2_CHECK(resolved != NULL && strcmp(resolved, legacy_path) == 0); axyne_preferences_free_path(resolved); + /* Adopting the old file copies it byte for byte and keeps it. */ + V2_CHECK(axyne_preferences_adopt_legacy_file(empty, &error) == AXYNE_STATUS_NOT_FOUND); + { + char *legacy_bytes = NULL, *adopted_bytes = NULL; + size_t legacy_length = 0, adopted_length = 0; + V2_CHECK(axyne_fs_read_file(legacy_path, &legacy_bytes, &legacy_length, NULL) == + AXYNE_STATUS_OK); + V2_CHECK(axyne_preferences_adopt_legacy_file(directory, &error) == AXYNE_STATUS_OK); + V2_CHECK(axyne_fs_read_file(path, &adopted_bytes, &adopted_length, NULL) == + AXYNE_STATUS_OK); + V2_CHECK(legacy_length == adopted_length && + memcmp(legacy_bytes, adopted_bytes, legacy_length) == 0); + axyne_fs_free(legacy_bytes); + axyne_fs_free(adopted_bytes); + resolved = axyne_preferences_read_path(directory); + V2_CHECK(resolved != NULL && strcmp(resolved, path) == 0); + axyne_preferences_free_path(resolved); + /* An existing settings.json is left alone. */ + V2_CHECK(axyne_preferences_adopt_legacy_file(directory, &error) == AXYNE_STATUS_OK); + axyne_test_remove_file(path); + } resolved = axyne_preferences_write_path(directory); V2_CHECK(resolved != NULL && strcmp(resolved, path) == 0); axyne_preferences_free_path(resolved);