Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ 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 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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ 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:** 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

### Added
Expand Down
114 changes: 76 additions & 38 deletions image-wlx/ImageView.m
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
#include <zlib.h>
#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
Expand Down Expand Up @@ -459,6 +459,7 @@ - (void)zoomBy:(CGFloat)factor atPoint:(NSPoint)docPoint;
- (void)magnificationDidChange;
- (BOOL)handleZoomKey:(NSEvent *)event;
- (BOOL)isPannable;
- (void)claimFocusIfUnclaimed;
@end

@implementation IVCanvas
Expand Down Expand Up @@ -546,18 +547,59 @@ - (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 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
* (-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 scrollForKey:event]) return;
[self handKeyToHost:event];
}

- (void)repostEscape:(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 | 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 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;
}
}

- (void)handKeyToHost:(NSEvent *)event {
NSWindow *win = self.window;
if (!win) return;
NSView *dcView = self.owner.superview;
Expand All @@ -567,20 +609,31 @@ - (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,
* 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;
if (me.window.isKeyWindow) [me claimFocusIfUnclaimed];
});
}

/* 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;
Expand All @@ -591,28 +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 {
if ([text isEqual:@" "]) [self scrollPageDown:nil];
else NSBeep();
}
- (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);
} else if (sel == @selector(insertBacktab:) || sel == @selector(insertTab:)) {
[super doCommandBySelector:sel];
}
/* 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
Expand Down
28 changes: 22 additions & 6 deletions image-wlx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,16 @@ 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 |
| 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
Expand Down Expand Up @@ -135,11 +142,16 @@ 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 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
or closed the viewer — so the arrows pan the next image after N. See the
charter in [AGENTS.md](../AGENTS.md).

### Keyboard focus

Expand Down Expand Up @@ -176,6 +188,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
Expand Down
Loading
Loading