From 8d7cc163d14bbb8eb1e4b75a9fe708112af9a647 Mon Sep 17 00:00:00 2001 From: / <6137228+NikolaiSachok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:13:37 +0100 Subject: [PATCH 1/2] fix(image-wlx): hand DC's viewer keys to Double Commander With the image focused, N / P and Alt+Right / Alt+Left did nothing (File > Next still worked): the canvas swallowed every key it did not use, and LCL handles its viewer hotkeys through NSApplication's -sendEvent: dispatch. The canvas now keeps only the zoom keys and the unmodified scroll keys and hands every other key to DC the way Esc already was, then takes focus back once DC has handled it, so the arrows pan the next image. test/keys_verify.m covers N, P, Alt+arrows, Q and a view-mode digit, and that the arrows and zoom keys stay with the image; it fails 16 checks on 0.1.0. Bump to 0.1.1. --- .github/workflows/ci.yml | 5 +- AGENTS.md | 7 ++ CHANGELOG.md | 9 ++ image-wlx/ImageView.m | 71 +++++++++----- image-wlx/README.md | 24 ++++- image-wlx/test/keys_verify.m | 176 +++++++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 image-wlx/test/keys_verify.m diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8366211..d561990 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,18 +108,19 @@ jobs: # The image viewer is AppKit only (no WebKit), so its harnesses run here # against generated samples: formats, fall-through, opening zoom, zoom - # keys, mouse, animation, FP traps, Esc, focus and copy. + # keys, mouse, animation, FP traps, Esc, DC's viewer keys, focus and copy. - name: Run image-wlx harnesses (real artifact) working-directory: image-wlx run: | clang -fobjc-arc -Wall -framework Cocoa -framework ImageIO \ -framework UniformTypeIdentifiers -lz -o build/make_samples test/make_samples.m ./build/make_samples build/samples - for t in test_host esc_verify focus_verify copy_verify; do + for t in test_host esc_verify keys_verify focus_verify copy_verify; do clang -fobjc-arc -Wall -Wextra -framework Cocoa -o build/$t test/$t.m done ./build/test_host build/ImageView.wlx build/samples ./build/esc_verify build/ImageView.wlx build/samples/photo.jpg + ./build/keys_verify build/ImageView.wlx build/samples/photo.jpg build/samples/wide.png ./build/focus_verify build/ImageView.wlx build/samples/photo.jpg ./build/copy_verify build/ImageView.wlx build/samples diff --git a/AGENTS.md b/AGENTS.md index 7fb8dd2..ee72804 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,13 @@ not as a bug to rediscover: 3. **That harness is a regression net, not proof.** The first Esc fix passed a mock and still failed in the app. Before shipping, press Esc in the real Double Commander with the plugin registered. +4. **Esc is one case of a wider rule: every key the plugin does not use belongs to + DC's viewer.** N / P and Alt+→ / Alt+← (next / previous file), Q, the view-mode + digits all go through the same `-sendEvent:` dispatch, so a focused plugin view + that consumes them breaks them silently — the File → Next menu item still works, + which is the tell. An AppKit view (image-wlx) hands every unused key to DC the + same way and takes focus back once DC has handled it (`dispatch_async`; the + re-posted event is dispatched first); `test/keys_verify.m` covers it. ### Cmd+C / Cmd+A go through `ListSendCommand`, not the web view Same shape as the Esc lesson, and it had bitten **both** web-view listers before it diff --git a/CHANGELOG.md b/CHANGELOG.md index b256627..c3f6eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ independently and tagged below. ## [Unreleased] +## image-wlx 0.1.1 — 2026-10-26 + +### Fixed +- **image-wlx:** Double Commander's viewer keys work while the image has + focus: N / P and Alt+→ / Alt+← load the next or previous file, Q closes the + viewer, the digits switch view mode. The image used to swallow every key it + did not use itself. After the next file loads, the image has keyboard focus + again, so the arrows pan it straight away. + ## image-wlx 0.1.0 — 2026-10-26 ### Added diff --git a/image-wlx/ImageView.m b/image-wlx/ImageView.m index e1db290..df53fe1 100644 --- a/image-wlx/ImageView.m +++ b/image-wlx/ImageView.m @@ -37,7 +37,7 @@ #include #include "listplug.h" -#define IV_VERSION "0.1.0" /* single source of truth for the plugin version */ +#define IV_VERSION "0.1.1" /* single source of truth for the plugin version */ /* Display bitmaps are capped so a 200-megapixel scan can't exhaust memory or * exceed the GPU's texture size: the longest side at 16384 px and the area at @@ -459,6 +459,7 @@ - (void)zoomBy:(CGFloat)factor atPoint:(NSPoint)docPoint; - (void)magnificationDidChange; - (BOOL)handleZoomKey:(NSEvent *)event; - (BOOL)isPannable; +- (void)claimFocusIfUnclaimed; @end @implementation IVCanvas @@ -546,18 +547,37 @@ - (void)mouseUp:(NSEvent *)event { #pragma mark Keyboard -/* Escape must close Double Commander's viewer. LCL processes its shortcuts - * through NSApplication's event dispatch (-sendEvent:), not through -keyDown: - * forwarding along the responder chain, so a key the plugin's view merely - * passes on never reaches it. The plugin moves focus to DC's own view and - * re-posts the Escape event, as markdown-wlx and book-wlx do. */ +/* The image keeps only the keys it uses: the zoom keys, and the arrows, Page + * Up/Down, Home/End and Space without a modifier, which scroll. Every other + * key belongs to Double Commander's viewer: Esc / Q close it, N / P and + * Alt+Right / Alt+Left load the next or previous file, the digits switch view + * mode. LCL processes its shortcuts through NSApplication's event dispatch + * (-sendEvent:), not through -keyDown: forwarding along the responder chain, + * so a key the plugin's view merely passes on never reaches it. The plugin + * moves focus to DC's own view and re-posts the event, as markdown-wlx and + * book-wlx do for Escape. */ - (void)keyDown:(NSEvent *)event { - if (event.keyCode == 53 /* kVK_Escape */) { [self repostEscape:event]; return; } if ([self.owner handleZoomKey:event]) return; - [self interpretKeyEvents:@[ event ]]; + if ([self isScrollKey:event]) { [self interpretKeyEvents:@[ event ]]; return; } + [self handKeyToHost:event]; } -- (void)repostEscape:(NSEvent *)event { +- (BOOL)isScrollKey:(NSEvent *)event { + NSEventModifierFlags mods = event.modifierFlags & + (NSEventModifierFlagCommand | NSEventModifierFlagOption | NSEventModifierFlagControl); + if (mods) return NO; + switch (event.keyCode) { + case 123: case 124: case 125: case 126: /* arrows */ + case 116: case 121: /* Page Up, Page Down */ + case 115: case 119: /* Home, End */ + case 49: /* Space */ + return YES; + default: + return NO; + } +} + +- (void)handKeyToHost:(NSEvent *)event { NSWindow *win = self.window; if (!win) return; NSView *dcView = self.owner.superview; @@ -567,17 +587,28 @@ - (void)repostEscape:(NSEvent *)event { if (!moved) moved = [win makeFirstResponder:nil]; if (win.firstResponder == self) return; /* couldn't move focus; avoid a loop */ - NSEvent *esc = [NSEvent keyEventWithType:NSEventTypeKeyDown + NSEvent *key = [NSEvent keyEventWithType:NSEventTypeKeyDown location:event.locationInWindow modifierFlags:event.modifierFlags timestamp:event.timestamp windowNumber:event.windowNumber context:nil - characters:@"\x1b" - charactersIgnoringModifiers:@"\x1b" - isARepeat:NO - keyCode:53]; - [NSApp postEvent:esc atStart:YES]; + characters:event.characters + charactersIgnoringModifiers:event.charactersIgnoringModifiers + isARepeat:event.isARepeat + keyCode:event.keyCode]; + [NSApp postEvent:key atStart:YES]; + + /* Take focus back once DC has handled the key, so the arrows keep panning + * the next image after N. The re-posted event is at the head of the queue + * and is dispatched before the run loop gets to this block. Not when DC + * moved focus to a control of its own, closed the viewer, or opened a + * dialog. */ + __weak IVView *owner = self.owner; + dispatch_async(dispatch_get_main_queue(), ^{ + IVView *me = owner; + if (me.window.isKeyWindow) [me claimFocusIfUnclaimed]; + }); } /* Arrow keys, Page Up/Down, Home/End and Space scroll the image. */ @@ -599,20 +630,14 @@ - (void)scrollPageUp:(id)s { (void)s; [self scrollBy:NSMakePoint(0, -[self pag - (void)scrollPageDown:(id)s { (void)s; [self scrollBy:NSMakePoint(0, [self pageStep])]; } - (void)pageUp:(id)s { [self scrollPageUp:s]; } - (void)pageDown:(id)s { [self scrollPageDown:s]; } -- (void)insertText:(id)text { - if ([text isEqual:@" "]) [self scrollPageDown:nil]; - else NSBeep(); -} +- (void)insertText:(id)text { (void)text; [self scrollPageDown:nil]; } /* Space */ - (void)scrollToBeginningOfDocument:(id)s { (void)s; [self scrollBy:NSMakePoint(0, -CGFLOAT_MAX / 4)]; } - (void)scrollToEndOfDocument:(id)s { (void)s; [self scrollBy:NSMakePoint(0, CGFLOAT_MAX / 4)]; } - (void)moveToBeginningOfDocument:(id)s { [self scrollToBeginningOfDocument:s]; } - (void)moveToEndOfDocument:(id)s { [self scrollToEndOfDocument:s]; } - (void)doCommandBySelector:(SEL)sel { - if ([self respondsToSelector:sel]) { + if ([self respondsToSelector:sel]) ((void (*)(id, SEL, id))[self methodForSelector:sel])(self, sel, nil); - } else if (sel == @selector(insertBacktab:) || sel == @selector(insertTab:)) { - [super doCommandBySelector:sel]; - } } @end diff --git a/image-wlx/README.md b/image-wlx/README.md index 066e182..1bcfbd7 100644 --- a/image-wlx/README.md +++ b/image-wlx/README.md @@ -81,7 +81,13 @@ Press **F3** on an image, or turn on Quick View (**Ctrl+Q**). | Toggle Fit ↔ 100% | double-click, two-finger double tap | | Pan | drag, scroll, arrows, PgUp / PgDn, Home / End, Space | | Copy image | Cmd+C | -| Close viewer | Esc | +| Next / previous file | N / P, Alt+→ / Alt+← (F3 viewer) | +| Close viewer | Esc, Q | + +Next / previous are Double Commander's own viewer commands (also in the +viewer's File menu). They step through the files in the panel, in panel order; +with a selection, through the selected files only. Every key the image does +not use goes to DC's viewer in the same way. The zoom an image **opens** at follows DC's own *Image → Stretch* settings (keys **F** and **L** in the viewer): with *Stretch only large* (the default) a @@ -135,11 +141,15 @@ error. plugin's view, so a local event monitor takes them first — only while the image has keyboard focus, so Quick View's file panel keeps its keys. -### Escape closes the viewer +### DC's viewer keys reach DC -The canvas moves focus to DC's view and re-posts Esc through `NSApplication`, -which is where LCL handles its shortcuts. See the charter in -[AGENTS.md](../AGENTS.md). +The image keeps the zoom keys and the unmodified arrows, PgUp / PgDn, +Home / End and Space. For any other key (Esc, Q, N, P, Alt+arrows, the view +mode digits) the canvas moves focus to DC's view and re-posts the key through +`NSApplication`, which is where LCL handles its shortcuts. Once DC has handled +it, the image takes focus back — unless DC moved focus to a control of its own +or closed the viewer — so the arrows pan the next image after N. See the +charter in [AGENTS.md](../AGENTS.md). ### Keyboard focus @@ -176,6 +186,10 @@ clang -fobjc-arc -framework Cocoa -o build/test_host test/test_host.m clang -fobjc-arc -framework Cocoa -o build/esc_verify test/esc_verify.m ./build/esc_verify build/ImageView.wlx build/samples/photo.jpg +# N / P / Alt+arrows / Q reach DC's viewer; the image keeps focus afterwards +clang -fobjc-arc -framework Cocoa -o build/keys_verify test/keys_verify.m +./build/keys_verify build/ImageView.wlx build/samples/photo.jpg build/samples/wide.png + # Keys work straight after F3; Quick View keeps its focus clang -fobjc-arc -framework Cocoa -o build/focus_verify test/focus_verify.m ./build/focus_verify build/ImageView.wlx build/samples/photo.jpg diff --git a/image-wlx/test/keys_verify.m b/image-wlx/test/keys_verify.m new file mode 100644 index 0000000..45058a1 --- /dev/null +++ b/image-wlx/test/keys_verify.m @@ -0,0 +1,176 @@ +/* Regression net for Double Commander's viewer hotkeys. + * + * With the image focused, the keys the plugin does not use must still reach + * DC's viewer: N / P and Alt+Right / Alt+Left load the next or previous file + * (cm_LoadNextFile / cm_LoadPrevFile), Q closes the viewer. LCL handles them + * through NSApplication's event dispatch, so the plugin re-posts them to DC's + * view. Afterwards the image must have keyboard focus again, so the arrows + * keep panning the next image. The keys the plugin does use (plain arrows, + * the zoom keys) must stay with it. + * + * The stand-in for DC's viewer panel records each key it receives, and on N + * or P calls ListLoadNext, as cm_LoadNextFile does. + * + * This is a regression net, not proof: per the charter, press N and P with + * the image focused in a real Double Commander. + * + * ./build/keys_verify + */ +#import +#include + +typedef void *HWND; +typedef HWND (*ListLoad_t)(HWND, char *, int); +typedef int (*ListLoadNext_t)(HWND, HWND, char *, int); + +static int gFailures = 0; +static ListLoadNext_t ListLoadNext; +static HWND gPlugin; +static const char *gNext; +static int gLoads = 0; + +static void check(BOOL cond, const char *what) { + printf(" %-60s %s\n", what, cond ? "ok" : "FAILED"); + if (!cond) gFailures++; +} + +static NSView *Find(NSView *root, NSString *cls) { + if ([root isKindOfClass:NSClassFromString(cls)]) return root; + for (NSView *v in root.subviews) { NSView *f = Find(v, cls); if (f) return f; } + return nil; +} + +static void After(double seconds, void (^block)(void)) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(seconds * NSEC_PER_SEC)), + dispatch_get_main_queue(), block); +} + + +/* Key status is the test's to decide (see focus_verify.m). */ +@interface HostWindow : NSWindow +@property (nonatomic) BOOL key; +@end +@implementation HostWindow +- (BOOL)isKeyWindow { return self.key; } +@end + +/* DC's viewer panel: the parent ListLoad gets. */ +@interface FakeDC : NSView +@property (nonatomic, strong) NSMutableArray *keys; +@end +@implementation FakeDC +- (BOOL)acceptsFirstResponder { return YES; } +- (void)keyDown:(NSEvent *)e { + [self.keys addObject:e]; + NSEventModifierFlags mods = e.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; + BOOL alt = (mods & NSEventModifierFlagOption) != 0; + BOOL next = e.keyCode == 45 /* N */ || (alt && e.keyCode == 124); + BOOL prev = e.keyCode == 35 /* P */ || (alt && e.keyCode == 123); + if ((next || prev) && ListLoadNext(gPlugin, gPlugin, (char *)gNext, 0) == 0) gLoads++; +} +@end + +typedef struct { + unsigned short code; + unichar ch; + NSEventModifierFlags mods; + BOOL toDC; /* the key belongs to DC's viewer */ + BOOL loads; /* DC answers it with ListLoadNext */ + const char *name; +} Case; + +#define ARROW (NSEventModifierFlagFunction | NSEventModifierFlagNumericPad) +static const Case kCases[] = { + { 45, 'n', 0, YES, YES, "N" }, + { 35, 'p', 0, YES, YES, "P" }, + { 124, NSRightArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Right" }, + { 123, NSLeftArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Left" }, + { 18, '1', 0, YES, NO, "1 (view as text)" }, + { 124, NSRightArrowFunctionKey, ARROW, NO, NO, "Right" }, + { 125, NSDownArrowFunctionKey, ARROW, NO, NO, "Down" }, + { 24, '=', 0, NO, NO, "= (zoom in)" }, + { 12, 'q', 0, YES, NO, "Q" }, +}; +static const int kCount = (int)(sizeof kCases / sizeof kCases[0]); + +static HostWindow *gWin; +static FakeDC *gDC; +static NSView *gCanvas; +static NSScrollView *gScroll; + +static void Step(int i) { + if (i == kCount) { + printf(gFailures ? "RESULT: FAIL (%d)\n" : "RESULT: PASS\n", gFailures); + [NSApp stop:nil]; + [NSApp postEvent:[NSEvent otherEventWithType:NSEventTypeApplicationDefined + location:NSZeroPoint modifierFlags:0 timestamp:0 windowNumber:0 + context:nil subtype:0 data1:0 data2:0] atStart:YES]; + return; + } + Case c = kCases[i]; + printf("%s:\n", c.name); + [gWin makeFirstResponder:gCanvas]; + NSUInteger seen = gDC.keys.count; + int loads = gLoads; + NSPoint origin = gScroll.contentView.bounds.origin; + CGFloat mag = gScroll.magnification; + NSString *chars = [NSString stringWithCharacters:&c.ch length:1]; + NSEvent *e = [NSEvent keyEventWithType:NSEventTypeKeyDown location:NSZeroPoint + modifierFlags:c.mods timestamp:0 windowNumber:gWin.windowNumber context:nil + characters:chars charactersIgnoringModifiers:chars isARepeat:NO keyCode:c.code]; + [NSApp postEvent:e atStart:NO]; + After(0.3, ^{ + NSEvent *got = gDC.keys.count > seen ? gDC.keys.lastObject : nil; + if (c.toDC) { + check(got != nil, "reaches DC's viewer"); + check(got.keyCode == c.code && [got.characters isEqualToString:chars] && + (got.modifierFlags & NSEventModifierFlagOption) == (c.mods & NSEventModifierFlagOption), + "arrives unchanged"); + if (c.loads) check(gLoads == loads + 1, "DC loads the next file"); + } else { + check(got == nil, "stays with the image"); + if (c.code == 24) check(gScroll.magnification > mag, "zooms in"); + else check(!NSEqualPoints(gScroll.contentView.bounds.origin, origin), "scrolls the image"); + } + check(gWin.firstResponder == gCanvas, "the image has keyboard focus afterwards"); + Step(i + 1); + }); +} + +int main(int argc, char **argv) { @autoreleasepool { + if (argc < 4) { + fprintf(stderr, "usage: keys_verify \n"); + return 2; + } + gNext = argv[3]; + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyAccessory]; + + void *h = dlopen(argv[1], RTLD_NOW); + if (!h) { fprintf(stderr, "dlopen failed: %s\n", dlerror()); return 2; } + ListLoad_t ListLoad = (ListLoad_t)dlsym(h, "ListLoad"); + ListLoadNext = (ListLoadNext_t)dlsym(h, "ListLoadNext"); + if (!ListLoad || !ListLoadNext) { fprintf(stderr, "missing exports\n"); return 2; } + + HostWindow *win = [[HostWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 400) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + win.releasedWhenClosed = NO; + NSView *form = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 800, 400)]; + win.contentView = form; + FakeDC *dc = [[FakeDC alloc] initWithFrame:form.bounds]; + dc.keys = [NSMutableArray array]; + [form addSubview:dc]; + [win orderFront:nil]; + win.key = YES; + + /* No Stretch: the image opens at 100%, larger than the window. */ + gPlugin = ListLoad((__bridge HWND)dc, argv[2], 0); + if (!gPlugin) { fprintf(stderr, "ListLoad returned NULL\n"); return 2; } + gWin = win; + gDC = dc; + gCanvas = Find((__bridge NSView *)gPlugin, @"IVCanvas"); + gScroll = (NSScrollView *)Find((__bridge NSView *)gPlugin, @"IVScrollView"); + After(0.8, ^{ Step(0); }); + [app run]; + return gFailures ? 1 : 0; +}} From 322ebb5748243386541a6decb75d21eca3fdf2c4 Mon Sep 17 00:00:00 2001 From: / <6137228+NikolaiSachok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:21:47 +0100 Subject: [PATCH 2/2] fix(image-wlx): scroll keys by key code; End goes to the bottom Review findings on the hotkey hand-off, plus what the extended harness found: - Cmd+Up / Cmd+Down scroll to top / bottom again (the hand-off had sent them to DC, which has no binding for them); Shift+arrows now go to DC instead of being swallowed; Shift+Space pages up. - End and Cmd+Down landed at the top: the huge scroll delta overflowed in NSClipView's constraint arithmetic. Scroll by the real distance instead. - Scroll keys are handled by key code, not -interpretKeyEvents:, so a DefaultKeyBinding.dict that maps Home / End to line start / end no longer leaves them dead. - keys_verify: each key reaches DC exactly once; Tab to DC's file panel and a dialog taking the key window both keep focus away from the image; Home / End / Cmd+Up / Cmd+Down land on the right edge. - Comment and charter no longer claim strict ordering between the re-posted key and the focus reclaim (a nested run loop in DC runs it during). --- AGENTS.md | 5 ++- CHANGELOG.md | 4 ++ image-wlx/ImageView.m | 79 +++++++++++++++++++++--------------- image-wlx/README.md | 8 ++-- image-wlx/test/keys_verify.m | 64 ++++++++++++++++++++++------- 5 files changed, 107 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee72804..43622ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,8 +120,9 @@ not as a bug to rediscover: digits all go through the same `-sendEvent:` dispatch, so a focused plugin view that consumes them breaks them silently — the File → Next menu item still works, which is the tell. An AppKit view (image-wlx) hands every unused key to DC the - same way and takes focus back once DC has handled it (`dispatch_async`; the - re-posted event is dispatched first); `test/keys_verify.m` covers it. + same way and takes focus back with `dispatch_async` — after DC's handling, or + during it if DC spins a nested run loop, so never rely on strict ordering; + `test/keys_verify.m` covers it. ### Cmd+C / Cmd+A go through `ListSendCommand`, not the web view Same shape as the Esc lesson, and it had bitten **both** web-view listers before it diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f6eca..ae2fc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ independently and tagged below. viewer, the digits switch view mode. The image used to swallow every key it did not use itself. After the next file loads, the image has keyboard focus again, so the arrows pan it straight away. +- **image-wlx:** End and Cmd+↓ scroll to the bottom of the image (they went to + the top), and Home / End no longer depend on the system's text key bindings + (a `DefaultKeyBinding.dict` that maps them to line start / end left them + dead). Cmd+↑ goes to the top, Shift+Space pages up. ## image-wlx 0.1.0 — 2026-10-26 diff --git a/image-wlx/ImageView.m b/image-wlx/ImageView.m index df53fe1..797a48d 100644 --- a/image-wlx/ImageView.m +++ b/image-wlx/ImageView.m @@ -547,8 +547,9 @@ - (void)mouseUp:(NSEvent *)event { #pragma mark Keyboard -/* The image keeps only the keys it uses: the zoom keys, and the arrows, Page - * Up/Down, Home/End and Space without a modifier, which scroll. Every other +/* The image keeps only the keys it uses: the zoom keys, and the keys that + * scroll — the arrows, Page Up/Down, Home/End and Space without a modifier, + * Shift+Space (page up), and Cmd+Up / Cmd+Down (top / bottom). Every other * key belongs to Double Commander's viewer: Esc / Q close it, N / P and * Alt+Right / Alt+Left load the next or previous file, the digits switch view * mode. LCL processes its shortcuts through NSApplication's event dispatch @@ -558,22 +559,43 @@ - (void)mouseUp:(NSEvent *)event { * book-wlx do for Escape. */ - (void)keyDown:(NSEvent *)event { if ([self.owner handleZoomKey:event]) return; - if ([self isScrollKey:event]) { [self interpretKeyEvents:@[ event ]]; return; } + if ([self scrollForKey:event]) return; [self handKeyToHost:event]; } -- (BOOL)isScrollKey:(NSEvent *)event { +/* By key code, not through -interpretKeyEvents:. The text system's bindings + * are about lines and selections, and users remap them (Home / End to start + * and end of line is a common DefaultKeyBinding.dict), which an image has no + * use for. */ +- (BOOL)scrollForKey:(NSEvent *)event { NSEventModifierFlags mods = event.modifierFlags & - (NSEventModifierFlagCommand | NSEventModifierFlagOption | NSEventModifierFlagControl); + (NSEventModifierFlagCommand | NSEventModifierFlagOption | + NSEventModifierFlagControl | NSEventModifierFlagShift); + CGFloat line = [self lineStep], page = [self pageStep]; + if (mods == NSEventModifierFlagCommand) { + switch (event.keyCode) { + case 126: [self scrollToTop]; return YES; /* Cmd+Up */ + case 125: [self scrollToBottom]; return YES; /* Cmd+Down */ + default: return NO; + } + } + if (mods == NSEventModifierFlagShift) { + if (event.keyCode != 49) return NO; + [self scrollBy:NSMakePoint(0, -page)]; /* Shift+Space */ + return YES; + } if (mods) return NO; switch (event.keyCode) { - case 123: case 124: case 125: case 126: /* arrows */ - case 116: case 121: /* Page Up, Page Down */ - case 115: case 119: /* Home, End */ - case 49: /* Space */ - return YES; - default: - return NO; + case 126: [self scrollBy:NSMakePoint(0, -line)]; return YES; /* Up */ + case 125: [self scrollBy:NSMakePoint(0, line)]; return YES; /* Down */ + case 123: [self scrollBy:NSMakePoint(-line, 0)]; return YES; /* Left */ + case 124: [self scrollBy:NSMakePoint( line, 0)]; return YES; /* Right */ + case 116: [self scrollBy:NSMakePoint(0, -page)]; return YES; /* Page Up */ + case 121: case 49: /* Page Down, Space */ + [self scrollBy:NSMakePoint(0, page)]; return YES; + case 115: [self scrollToTop]; return YES; /* Home */ + case 119: [self scrollToBottom]; return YES; /* End */ + default: return NO; } } @@ -600,10 +622,11 @@ - (void)handKeyToHost:(NSEvent *)event { [NSApp postEvent:key atStart:YES]; /* Take focus back once DC has handled the key, so the arrows keep panning - * the next image after N. The re-posted event is at the head of the queue - * and is dispatched before the run loop gets to this block. Not when DC - * moved focus to a control of its own, closed the viewer, or opened a - * dialog. */ + * the next image after N. The re-posted event is at the head of the queue, + * so this block runs after DC's handling — or during it, if DC spins a + * nested run loop there; either way the key has already gone to DC. Not + * when DC moved focus to a control of its own, closed the viewer, or + * opened a dialog. */ __weak IVView *owner = self.owner; dispatch_async(dispatch_get_main_queue(), ^{ IVView *me = owner; @@ -611,7 +634,6 @@ - (void)handKeyToHost:(NSEvent *)event { }); } -/* Arrow keys, Page Up/Down, Home/End and Space scroll the image. */ - (void)scrollBy:(NSPoint)delta { NSClipView *clip = self.enclosingScrollView.contentView; NSRect b = clip.bounds; @@ -622,22 +644,13 @@ - (void)scrollBy:(NSPoint)delta { } - (CGFloat)lineStep { return 40 / MAX(self.enclosingScrollView.magnification, 0.001); } - (CGFloat)pageStep { return NSHeight(self.enclosingScrollView.contentView.bounds) * 0.9; } -- (void)moveUp:(id)s { (void)s; [self scrollBy:NSMakePoint(0, -[self lineStep])]; } -- (void)moveDown:(id)s { (void)s; [self scrollBy:NSMakePoint(0, [self lineStep])]; } -- (void)moveLeft:(id)s { (void)s; [self scrollBy:NSMakePoint(-[self lineStep], 0)]; } -- (void)moveRight:(id)s { (void)s; [self scrollBy:NSMakePoint( [self lineStep], 0)]; } -- (void)scrollPageUp:(id)s { (void)s; [self scrollBy:NSMakePoint(0, -[self pageStep])]; } -- (void)scrollPageDown:(id)s { (void)s; [self scrollBy:NSMakePoint(0, [self pageStep])]; } -- (void)pageUp:(id)s { [self scrollPageUp:s]; } -- (void)pageDown:(id)s { [self scrollPageDown:s]; } -- (void)insertText:(id)text { (void)text; [self scrollPageDown:nil]; } /* Space */ -- (void)scrollToBeginningOfDocument:(id)s { (void)s; [self scrollBy:NSMakePoint(0, -CGFLOAT_MAX / 4)]; } -- (void)scrollToEndOfDocument:(id)s { (void)s; [self scrollBy:NSMakePoint(0, CGFLOAT_MAX / 4)]; } -- (void)moveToBeginningOfDocument:(id)s { [self scrollToBeginningOfDocument:s]; } -- (void)moveToEndOfDocument:(id)s { [self scrollToEndOfDocument:s]; } -- (void)doCommandBySelector:(SEL)sel { - if ([self respondsToSelector:sel]) - ((void (*)(id, SEL, id))[self methodForSelector:sel])(self, sel, nil); +/* By the real distance to the edge: a huge delta overflows in NSClipView's + * constraint arithmetic and lands at the wrong end. */ +- (void)scrollToTop { + [self scrollBy:NSMakePoint(0, NSMinY(self.frame) - NSMinY(self.enclosingScrollView.contentView.bounds))]; +} +- (void)scrollToBottom { + [self scrollBy:NSMakePoint(0, NSMaxY(self.frame) - NSMaxY(self.enclosingScrollView.contentView.bounds))]; } @end diff --git a/image-wlx/README.md b/image-wlx/README.md index 1bcfbd7..b5eb7d1 100644 --- a/image-wlx/README.md +++ b/image-wlx/README.md @@ -79,7 +79,8 @@ Press **F3** on an image, or turn on Quick View (**Ctrl+Q**). | Actual size | Cmd+0, toolbar Actual Size button | | Fit to window | Cmd+9, toolbar Zoom to Fit button | | Toggle Fit ↔ 100% | double-click, two-finger double tap | -| Pan | drag, scroll, arrows, PgUp / PgDn, Home / End, Space | +| Pan | drag, scroll, arrows, PgUp / PgDn, Space / Shift+Space | +| Top / bottom | Home / End, Cmd+↑ / Cmd+↓ | | Copy image | Cmd+C | | Next / previous file | N / P, Alt+→ / Alt+← (F3 viewer) | | Close viewer | Esc, Q | @@ -143,8 +144,9 @@ error. ### DC's viewer keys reach DC -The image keeps the zoom keys and the unmodified arrows, PgUp / PgDn, -Home / End and Space. For any other key (Esc, Q, N, P, Alt+arrows, the view +The image keeps the zoom keys and the keys that scroll: the unmodified arrows, +PgUp / PgDn, Home / End and Space, Shift+Space, and Cmd+↑ / Cmd+↓. For any +other key (Esc, Q, N, P, Alt+arrows, the view mode digits) the canvas moves focus to DC's view and re-posts the key through `NSApplication`, which is where LCL handles its shortcuts. Once DC has handled it, the image takes focus back — unless DC moved focus to a control of its own diff --git a/image-wlx/test/keys_verify.m b/image-wlx/test/keys_verify.m index 45058a1..fbaf048 100644 --- a/image-wlx/test/keys_verify.m +++ b/image-wlx/test/keys_verify.m @@ -5,8 +5,10 @@ * (cm_LoadNextFile / cm_LoadPrevFile), Q closes the viewer. LCL handles them * through NSApplication's event dispatch, so the plugin re-posts them to DC's * view. Afterwards the image must have keyboard focus again, so the arrows - * keep panning the next image. The keys the plugin does use (plain arrows, - * the zoom keys) must stay with it. + * keep panning the next image — unless DC moved focus to a control of its + * own (Tab to the file panel) or a dialog took the key window (F7, Find). + * The keys the plugin does use (plain arrows, Cmd+Up / Cmd+Down, the zoom + * keys) must stay with it. * * The stand-in for DC's viewer panel records each key it receives, and on N * or P calls ListLoadNext, as cm_LoadNextFile does. @@ -29,6 +31,10 @@ static const char *gNext; static int gLoads = 0; +@class HostWindow; +static HostWindow *gWin; +static NSView *gPanel; + static void check(BOOL cond, const char *what) { printf(" %-60s %s\n", what, cond ? "ok" : "FAILED"); if (!cond) gFailures++; @@ -54,7 +60,8 @@ @implementation HostWindow - (BOOL)isKeyWindow { return self.key; } @end -/* DC's viewer panel: the parent ListLoad gets. */ +/* DC's viewer panel: the parent ListLoad gets. Tab moves focus to a control + * of DC's own; F7 opens a dialog, so the viewer window stops being key. */ @interface FakeDC : NSView @property (nonatomic, strong) NSMutableArray *keys; @end @@ -67,9 +74,13 @@ - (void)keyDown:(NSEvent *)e { BOOL next = e.keyCode == 45 /* N */ || (alt && e.keyCode == 124); BOOL prev = e.keyCode == 35 /* P */ || (alt && e.keyCode == 123); if ((next || prev) && ListLoadNext(gPlugin, gPlugin, (char *)gNext, 0) == 0) gLoads++; + if (e.keyCode == 48 /* Tab */) [self.window makeFirstResponder:gPanel]; + if (e.keyCode == 98 /* F7 */) gWin.key = NO; } @end +typedef enum { FocusImage, FocusPanel, FocusDC } Focus; + typedef struct { unsigned short code; unichar ch; @@ -77,27 +88,40 @@ - (void)keyDown:(NSEvent *)e { BOOL toDC; /* the key belongs to DC's viewer */ BOOL loads; /* DC answers it with ListLoadNext */ const char *name; + Focus focus; /* who has keyboard focus afterwards */ } Case; #define ARROW (NSEventModifierFlagFunction | NSEventModifierFlagNumericPad) static const Case kCases[] = { - { 45, 'n', 0, YES, YES, "N" }, - { 35, 'p', 0, YES, YES, "P" }, - { 124, NSRightArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Right" }, - { 123, NSLeftArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Left" }, - { 18, '1', 0, YES, NO, "1 (view as text)" }, - { 124, NSRightArrowFunctionKey, ARROW, NO, NO, "Right" }, - { 125, NSDownArrowFunctionKey, ARROW, NO, NO, "Down" }, - { 24, '=', 0, NO, NO, "= (zoom in)" }, - { 12, 'q', 0, YES, NO, "Q" }, + { 45, 'n', 0, YES, YES, "N", FocusImage }, + { 35, 'p', 0, YES, YES, "P", FocusImage }, + { 124, NSRightArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Right", FocusImage }, + { 123, NSLeftArrowFunctionKey, ARROW | NSEventModifierFlagOption, YES, YES, "Alt+Left", FocusImage }, + { 18, '1', 0, YES, NO, "1 (view as text)", FocusImage }, + { 124, NSRightArrowFunctionKey, ARROW | NSEventModifierFlagShift, YES, NO, "Shift+Right", FocusImage }, + { 124, NSRightArrowFunctionKey, ARROW, NO, NO, "Right", FocusImage }, + { 125, NSDownArrowFunctionKey, ARROW, NO, NO, "Down", FocusImage }, + { 126, NSUpArrowFunctionKey, ARROW | NSEventModifierFlagCommand, NO, NO, "Cmd+Up (top)", FocusImage }, + { 125, NSDownArrowFunctionKey, ARROW | NSEventModifierFlagCommand, NO, NO, "Cmd+Down (bottom)", FocusImage }, + { 115, NSHomeFunctionKey, NSEventModifierFlagFunction, NO, NO, "Home (top)", FocusImage }, + { 119, NSEndFunctionKey, NSEventModifierFlagFunction, NO, NO, "End (bottom)", FocusImage }, + { 24, '=', 0, NO, NO, "= (zoom in)", FocusImage }, + { 48, '\t', 0, YES, NO, "Tab (DC focuses its file panel)", FocusPanel }, + { 98, NSF7FunctionKey, NSEventModifierFlagFunction, YES, NO, "F7 (DC opens a dialog)", FocusDC }, + { 12, 'q', 0, YES, NO, "Q", FocusImage }, }; static const int kCount = (int)(sizeof kCases / sizeof kCases[0]); -static HostWindow *gWin; static FakeDC *gDC; static NSView *gCanvas; static NSScrollView *gScroll; +/* The clip view shows the top (-1) or the bottom (+1) edge of the image. */ +static BOOL AtEdge(int edge) { + NSRect b = gScroll.contentView.bounds, doc = gCanvas.frame; + return edge < 0 ? fabs(NSMinY(b) - NSMinY(doc)) < 0.5 : fabs(NSMaxY(b) - NSMaxY(doc)) < 0.5; +} + static void Step(int i) { if (i == kCount) { printf(gFailures ? "RESULT: FAIL (%d)\n" : "RESULT: PASS\n", gFailures); @@ -109,6 +133,7 @@ static void Step(int i) { } Case c = kCases[i]; printf("%s:\n", c.name); + gWin.key = YES; [gWin makeFirstResponder:gCanvas]; NSUInteger seen = gDC.keys.count; int loads = gLoads; @@ -122,7 +147,7 @@ static void Step(int i) { After(0.3, ^{ NSEvent *got = gDC.keys.count > seen ? gDC.keys.lastObject : nil; if (c.toDC) { - check(got != nil, "reaches DC's viewer"); + check(gDC.keys.count == seen + 1, "reaches DC's viewer, once"); check(got.keyCode == c.code && [got.characters isEqualToString:chars] && (got.modifierFlags & NSEventModifierFlagOption) == (c.mods & NSEventModifierFlagOption), "arrives unchanged"); @@ -130,9 +155,16 @@ static void Step(int i) { } else { check(got == nil, "stays with the image"); if (c.code == 24) check(gScroll.magnification > mag, "zooms in"); + else if (c.code == 115 || c.code == 126) check(AtEdge(-1), "scrolls to the top"); + else if (c.code == 119 || (c.code == 125 && c.mods & NSEventModifierFlagCommand)) + check(AtEdge(+1), "scrolls to the bottom"); else check(!NSEqualPoints(gScroll.contentView.bounds.origin, origin), "scrolls the image"); } - check(gWin.firstResponder == gCanvas, "the image has keyboard focus afterwards"); + switch (c.focus) { + case FocusImage: check(gWin.firstResponder == gCanvas, "the image has keyboard focus afterwards"); break; + case FocusPanel: check(gWin.firstResponder == gPanel, "DC's file panel keeps the focus it took"); break; + case FocusDC: check(gWin.firstResponder == gDC, "focus is not taken back behind the dialog"); break; + } Step(i + 1); }); } @@ -160,6 +192,8 @@ int main(int argc, char **argv) { @autoreleasepool { FakeDC *dc = [[FakeDC alloc] initWithFrame:form.bounds]; dc.keys = [NSMutableArray array]; [form addSubview:dc]; + gPanel = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + [form addSubview:gPanel positioned:NSWindowBelow relativeTo:dc]; [win orderFront:nil]; win.key = YES;