12.2b: the Keyboard Shortcuts window and remapped keys everywhere (Compositor 1.1.6)

This commit is contained in:
雷电芽衣 committed 2026-09-26 19:17:50 -04:00
1 parent f1b2c65c1e
commit 4bfc6c237e
29 files changed
+1645 -164

No files matched your search

+2 -1
View File
@@ -51,7 +51,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too.
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead.
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`.
- `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render.
- `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it.
@@ -113,6 +113,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not.
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
- Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`.
- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `compositor.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+6
View File
@@ -108,6 +108,8 @@ add_library(compositor_core STATIC
src/UI/EffectsSheet.cpp
src/UI/FilterSheet.cpp
src/UI/FloatingPanel.cpp
src/UI/KeyboardShortcuts.cpp
src/UI/KeyboardShortcuts+Sheet.cpp
src/UI/NewCanvasSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
@@ -135,6 +137,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+Crop.cpp
src/Rendering/EditorCanvas+Effects.cpp
src/Rendering/EditorCanvas+Grid.cpp
src/Rendering/EditorCanvas+Keys.cpp
src/Rendering/EditorCanvas+Gradient.cpp
src/Rendering/EditorCanvas+Cursors.cpp
src/Rendering/BrushCursorOverlay.cpp
@@ -332,6 +335,9 @@ compositor_test(ClippingStackTests)
compositor_test(CompositorTests)
compositor_test(CompositorMenusTests)
compositor_test(ViewMenuTests)
compositor_test(KeyboardShortcutsTests)
compositor_test(KeyboardShortcutsSheetTests)
compositor_test(RemappedKeysTests)
compositor_test(CompositorUITestsLaunchTests)
compositor_test(ContentViewTests)
compositor_test(ContentViewStatusTests)
+4 -1
View File
@@ -305,7 +305,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.2a work: folders duplicate with their contents (canvas Alt-drag, list; the menu still refuses a folder, as 1.1.6), no clipping strip on folders, the text row's "Text", the accent-only transform box, the effects sheet's slider and typed ranges and swatch rims; folder opacity through every drawing path with `canEditOpacity`, the format at version 8 with `d9b3f9e`'s rule; View ▸ Snap. Evidence: every test green in the dev image, lint clean; mutants over opacity, the drawing paths, the gates and controls, the store, duplication, the transform duplicate, the list, the sheet, Snap, the crop, the menu, the overlay and the cell: all caught; dead terms left out (`duplicateLayer`'s `canEditLayers`, the thumb's clamp).
- 12.2a round 1: FAIL, five findings. The layer list lacked 1.1.6's T for Type (added, tested in `NativeLayerListTests`); a stale comment on `beginDuplicateTransform` said folders just move (it copies roots with their contents); TASKS credited the Layer menu with folder copies, which arrive with 1.2.5 in 12.10 (words fixed); the 10,000-layer limit was tested past its edge only (three copies onto 9,997 now reach 10,000); the baker's folder walk was tested one folder deep (a folder around the folder now dims the source to 63). Each fix's mutant is caught; the equivalent `activeLayer()` term of `canEditOpacity` is named in AGENTS.md.
- 12.2a round 2: PASS, no findings.
- [ ] 12.2b Version 1.1.6, part two: the keyboard shortcuts window, remapped shortcuts on the menus, the canvas, the layer list, the sheets' buttons and open text; Space released by its physical key.
- [x] 12.2b Version 1.1.6, part two: the keyboard shortcuts window, remapped shortcuts on the menus, the canvas, the layer list, the sheets' buttons and open text; Space released by its physical key.
- 12.2b work: the Keyboard Shortcuts window (Edit menu, after Paste), Swift's chords, definitions and rules in QSettings, remapped keys on the menus, the canvas, the layer list, the window's key filter, open text and every sheet's Apply and Cancel (Levels' Preview too), Space ended by its own key. Evidence: 215 of 215 in the dev image, lint clean; mutants over the model, the settings, the rewriting, `NativeShortcut`, every sheet's binding, the canvas, list and filter wrappers, the menus and the sheet: all caught but the equivalent ones AGENTS.md names; dead terms left out (the Save action's recording test, `menu`'s empty-sequence guard, `NativeShortcut`'s enabled test).
- 12.2b round 1: FAIL, four findings. The recorder claimed no key, so a live menu shortcut ran instead of being recorded (a `Qt::Tool` panel keeps its parent's shortcuts; the test's window was never shown): it claims every key while recording, the test shows the window; a synthesized press carried the unshifted text, so a remapped hardness key stepped the size (Swift's shifted text now, tested); Tab moved the focus instead of recording (the recorder takes it); Cancel moved onto Return, or Apply onto Escape, left both dead (a `NativeShortcut` rests only on its own plain key now, tested). Each fix's mutant is caught.
- 12.2b round 2: PASS, no findings.
- [ ] 12.3 Version 1.1.7: rulers, guides, the layout grid and Snap To.
- [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region).
- [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet).
+6
View File
@@ -1,4 +1,5 @@
#include "Rendering/EditorCanvas.h"
#include "UI/KeyboardShortcuts.h"
#include "Rendering/EyedropperIcon.h"
#include <QApplication>
#include <QKeyEvent>
@@ -314,6 +315,11 @@ bool CanvasView::eventFilter(QObject *watched, QEvent *event)
const auto *widget = qobject_cast<QWidget *>(watched);
if (!widget || widget->window() != window())
return false;
// Remapped keys stand for theirs; a moved key passes on.
const std::unique_ptr<QKeyEvent> typed = ShortcutSettings::shared().canvasEvent(*key);
if (!typed)
return false;
key = typed.get();
// Shift-+ and Shift-− step the blend mode, except while typing.
const bool typing = qobject_cast<QLineEdit *>(QApplication::focusWidget()) || (m_inlineTextEditor && hasFocus());
if (event->type() != QEvent::KeyPress || typing || key->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))
+174
View File
@@ -0,0 +1,174 @@
#include "Rendering/EditorCanvas.h"
#include "UI/KeyboardShortcuts.h"
#include <QKeyEvent>
namespace {
bool isArrow(int key)
{
return key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down;
}
std::optional<NavigationTool> toolForKey(int key)
{
switch (key) {
case Qt::Key_B:
case Qt::Key_E:
return NavigationTool::brush;
case Qt::Key_J:
return NavigationTool::spotHealing;
case Qt::Key_S:
return NavigationTool::cloneStamp;
case Qt::Key_T:
return NavigationTool::type;
case Qt::Key_G:
return NavigationTool::gradient;
case Qt::Key_U:
return NavigationTool::shape;
case Qt::Key_I:
return NavigationTool::eyedropper;
case Qt::Key_W:
return NavigationTool::wand;
case Qt::Key_A:
return NavigationTool::idle;
case Qt::Key_R:
return NavigationTool::blur;
case Qt::Key_C:
return NavigationTool::crop;
case Qt::Key_V:
return NavigationTool::move;
case Qt::Key_H:
return NavigationTool::hand;
case Qt::Key_Z:
return NavigationTool::zoom;
default:
return std::nullopt;
}
}
}
void CanvasView::keyPressEvent(QKeyEvent *event)
{
// Remapped keys arrive as the keys they stand for.
ShortcutSettings &shortcuts = ShortcutSettings::shared();
const std::unique_ptr<QKeyEvent> typed = m_inlineTextEditor ? shortcuts.textEvent(*event) : shortcuts.canvasEvent(*event);
if (!typed)
return;
pressKey(typed.get(), event->key());
event->setAccepted(typed->isAccepted());
}
void CanvasView::pressKey(QKeyEvent *event, int physical)
{
// A drag swallows Alt's release: every key re-reads it.
m_optionHeld = event->modifiers().testFlag(Qt::AltModifier) || event->key() == Qt::Key_Alt;
m_controlHeld = event->modifiers().testFlag(Qt::ControlModifier) || event->key() == Qt::Key_Control;
m_shiftHeld = event->modifiers().testFlag(Qt::ShiftModifier) || event->key() == Qt::Key_Shift;
// Escape drops a text box being drawn, as Swift's first.
if (event->key() == Qt::Key_Escape && m_textBoxAnchor) {
endTextGesture();
return;
}
// Open text is the text view: it takes every key.
if (m_inlineTextEditor) {
if (!m_inlineTextEditor->keyPress(*event))
QWidget::keyPressEvent(event);
return;
}
const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier));
const bool enter = event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter;
const std::optional<NavigationTool> tool = plain ? toolForKey(event->key()) : std::nullopt;
const bool erase = event->key() == Qt::Key_Backspace || event->key() == Qt::Key_Delete;
if (erase && event->modifiers() == Qt::ShiftModifier) {
// Shift-Delete: Content-Aware Fill, taken either way.
if (m_session.canContentAwareFill())
m_session.beginFilter(FilterKind::contentAwareFill);
} else if (m_session.levels() && event->key() != Qt::Key_Space) {
// Open Levels takes its keys and passes the rest on.
if (event->key() == Qt::Key_Escape)
m_session.cancelLevels();
else if (enter)
m_session.commitLevels();
else if (event->key() == Qt::Key_P && event->modifiers().testFlag(Qt::AltModifier))
m_session.updateLevels(m_session.levels()->settings, !m_session.levels()->preview);
else
QWidget::keyPressEvent(event);
} else if (m_session.brushStroke() || m_session.warpStroke()) {
// A stroke takes every key; Escape cancels it.
if (event->key() == Qt::Key_Escape && !m_session.isProjectBusy()) {
m_session.cancelBrush();
synchronizeDisplay();
}
} else if (selectionKey(*event)) {
return;
} else if (event->key() == Qt::Key_Escape && m_session.shapeDraft()) {
m_session.cancelShape();
synchronizeDisplay();
} else if ((event->key() == Qt::Key_Escape || enter) && m_session.gradientEdit()) {
m_gradientDrag.reset();
if (enter)
m_session.commitGradient();
else
m_session.cancelGradient();
} else if (cropKey(*event)) {
return;
} else if ((event->key() == Qt::Key_Escape || enter) && m_session.transformEdit()) {
// The keys end a drag with its edit, cursor included.
m_transformDrag.reset();
m_dragCursor.reset();
if (enter)
m_session.commitTransform();
else
m_session.cancelTransform();
updateCursor();
} else if (isArrow(event->key()) && event->modifiers().testFlag(Qt::ControlModifier) && !(event->modifiers() & (Qt::AltModifier | Qt::MetaModifier))
&& !m_session.lassoDraft() && m_session.selection() && !m_session.selection()->isEmpty()) {
// Ctrl-arrow moves the selected pixels in any tool.
const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1;
m_session.nudgePixels(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0,
event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0, [canvas = QPointer<CanvasView>(this)] {
if (canvas)
canvas->synchronizeDisplay();
});
} else if (m_session.tool() == NavigationTool::move && plain && isArrow(event->key())) {
const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1;
m_session.nudgeLayer(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0,
event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0);
} else if (erase && !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
m_session.deleteKeyPressed();
} else if (event->key() == Qt::Key_Space) {
// Swift's panPhysicalKey: the key held, whatever it stands for.
m_panKey = physical;
m_spaceHeld = true;
updateCursor();
updateBrushCursor();
} else if (brushKey(*event)) {
return;
} else if (plain && (event->key() == Qt::Key_X || event->key() == Qt::Key_D)) {
// X swaps the palette; D brings back black and white.
if (event->key() == Qt::Key_X)
m_session.swapPaletteColors();
else
m_session.resetPaletteColors();
} else if (tool == NavigationTool::shape && event->modifiers().testFlag(Qt::ShiftModifier) && m_session.tool() == NavigationTool::shape) {
m_session.toggleShapeKind();
} else if (tool) {
m_session.selectTool(*tool);
} else {
QWidget::keyPressEvent(event);
}
}
void CanvasView::keyReleaseEvent(QKeyEvent *event)
{
// Auto-repeat sends release and press pairs: not a release.
if (event->isAutoRepeat()) {
QWidget::keyReleaseEvent(event);
} else if (event->key() == m_panKey.value_or(Qt::Key_Space)) {
m_panKey.reset();
m_spaceHeld = false;
updateCursor();
updateBrushCursor();
} else {
QWidget::keyReleaseEvent(event);
}
}
-157
View File
@@ -11,50 +11,6 @@
#include <QWindow>
#include <cmath>
namespace {
bool isArrow(int key)
{
return key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down;
}
std::optional<NavigationTool> toolForKey(int key)
{
switch (key) {
case Qt::Key_B:
case Qt::Key_E:
return NavigationTool::brush;
case Qt::Key_J:
return NavigationTool::spotHealing;
case Qt::Key_S:
return NavigationTool::cloneStamp;
case Qt::Key_T:
return NavigationTool::type;
case Qt::Key_G:
return NavigationTool::gradient;
case Qt::Key_U:
return NavigationTool::shape;
case Qt::Key_I:
return NavigationTool::eyedropper;
case Qt::Key_W:
return NavigationTool::wand;
case Qt::Key_A:
return NavigationTool::idle;
case Qt::Key_R:
return NavigationTool::blur;
case Qt::Key_C:
return NavigationTool::crop;
case Qt::Key_V:
return NavigationTool::move;
case Qt::Key_H:
return NavigationTool::hand;
case Qt::Key_Z:
return NavigationTool::zoom;
default:
return std::nullopt;
}
}
}
bool CanvasView::event(QEvent *event)
{
// Open text takes its editing keys ahead of the menus.
@@ -343,119 +299,6 @@ void CanvasView::mouseReleaseEvent(QMouseEvent *event)
synchronizeDisplay();
}
void CanvasView::keyPressEvent(QKeyEvent *event)
{
// A drag swallows Alt's release: every key re-reads it.
m_optionHeld = event->modifiers().testFlag(Qt::AltModifier) || event->key() == Qt::Key_Alt;
m_controlHeld = event->modifiers().testFlag(Qt::ControlModifier) || event->key() == Qt::Key_Control;
m_shiftHeld = event->modifiers().testFlag(Qt::ShiftModifier) || event->key() == Qt::Key_Shift;
// Escape drops a text box being drawn, as Swift's first.
if (event->key() == Qt::Key_Escape && m_textBoxAnchor) {
endTextGesture();
return;
}
// Open text is the text view: it takes every key.
if (m_inlineTextEditor) {
if (!m_inlineTextEditor->keyPress(*event))
QWidget::keyPressEvent(event);
return;
}
const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier));
const bool enter = event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter;
const std::optional<NavigationTool> tool = plain ? toolForKey(event->key()) : std::nullopt;
const bool erase = event->key() == Qt::Key_Backspace || event->key() == Qt::Key_Delete;
if (erase && event->modifiers() == Qt::ShiftModifier) {
// Shift-Delete: Content-Aware Fill, taken either way.
if (m_session.canContentAwareFill())
m_session.beginFilter(FilterKind::contentAwareFill);
} else if (m_session.levels() && event->key() != Qt::Key_Space) {
// Open Levels takes its keys and passes the rest on.
if (event->key() == Qt::Key_Escape)
m_session.cancelLevels();
else if (enter)
m_session.commitLevels();
else if (event->key() == Qt::Key_P && event->modifiers().testFlag(Qt::AltModifier))
m_session.updateLevels(m_session.levels()->settings, !m_session.levels()->preview);
else
QWidget::keyPressEvent(event);
} else if (m_session.brushStroke() || m_session.warpStroke()) {
// A stroke takes every key; Escape cancels it.
if (event->key() == Qt::Key_Escape && !m_session.isProjectBusy()) {
m_session.cancelBrush();
synchronizeDisplay();
}
} else if (selectionKey(*event)) {
return;
} else if (event->key() == Qt::Key_Escape && m_session.shapeDraft()) {
m_session.cancelShape();
synchronizeDisplay();
} else if ((event->key() == Qt::Key_Escape || enter) && m_session.gradientEdit()) {
m_gradientDrag.reset();
if (enter)
m_session.commitGradient();
else
m_session.cancelGradient();
} else if (cropKey(*event)) {
return;
} else if ((event->key() == Qt::Key_Escape || enter) && m_session.transformEdit()) {
// The keys end a drag with its edit, cursor included.
m_transformDrag.reset();
m_dragCursor.reset();
if (enter)
m_session.commitTransform();
else
m_session.cancelTransform();
updateCursor();
} else if (isArrow(event->key()) && event->modifiers().testFlag(Qt::ControlModifier) && !(event->modifiers() & (Qt::AltModifier | Qt::MetaModifier))
&& !m_session.lassoDraft() && m_session.selection() && !m_session.selection()->isEmpty()) {
// Ctrl-arrow moves the selected pixels in any tool.
const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1;
m_session.nudgePixels(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0,
event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0, [canvas = QPointer<CanvasView>(this)] {
if (canvas)
canvas->synchronizeDisplay();
});
} else if (m_session.tool() == NavigationTool::move && plain && isArrow(event->key())) {
const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1;
m_session.nudgeLayer(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0,
event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0);
} else if (erase && !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
m_session.deleteKeyPressed();
} else if (event->key() == Qt::Key_Space) {
m_spaceHeld = true;
updateCursor();
updateBrushCursor();
} else if (brushKey(*event)) {
return;
} else if (plain && (event->key() == Qt::Key_X || event->key() == Qt::Key_D)) {
// X swaps the palette; D brings back black and white.
if (event->key() == Qt::Key_X)
m_session.swapPaletteColors();
else
m_session.resetPaletteColors();
} else if (tool == NavigationTool::shape && event->modifiers().testFlag(Qt::ShiftModifier) && m_session.tool() == NavigationTool::shape) {
m_session.toggleShapeKind();
} else if (tool) {
m_session.selectTool(*tool);
} else {
QWidget::keyPressEvent(event);
}
}
void CanvasView::keyReleaseEvent(QKeyEvent *event)
{
// Auto-repeat sends release and press pairs: not a release.
if (event->isAutoRepeat()) {
QWidget::keyReleaseEvent(event);
} else if (event->key() == Qt::Key_Space) {
m_spaceHeld = false;
updateCursor();
updateBrushCursor();
} else {
QWidget::keyReleaseEvent(event);
}
}
void CanvasView::focusInEvent(QFocusEvent *event)
{
if (m_inlineTextEditor)
+4
View File
@@ -187,6 +187,8 @@ private:
bool moveSelectionTool(QPointF point, Qt::KeyboardModifiers modifiers);
void releaseSelectionTool();
bool selectionKey(const QKeyEvent &key);
// The keys as the canvas reads them, after remapping.
void pressKey(QKeyEvent *event, int physical);
void endSelectionGestures();
void dragSelection(QPointF point, Qt::KeyboardModifiers modifiers);
void dragMarqueeDraft(QPointF pixel, Qt::KeyboardModifiers modifiers);
@@ -255,6 +257,8 @@ private:
std::optional<NavigationTool> m_displayedTool;
int m_lastFocusRequest = 0;
bool m_spaceHeld = false;
// The key that began the pan, when Space is remapped.
std::optional<int> m_panKey;
bool m_optionHeld = false;
std::optional<QPointF> m_lastDragPoint;
std::optional<ZoomDrag> m_zoomDrag;
+3
View File
@@ -1,4 +1,5 @@
#include "UI/CanvasSizeSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
@@ -205,6 +206,8 @@ CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor fo
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_ok->setObjectName(QStringLiteral("canvasOK"));
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, cancel);
// OK rests while the draft is invalid: no guard needed.
connect(m_ok, &QPushButton::clicked, this, [this] {
m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor,
+3
View File
@@ -1,4 +1,5 @@
#include "UI/ColorPickerSheet.h"
#include "UI/KeyboardShortcuts.h"
#include <QFontDatabase>
#include <QGridLayout>
#include <QHBoxLayout>
@@ -183,6 +184,8 @@ ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bo
m_preview->setAccessibleName(QStringLiteral("New color"));
m_ok->setObjectName(QStringLiteral("pickerOK"));
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, m_cancel);
m_ok->setFixedWidth(90);
m_cancel->setObjectName(QStringLiteral("pickerCancel"));
m_cancel->setAutoDefault(false);
+17 -1
View File
@@ -1,5 +1,6 @@
#include "UI/CompositorMenus.h"
#include "Logging.h"
#include "UI/KeyboardShortcuts.h"
#include <QApplication>
#include <QMenu>
@@ -88,6 +89,10 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
qCWarning(lcApp) << "nothing to paste";
});
edit->addSeparator();
add(edit, QStringLiteral("keyboardShortcuts"), QStringLiteral("Keyboard Shortcuts…"), QKeySequence(), [this] {
m_shortcutsPanel.onClose = [this] { m_shortcutsPanel.close(); };
m_shortcutsPanel.show(QStringLiteral("Keyboard Shortcuts"), new KeyboardShortcutsSheet([this] { m_shortcutsPanel.close(); }));
});
// Text fields delete the selection, else back from the caret.
add(edit, QStringLiteral("fillForeground"), QStringLiteral("Fill with Foreground Color"), QKeySequence(Qt::ALT | Qt::Key_Backspace), [this] {
if (!m_field) {
@@ -215,6 +220,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
connect(&m_workspace, &ProjectWorkspace::changed, this, &CompositorMenus::watchFront);
// A field gaining or losing focus changes Undo's meaning.
connect(qApp, &QApplication::focusChanged, this, &CompositorMenus::focusMoved);
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &CompositorMenus::remap);
watchFront();
}
@@ -222,11 +228,21 @@ QAction *CompositorMenus::add(QMenu *menu, const QString &name, const QString &t
{
QAction *made = menu->addAction(text);
made->setObjectName(name);
made->setShortcut(shortcut);
made->setProperty("originalShortcut", shortcut);
made->setShortcut(ShortcutSettings::shared().menu(shortcut));
connect(made, &QAction::triggered, this, run);
return made;
}
void CompositorMenus::remap()
{
for (QAction *entry : parent()->findChildren<QAction *>()) {
const QVariant original = entry->property("originalShortcut");
if (original.isValid())
entry->setShortcut(ShortcutSettings::shared().menu(original.value<QKeySequence>()));
}
}
QAction *CompositorMenus::action(const QString &name) const
{
return parent()->findChild<QAction *>(name);
+5
View File
@@ -1,6 +1,7 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include "Rendering/EditorCanvas.h"
#include "UI/FloatingPanel.h"
#include <QAction>
#include <QLineEdit>
#include <QMenuBar>
@@ -24,8 +25,12 @@ private:
ProjectController &projects() const { return m_workspace.current().controller; }
QAction *add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run);
// Each entry's own key, remapped from ShortcutSettings.
void remap();
ProjectWorkspace &m_workspace;
QWidget &m_window;
FloatingPanel m_shortcutsPanel{QStringLiteral("keyboardShortcuts"), m_window};
QMetaObject::Connection m_sessionWatch;
std::optional<QUuid> m_watchedTab;
// The field whose undo the Edit entries drive.
+3
View File
@@ -1,4 +1,5 @@
#include "UI/EffectsSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
@@ -91,6 +92,8 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
auto *ok = new QPushButton(QStringLiteral("OK"), this);
ok->setObjectName(QStringLiteral("effectsOK"));
ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, ok, cancel);
connect(cancel, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(false); });
connect(ok, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(true); });
auto *buttons = new QHBoxLayout;
+3
View File
@@ -1,4 +1,5 @@
#include "UI/FilterSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include "UI/CurvesControls.h"
@@ -213,6 +214,8 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
m_cancel->setAutoDefault(false);
m_ok->setObjectName(QStringLiteral("filterOK"));
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, m_cancel);
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateFilter(settings(), on); });
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelFilter(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitFilter(); });
+3
View File
@@ -1,4 +1,5 @@
#include "UI/HueSaturationSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "Rendering/EyedropperIcon.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
@@ -322,6 +323,8 @@ HueSaturationSheet::HueSaturationSheet(EditorSession &session, QWidget *parent)
// Return is OK's from anywhere, as Swift's default action.
m_cancel->setAutoDefault(false);
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, m_cancel);
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelHueSaturation(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitHueSaturation(); });
auto *column = new QVBoxLayout(this);
+3
View File
@@ -1,4 +1,5 @@
#include "UI/ImageSizeSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
#include <QComboBox>
@@ -143,6 +144,8 @@ ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<voi
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_resize->setObjectName(QStringLiteral("imageResize"));
m_resize->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_resize, cancel);
// Resize rests while the size is invalid: no guard needed.
connect(m_resize, &QPushButton::clicked, this, [this] {
m_finish(ImageSizeOptions{.width = qint64(std::round(m_width)), .height = qint64(std::round(m_height)), .resolution = m_resolution,
+3
View File
@@ -1,4 +1,5 @@
#include "UI/JPEGExportSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
#include <QColorDialog>
@@ -148,6 +149,8 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::op
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_export->setObjectName(QStringLiteral("jpegExport"));
m_export->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_export, cancel);
// Export rests until a result is ready.
connect(m_export, &QPushButton::clicked, this, [this] {
QSettings().setValue(qualityKey, m_options.quality);
+205
View File
@@ -0,0 +1,205 @@
#include "UI/KeyboardShortcuts.h"
#include <QApplication>
#include <QFrame>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QScrollArea>
#include <QVBoxLayout>
namespace {
QLabel *headline(const QString &text, QWidget *parent)
{
auto *label = new QLabel(text, parent);
QFont font = label->font();
font.setPixelSize(13);
font.setWeight(QFont::DemiBold);
label->setFont(font);
return label;
}
QLabel *words(const QString &text, QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setWordWrap(true);
return label;
}
QFrame *divider(QWidget *parent)
{
auto *line = new QFrame(parent);
line->setFrameShape(QFrame::HLine);
line->setForegroundRole(QPalette::Mid);
return line;
}
bool isModifier(int key)
{
return key == Qt::Key_Shift || key == Qt::Key_Control || key == Qt::Key_Alt || key == Qt::Key_Meta || key == Qt::Key_AltGr
|| key == Qt::Key_Super_L || key == Qt::Key_Super_R;
}
}
ShortcutRecorder::ShortcutRecorder(std::function<void()> start, std::function<void(ShortcutChord)> finish, QWidget *parent)
: QPushButton(parent), m_start(std::move(start)), m_finish(std::move(finish))
{
setAutoDefault(false);
setFixedSize(150, 26);
connect(this, &QPushButton::clicked, this, [this] {
setFocus();
m_start();
});
}
void ShortcutRecorder::display(const ShortcutChord &chord, bool recording)
{
m_recording = recording;
setText(recording ? QStringLiteral("Press keys…") : chord.label());
setAccessibleName(recording ? QStringLiteral("Press a shortcut") : chord.label());
}
// While recording every key is the recorder's: menus' and Tab.
bool ShortcutRecorder::event(QEvent *event)
{
if (m_recording && event->type() == QEvent::ShortcutOverride) {
event->accept();
return true;
}
if (m_recording && event->type() == QEvent::KeyPress) {
keyPressEvent(static_cast<QKeyEvent *>(event));
return true;
}
return QPushButton::event(event);
}
void ShortcutRecorder::keyPressEvent(QKeyEvent *event)
{
if (!m_recording) {
QPushButton::keyPressEvent(event);
return;
}
// A modifier alone is still being held down.
if (isModifier(event->key()))
return;
const ShortcutChord chord(*event);
if (chord.key.size() != 1) {
QApplication::beep();
return;
}
m_finish(chord);
clearFocus();
}
KeyboardShortcutsSheet::KeyboardShortcutsSheet(std::function<void()> close, QWidget *parent)
: QWidget(parent), m_close(std::move(close)), m_draft(ShortcutSettings::shared().overrides()), m_search(new QLineEdit(this)),
m_problem(new QLabel(this)), m_save(new QPushButton(QStringLiteral("Save"), this))
{
setObjectName(QStringLiteral("keyboardShortcutsSheet"));
setFixedWidth(660);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(10);
QLabel *intro = words(QStringLiteral("Click a shortcut, then press its new key combination. Changes apply when you save."), this);
intro->setForegroundRole(QPalette::PlaceholderText);
column->addWidget(intro);
m_search->setObjectName(QStringLiteral("shortcutSearch"));
m_search->setPlaceholderText(QStringLiteral("Search shortcuts"));
column->addWidget(m_search);
auto *scroll = new QScrollArea(this);
scroll->setFixedHeight(465);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *list = new QWidget(scroll);
auto *rows = new QVBoxLayout(list);
rows->setContentsMargins(0, 0, 8, 0);
rows->setSpacing(6);
for (const QString &group : {QStringLiteral("Menus"), QStringLiteral("Canvas & Layers"), QStringLiteral("Text Editing")}) {
rows->addSpacing(8);
rows->addWidget(headline(group, list));
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
if (definition.group != group)
continue;
const QString id = definition.id();
auto *row = new QWidget(list);
auto *line = new QHBoxLayout(row);
line->setContentsMargins(0, 0, 0, 0);
line->addWidget(new QLabel(definition.title, row));
line->addStretch(1);
auto *recorder = new ShortcutRecorder([this, id] {
m_recording = id;
synchronize();
}, [this, id](const ShortcutChord &chord) {
m_draft.insert(id, chord);
m_recording = std::nullopt;
synchronize();
}, row);
recorder->setObjectName(QStringLiteral("recorder:") + id);
line->addWidget(recorder);
rows->addWidget(row);
m_rows.push_back({row, recorder, &definition});
}
}
rows->addWidget(divider(list));
rows->addWidget(headline(QStringLiteral("Contextual keys & mouse gestures"), list));
rows->addWidget(words(QStringLiteral("Text fields keep their standard editing keys. Dialogs share the Apply and Cancel assignments above. "
"Numeric fields use Up and Down, with Shift for larger steps. The shortcut editor itself always "
"uses Return to save and Esc to cancel when not recording."), list));
rows->addWidget(words(QStringLiteral("Alt temporarily selects the eyedropper in painting tools. Shift constrains shapes and movement or adds to "
"a selection; Alt subtracts from selections or draws from the centre. Ctrl-drag moves selected pixels; "
"Ctrl-Alt-drag copies them. Alt-drag duplicates layers, folders and effects; Alt-click at a layer boundary "
"toggles clipping. Ctrl-click a thumbnail loads its selection. Ctrl bypasses snapping. Right-drag adjusts "
"brush size. Modifier-and-mouse gestures are fixed."), list));
rows->addStretch(1);
scroll->setWidget(list);
column->addWidget(scroll);
m_problem->setObjectName(QStringLiteral("shortcutProblem"));
m_problem->setForegroundRole(QPalette::BrightText);
QFont callout = m_problem->font();
callout.setPixelSize(12);
m_problem->setFont(callout);
m_problem->setWordWrap(true);
column->addWidget(m_problem);
column->addWidget(divider(this));
auto *buttons = new QHBoxLayout;
auto *restore = new QPushButton(QStringLiteral("Restore Defaults"), this);
restore->setObjectName(QStringLiteral("restoreShortcuts"));
restore->setAutoDefault(false);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("cancelShortcuts"));
cancel->setAutoDefault(false);
m_save->setObjectName(QStringLiteral("saveShortcuts"));
m_save->setDefault(true);
buttons->addWidget(restore);
buttons->addStretch(1);
buttons->addWidget(cancel);
buttons->addWidget(m_save);
column->addLayout(buttons);
connect(m_search, &QLineEdit::textChanged, this, &KeyboardShortcutsSheet::synchronize);
connect(restore, &QPushButton::clicked, this, [this] {
m_recording = std::nullopt;
m_draft.clear();
synchronize();
});
connect(cancel, &QPushButton::clicked, this, [this] { m_close(); });
connect(m_save, &QPushButton::clicked, this, [this] {
if (ShortcutSettings::shared().save(m_draft))
m_close();
});
synchronize();
}
void KeyboardShortcutsSheet::synchronize()
{
const QString search = m_search->text();
for (const Row &each : m_rows) {
const QString id = each.definition->id();
each.row->setVisible(search.isEmpty() || each.definition->title.contains(search, Qt::CaseInsensitive));
each.recorder->display(m_draft.value(id, each.definition->original), m_recording == id);
}
// Only a conflict takes room above the buttons.
const std::optional<QString> problem = ShortcutSettings::problem(m_draft);
m_problem->setText(problem.value_or(QString()));
m_problem->setVisible(problem.has_value());
m_save->setEnabled(!m_recording && !problem);
}
+351
View File
@@ -0,0 +1,351 @@
#include "UI/KeyboardShortcuts.h"
#include "Logging.h"
#include <QAbstractButton>
#include <QJsonDocument>
#include <QJsonObject>
#include <QKeyEvent>
#include <QSettings>
#include <QShortcut>
#include <array>
namespace {
// Swift's special keys, their Qt keys and their names.
struct Special {
QString key;
Qt::Key qt;
};
const std::array specials{
Special{QStringLiteral("\x7f"), Qt::Key_Backspace}, Special{QStringLiteral("\r"), Qt::Key_Return},
Special{QStringLiteral("\x1b"), Qt::Key_Escape}, Special{QStringLiteral("\t"), Qt::Key_Tab},
Special{QStringLiteral(" "), Qt::Key_Space}, Special{QString(QChar(0xf702)), Qt::Key_Left},
Special{QString(QChar(0xf703)), Qt::Key_Right}, Special{QString(QChar(0xf701)), Qt::Key_Down},
Special{QString(QChar(0xf700)), Qt::Key_Up},
};
int bits(Qt::KeyboardModifiers modifiers)
{
return (modifiers.testFlag(Qt::ControlModifier) ? 1 : 0) | (modifiers.testFlag(Qt::AltModifier) ? 2 : 0)
| (modifiers.testFlag(Qt::MetaModifier) ? 4 : 0) | (modifiers.testFlag(Qt::ShiftModifier) ? 8 : 0);
}
Qt::KeyboardModifiers flags(int bits)
{
Qt::KeyboardModifiers result;
result.setFlag(Qt::ControlModifier, bits & 1);
result.setFlag(Qt::AltModifier, bits & 2);
result.setFlag(Qt::MetaModifier, bits & 4);
result.setFlag(Qt::ShiftModifier, bits & 8);
return result;
}
// A key as Swift's charactersIgnoringModifiers reads it, lowered.
QString keyText(int key)
{
if (key == Qt::Key_Delete || key == Qt::Key_Backspace)
return QStringLiteral("\x7f");
if (key == Qt::Key_Enter)
return QStringLiteral("\r");
if (key == Qt::Key_Backtab)
return QStringLiteral("\t");
for (const Special &special : specials) {
if (special.qt == key)
return special.key;
}
const QString typed = QKeySequence(key).toString(QKeySequence::PortableText).toLower();
// Shifted brackets and signs count as their own keys.
static const QHash<QString, QString> unshifted{{QStringLiteral("{"), QStringLiteral("[")}, {QStringLiteral("}"), QStringLiteral("]")},
{QStringLiteral("+"), QStringLiteral("=")}, {QStringLiteral("_"), QStringLiteral("-")}};
return unshifted.value(typed, typed);
}
// Swift's shifted characters for the keys Shift changes.
QString shiftedText(const QString &key, bool shifted)
{
static const QHash<QString, QString> shifts{{QStringLiteral("["), QStringLiteral("{")}, {QStringLiteral("]"), QStringLiteral("}")},
{QStringLiteral("="), QStringLiteral("+")}, {QStringLiteral("-"), QStringLiteral("_")}};
return shifted ? shifts.value(key, key) : key;
}
Qt::Key qtKey(const QString &key, bool shifted)
{
for (const Special &special : specials) {
if (special.key == key)
return special.qt;
}
const QString typed = shiftedText(key, shifted);
return typed.size() == 1 ? Qt::Key(typed.at(0).toUpper().unicode()) : Qt::Key_unknown;
}
ShortcutDefinition entry(const QString &title, const QString &key, int modifiers, bool menu)
{
return {title, menu ? QStringLiteral("Menus") : QStringLiteral("Canvas & Layers"), ShortcutChord(key, modifiers)};
}
QJsonObject encoded(const QHash<QString, ShortcutChord> &values)
{
QJsonObject object;
for (auto value = values.cbegin(); value != values.cend(); ++value)
object.insert(value.key(), QJsonObject{{QStringLiteral("key"), value->key}, {QStringLiteral("modifiers"), value->modifiers}});
return object;
}
}
ShortcutChord::ShortcutChord(const QKeyEvent &event) : key(keyText(event.key())), modifiers(bits(event.modifiers())) {}
ShortcutChord::ShortcutChord(QKeyCombination combination)
: key(keyText(combination.key())), modifiers(bits(combination.keyboardModifiers()))
{
}
QKeyCombination ShortcutChord::combination() const
{
return QKeyCombination(flags(modifiers), qtKey(key, modifiers & 8));
}
QString ShortcutChord::label() const
{
return QKeySequence(combination()).toString(QKeySequence::NativeText);
}
std::unique_ptr<QKeyEvent> ShortcutChord::event(const QKeyEvent &like) const
{
const Qt::Key typed = qtKey(key, modifiers & 8);
const QString text = typed == Qt::Key_Space || key.size() != 1 || key.at(0).unicode() >= 0xf700 || key.at(0).unicode() < 0x20
? QString()
: shiftedText(key, modifiers & 8);
return std::make_unique<QKeyEvent>(like.type(), typed, flags(modifiers), text, like.isAutoRepeat(), like.count());
}
const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
{
static const std::vector<ShortcutDefinition> definitions = [] {
const QString backspace = QStringLiteral("\x7f");
// Swift's list; Hide Compositor has no twin on Linux.
std::vector<ShortcutDefinition> result{
entry("Undo", "z", 1, true), entry("Redo", "z", 9, true), entry("New Canvas", "n", 1, true),
entry("Open Project", "o", 1, true), entry("Save", "s", 1, true), entry("Save As", "s", 9, true),
entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Close Project", "w", 1, true),
entry("Fit Canvas", "0", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true),
entry("Zoom Out", "-", 1, true), entry("Show Transform Controls", "h", 1, true), entry("Cut", "x", 1, true),
entry("Copy", "c", 1, true), entry("Copy Merged", "c", 9, true), entry("Paste", "v", 1, true),
entry("Fill with Foreground", backspace, 2, true), entry("Fill with Background", backspace, 1, true),
entry("Content-Aware Fill", backspace, 8, true), entry("Select All", "a", 1, true), entry("Deselect", "d", 1, true),
entry("Inverse Selection", "i", 9, true), entry("Select Subject", "a", 3, true), entry("Curves", "m", 1, true),
entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true),
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true)};
const std::vector<std::pair<const char *, QString>> tools{
{"Select tool", "a"}, {"Move / Transform tool", "v"}, {"Hand tool", "h"}, {"Zoom tool", "z"}, {"Brush tool", "b"},
{"Eraser", "e"}, {"Spot Healing", "j"}, {"Clone Stamp", "s"}, {"Type tool", "t"}, {"Gradient tool", "g"},
{"Shape tool", "u"}, {"Eyedropper tool", "i"}, {"Marquee / cycle shape", "m"}, {"Magic Wand", "w"},
{"Lasso / cycle mode", "l"}, {"Blur / Smudge / Liquify", "r"}, {"Crop tool", "c"}, {"Swap foreground/background", "x"},
{"Reset colors", "d"}, {"Cycle tool mode", "\t"}, {"Temporary Hand tool (hold)", " "},
{"Delete selection / layer / effect / lasso point", backspace}, {"Apply current canvas operation", "\r"},
{"Cancel current canvas operation", "\x1b"}, {"Decrease brush size", "["}, {"Increase brush size", "]"}};
for (const auto &[title, key] : tools)
result.push_back(entry(title, key, 0, false));
for (const auto &[title, key] : std::vector<std::pair<const char *, const char *>>{
{"Decrease brush hardness", "["}, {"Increase brush hardness", "]"}, {"Previous blend mode", "-"}, {"Next blend mode", "="}, {"Cycle shape kind", "u"}})
result.push_back(entry(title, key, 8, false));
for (int digit = 0; digit <= 9; ++digit)
result.push_back(entry(QStringLiteral("Opacity digit %1 (type two for exact %)").arg(digit), QString::number(digit), 0, false));
for (const auto &[direction, key] : std::vector<std::pair<QString, QString>>{
{"Left", QString(QChar(0xf702))}, {"Right", QString(QChar(0xf703))}, {"Up", QString(QChar(0xf700))}, {"Down", QString(QChar(0xf701))}}) {
result.push_back(entry(QStringLiteral("Nudge %1 1 px").arg(direction), key, 0, false));
result.push_back(entry(QStringLiteral("Nudge %1 10 px").arg(direction), key, 8, false));
result.push_back(entry(QStringLiteral("Move selected pixels %1 1 px").arg(direction), key, 1, false));
result.push_back(entry(QStringLiteral("Move selected pixels %1 10 px").arg(direction), key, 9, false));
}
const QString text = QStringLiteral("Text Editing");
result.push_back({QStringLiteral("Finish editing text"), text, ShortcutChord(QStringLiteral("\r"), 1)});
for (const auto &[title, key] : std::vector<std::pair<QString, QString>>{{"Decrease tracking", QString(QChar(0xf702))},
{"Increase tracking", QString(QChar(0xf703))},
{"Decrease leading", QString(QChar(0xf700))},
{"Increase leading", QString(QChar(0xf701))}}) {
result.push_back({title, text, ShortcutChord(key, 2)});
result.push_back({title + QStringLiteral(" by 10"), text, ShortcutChord(key, 10)});
}
result.push_back(entry("Toggle Levels preview", "p", 2, false));
return result;
}();
return definitions;
}
ShortcutSettings &ShortcutSettings::shared()
{
static ShortcutSettings settings;
return settings;
}
ShortcutSettings::ShortcutSettings()
{
reload();
}
void ShortcutSettings::reload()
{
m_overrides.clear();
const QByteArray data = QSettings().value(QLatin1String(storageKey)).toByteArray();
if (!data.isEmpty()) {
QHash<QString, ShortcutChord> saved;
const QJsonObject object = QJsonDocument::fromJson(data).object();
for (auto value = object.constBegin(); value != object.constEnd(); ++value) {
const QJsonObject chord = value->toObject();
saved.insert(value.key(), ShortcutChord(chord.value(QLatin1String("key")).toString(), chord.value(QLatin1String("modifiers")).toInt(-1)));
}
// Swift keeps stored overrides only while they hold together.
if (const std::optional<QString> wrong = problem(saved))
qCWarning(lcApp).noquote() << "stored keyboard shortcuts ignored:" << *wrong;
else
m_overrides = saved;
}
emit changed();
}
ShortcutChord ShortcutSettings::chord(const ShortcutDefinition &definition) const
{
return m_overrides.value(definition.id(), definition.original);
}
QKeySequence ShortcutSettings::menu(const QKeySequence &original) const
{
// An empty sequence reads as no key, which nothing matches.
const ShortcutChord typed(original[0]);
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
if (definition.isMenu() && definition.original == typed)
return QKeySequence(chord(definition).combination());
}
return original;
}
ShortcutChord ShortcutSettings::native(const ShortcutChord &original) const
{
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
if (!definition.isMenu() && definition.original == original)
return chord(definition);
}
return original;
}
bool ShortcutSettings::save(const QHash<QString, ShortcutChord> &values)
{
if (problem(values))
return false;
m_overrides = values;
QSettings().setValue(QLatin1String(storageKey), QJsonDocument(encoded(values)).toJson(QJsonDocument::Compact));
emit changed();
return true;
}
std::optional<QString> ShortcutSettings::problem(const QHash<QString, ShortcutChord> &values)
{
QHash<QString, QString> assigned;
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
const ShortcutChord chord = values.value(definition.id(), definition.original);
if (chord.key.size() != 1 || chord.modifiers < 0 || chord.modifiers > 15)
return QStringLiteral("Choose a single key with optional modifiers.");
if (definition.group == QLatin1String("Text Editing") && !(chord.modifiers & 7))
return QStringLiteral("Text-editing shortcuts need Ctrl, Alt or Meta so they do not replace normal typing.");
const QString name = QString::number(chord.modifiers) + QLatin1Char(':') + chord.key;
if (assigned.contains(name))
return QStringLiteral("%1 is assigned to both %2 and %3.").arg(chord.label(), assigned.value(name), definition.title);
assigned.insert(name, definition.title);
}
return std::nullopt;
}
std::unique_ptr<QKeyEvent> ShortcutSettings::canvasEvent(const QKeyEvent &event) const
{
std::unique_ptr<QKeyEvent> same(event.clone());
if (m_overrides.isEmpty())
return same;
const ShortcutChord input(event);
const std::vector<ShortcutDefinition> &all = ShortcutDefinition::all();
for (const ShortcutDefinition &definition : all) {
if (definition.group == QLatin1String("Canvas & Layers") && chord(definition) == input)
return definition.original == input ? std::move(same) : definition.original.event(event);
}
for (const ShortcutDefinition &definition : all) {
if (definition.group != QLatin1String("Text Editing") && definition.original == input && chord(definition) != input)
return nullptr;
}
// Tool letters also take Shift, unless Shift has its own.
if (input.modifiers == 8) {
const ShortcutChord plain(input.key);
for (const ShortcutDefinition &definition : all) {
if (!definition.isMenu() && definition.original.modifiers == 0 && chord(definition) == plain)
return ShortcutChord(definition.original.key, 8).event(event);
}
for (const ShortcutDefinition &definition : all) {
if (!definition.isMenu() && definition.original == plain && chord(definition) != plain)
return nullptr;
}
}
return same;
}
std::unique_ptr<QKeyEvent> ShortcutSettings::textEvent(const QKeyEvent &event) const
{
std::unique_ptr<QKeyEvent> same(event.clone());
if (m_overrides.isEmpty())
return same;
std::vector<ShortcutDefinition> definitions;
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
if (definition.group == QLatin1String("Text Editing") || definition.original == ShortcutChord(QStringLiteral("\x1b")))
definitions.push_back(definition);
}
const ShortcutChord input(event);
for (const ShortcutDefinition &definition : definitions) {
if (chord(definition) == input)
return definition.original == input ? std::move(same) : definition.original.event(event);
}
for (const ShortcutDefinition &definition : definitions) {
if (definition.original == input && chord(definition) != input)
return nullptr;
}
return same;
}
NativeShortcut::NativeShortcut(const ShortcutChord &original, QWidget &scope, QAbstractButton &target)
: QObject(&scope), m_original(original), m_scope(scope), m_target(&target), m_shortcut(new QShortcut(&scope))
{
m_shortcut->setContext(Qt::WidgetWithChildrenShortcut);
// A disabled button ignores click() itself.
connect(m_shortcut, &QShortcut::activated, this, [this] {
if (m_target && m_target->isVisible())
m_target->click();
});
scope.installEventFilter(this);
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &NativeShortcut::synchronize);
synchronize();
}
void NativeShortcut::bind(QWidget &scope, QAbstractButton *apply, QAbstractButton *cancel)
{
if (apply)
new NativeShortcut(ShortcutChord(QStringLiteral("\r")), scope, *apply);
if (cancel)
new NativeShortcut(ShortcutChord(QStringLiteral("\x1b")), scope, *cancel);
}
void NativeShortcut::synchronize()
{
const ShortcutChord chord = ShortcutSettings::shared().native(m_original);
m_shortcut->setKey(QKeySequence(chord.combination()));
// Its own plain Return or Escape already reaches the button.
const bool plain = !m_original.modifiers && (m_original.key == QLatin1String("\r") || m_original.key == QLatin1String("\x1b"));
m_shortcut->setEnabled(!(plain && chord == m_original));
}
bool NativeShortcut::eventFilter(QObject *watched, QEvent *event)
{
// The original key, moved elsewhere, no longer acts.
if (watched == &m_scope && event->type() == QEvent::KeyPress) {
const ShortcutChord typed(*static_cast<QKeyEvent *>(event));
if (typed == m_original && ShortcutSettings::shared().native(m_original) != m_original)
return true;
}
return QObject::eventFilter(watched, event);
}
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <QHash>
#include <QKeySequence>
#include <QObject>
#include <QPointer>
#include <QPushButton>
#include <QString>
#include <functional>
#include <memory>
#include <optional>
#include <vector>
class QAbstractButton;
class QKeyEvent;
class QLabel;
class QLineEdit;
class QShortcut;
class QWidget;
// One key with its modifiers, stored as Swift stores it.
struct ShortcutChord {
// One character; Backspace is 0x7f, the arrows Cocoa's F700s.
QString key;
// Bits: Ctrl 1, Alt 2, Meta 4, Shift 8.
int modifiers = 0;
ShortcutChord() = default;
ShortcutChord(QString key, int modifiers = 0) : key(std::move(key)), modifiers(modifiers) {}
explicit ShortcutChord(const QKeyEvent &event);
explicit ShortcutChord(QKeyCombination combination);
QKeyCombination combination() const;
QString label() const;
// The same press, as though this chord were typed.
std::unique_ptr<QKeyEvent> event(const QKeyEvent &like) const;
friend bool operator==(const ShortcutChord &, const ShortcutChord &) = default;
};
struct ShortcutDefinition {
QString title;
QString group;
ShortcutChord original;
QString id() const { return group + QLatin1Char(':') + title; }
bool isMenu() const { return group == QLatin1String("Menus"); }
static const std::vector<ShortcutDefinition> &all();
};
// Swift's ShortcutSettings: overrides, kept in QSettings.
class ShortcutSettings : public QObject {
Q_OBJECT
public:
static ShortcutSettings &shared();
static constexpr const char *storageKey = "keyboardShortcuts.v1";
const QHash<QString, ShortcutChord> &overrides() const { return m_overrides; }
ShortcutChord chord(const ShortcutDefinition &definition) const;
// A menu entry's key, or a sheet's, as remapped.
QKeySequence menu(const QKeySequence &original) const;
ShortcutChord native(const ShortcutChord &original) const;
// Saves valid overrides; answers whether it did.
bool save(const QHash<QString, ShortcutChord> &values);
static std::optional<QString> problem(const QHash<QString, ShortcutChord> &values);
// Null swallows the press; else the press to handle.
std::unique_ptr<QKeyEvent> canvasEvent(const QKeyEvent &event) const;
std::unique_ptr<QKeyEvent> textEvent(const QKeyEvent &event) const;
// Reads the stored overrides again, as at launch.
void reload();
signals:
void changed();
private:
ShortcutSettings();
QHash<QString, ShortcutChord> m_overrides;
};
// Swift's configuredNativeShortcut on a sheet's button.
class NativeShortcut : public QObject {
Q_OBJECT
public:
NativeShortcut(const ShortcutChord &original, QWidget &scope, QAbstractButton &target);
// A sheet's default (Return) and cancel (Escape) buttons.
static void bind(QWidget &scope, QAbstractButton *apply, QAbstractButton *cancel);
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void synchronize();
const ShortcutChord m_original;
QWidget &m_scope;
QPointer<QAbstractButton> m_target;
QShortcut *const m_shortcut;
};
// A shortcut's button: click, then press the new keys.
class ShortcutRecorder : public QPushButton {
Q_OBJECT
public:
ShortcutRecorder(std::function<void()> start, std::function<void(ShortcutChord)> finish, QWidget *parent);
void display(const ShortcutChord &chord, bool recording);
protected:
bool event(QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
const std::function<void()> m_start;
const std::function<void(ShortcutChord)> m_finish;
bool m_recording = false;
};
// Swift's KeyboardShortcutsSheet, in the "keyboardShortcuts" panel.
class KeyboardShortcutsSheet : public QWidget {
Q_OBJECT
public:
KeyboardShortcutsSheet(std::function<void()> close, QWidget *parent = nullptr);
private:
void synchronize();
const std::function<void()> m_close;
QHash<QString, ShortcutChord> m_draft;
std::optional<QString> m_recording;
QLineEdit *const m_search;
QLabel *const m_problem;
QPushButton *const m_save;
struct Row {
QWidget *row;
ShortcutRecorder *recorder;
const ShortcutDefinition *definition;
};
std::vector<Row> m_rows;
};
+3
View File
@@ -1,4 +1,5 @@
#include "UI/LassoControls.h"
#include "UI/KeyboardShortcuts.h"
#include <QBoxLayout>
#include <QFrame>
#include <QLabel>
@@ -277,6 +278,8 @@ SelectionAmountSheet::SelectionAmountSheet(EditorSession &session, SelectionAmou
cancel->setAutoDefault(false);
m_ok->setObjectName(QStringLiteral("amountOK"));
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, cancel);
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
+4 -1
View File
@@ -1,4 +1,5 @@
#include "UI/LevelsSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "Rendering/EyedropperIcon.h"
#include <QEvent>
#include <QFrame>
@@ -291,7 +292,7 @@ LevelsSheet::LevelsSheet(EditorSession &session, QWidget *parent)
connect(m_autos[index], &QPushButton::clicked, this, [this, index] { m_session.autoLevels(allLevelsAutos[index]); });
}
m_sampleHint->setWordWrap(true);
m_preview->setShortcut(QKeySequence(Qt::ALT | Qt::Key_P));
new NativeShortcut(ShortcutChord(QStringLiteral("p"), 2), *this, *m_preview);
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateLevels(m_session.levels().value().settings, on); });
m_reset->setAutoDefault(false);
connect(m_reset, &QPushButton::clicked, this, [this] {
@@ -300,6 +301,8 @@ LevelsSheet::LevelsSheet(EditorSession &session, QWidget *parent)
});
m_cancel->setAutoDefault(false);
m_ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_ok, m_cancel);
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelLevels(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitLevels(); });
m_spinner->setRange(0, 0);
+11
View File
@@ -1,4 +1,5 @@
#include "UI/NativeLayerList.h"
#include "UI/KeyboardShortcuts.h"
#include <QEvent>
#include <QKeyEvent>
#include <QScrollBar>
@@ -168,6 +169,16 @@ bool NativeLayerList::event(QEvent *event)
}
void NativeLayerList::keyPressEvent(QKeyEvent *event)
{
// Remapped keys arrive as the keys they stand for.
const std::unique_ptr<QKeyEvent> typed = ShortcutSettings::shared().canvasEvent(*event);
if (!typed)
return;
pressKey(typed.get());
event->setAccepted(typed->isAccepted());
}
void NativeLayerList::pressKey(QKeyEvent *event)
{
const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier));
const int key = event->key();
+1
View File
@@ -238,6 +238,7 @@ public:
protected:
bool event(QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void pressKey(QKeyEvent *event);
// Below every row: Swift's table lets go of everything.
void mousePressEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
+12 -1
View File
@@ -1,4 +1,5 @@
#include "UI/NewCanvasSheet.h"
#include "UI/KeyboardShortcuts.h"
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
@@ -32,6 +33,8 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
m_note->setObjectName(QStringLiteral("canvasNote"));
m_create->setObjectName(QStringLiteral("createCanvas"));
m_create->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_create, nullptr);
auto *open = new QPushButton(QStringLiteral("Open project"), this);
open->setObjectName(QStringLiteral("openProject"));
auto *import = new QPushButton(QStringLiteral("Import image"), this);
@@ -70,8 +73,16 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
// Return anywhere on the sheet is the default action.
std::vector<QShortcut *> returns;
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
new QShortcut(QKeySequence(key), this, this, &NewCanvasSheet::create, Qt::WidgetWithChildrenShortcut);
returns.push_back(new QShortcut(QKeySequence(key), this, this, &NewCanvasSheet::create, Qt::WidgetWithChildrenShortcut));
// Until Apply is moved elsewhere, as Swift's native shortcut.
const auto follow = [returns] {
for (QShortcut *shortcut : returns)
shortcut->setEnabled(ShortcutSettings::shared().native(ShortcutChord(QStringLiteral("\r"))) == ShortcutChord(QStringLiteral("\r")));
};
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, follow);
follow();
connect(m_create, &QPushButton::clicked, this, &NewCanvasSheet::create);
connect(open, &QPushButton::clicked, this, [onOpen = std::move(onOpen)] { onOpen(); });
connect(import, &QPushButton::clicked, this, [this] { m_session.setShowsImporter(true); });
+3
View File
@@ -1,4 +1,5 @@
#include "UI/TransformInspector.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/LayerIcons.h"
#include <QEvent>
#include <QKeyEvent>
@@ -147,6 +148,8 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
m_cancel->setObjectName(QStringLiteral("cancelTransform"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelTransform(); });
m_apply->setObjectName(QStringLiteral("applyTransform"));
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_apply, m_cancel);
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitTransform(); });
// Before the stretch ending the row, in Swift's order.
for (QWidget *widget : std::initializer_list<QWidget *>{m_autoSelect, m_showControls, m_fields, m_cancel, m_apply})
+2 -2
View File
@@ -31,7 +31,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"},
{"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"},
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""},
@@ -50,7 +50,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
titles << menu->title();
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
QCOMPARE(menus[0]->actions().size(), 11);
QCOMPARE(menus[1]->actions().size(), 12);
QCOMPARE(menus[1]->actions().size(), 13);
QCOMPARE(menus[2]->actions().size(), 7);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[4]->actions().size(), 13);
+186
View File
@@ -0,0 +1,186 @@
#include "MenuFixtures.h"
#include "UI/KeyboardShortcuts.h"
#include <QApplication>
#include <QLabel>
#include <QSettings>
#include <QStandardPaths>
// The Keyboard Shortcuts window and the menus it remaps.
namespace {
QWidget *shownPanel()
{
for (QWidget *widget : QApplication::topLevelWidgets()) {
if (widget->objectName() == QStringLiteral("keyboardShortcuts") && widget->isVisible())
return widget;
}
return nullptr;
}
template <typename Widget> Widget &find(QWidget &root, const QString &name)
{
Widget *found = root.findChild<Widget *>(name);
if (!found)
throw std::runtime_error("no widget named " + name.toStdString());
return *found;
}
QWidget &opened(Bar &bar)
{
// A shown window's menus hold live shortcuts.
bar.window.show();
if (!QTest::qWaitForWindowExposed(&bar.window))
throw std::runtime_error("the window never showed");
bar.action("keyboardShortcuts").trigger();
QWidget *panel = nullptr;
if (!QTest::qWaitFor([&] { return (panel = shownPanel()) != nullptr; }))
throw std::runtime_error("the panel never showed");
return *panel;
}
}
class KeyboardShortcutsSheetTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void cleanup();
void aRecordedKeyRemapsItsMenuEntryOnSave();
void recordingWaitsForARealKey();
void aClashRestsSaveAndDefaultsComeBack();
void cancelKeepsWhatWasSaved();
};
void KeyboardShortcutsSheetTests::initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
QSettings().remove(QLatin1String(ShortcutSettings::storageKey));
ShortcutSettings::shared().reload();
}
void KeyboardShortcutsSheetTests::cleanup()
{
QSettings().remove(QLatin1String(ShortcutSettings::storageKey));
ShortcutSettings::shared().reload();
if (QWidget *panel = shownPanel())
panel->hide();
}
void KeyboardShortcutsSheetTests::aRecordedKeyRemapsItsMenuEntryOnSave()
{
Bar bar;
QCOMPARE(bar.action("keyboardShortcuts").text(), QString("Keyboard Shortcuts…"));
QWidget &panel = opened(bar);
QCOMPARE(panel.windowTitle(), QString("Keyboard Shortcuts"));
auto &sheet = find<QWidget>(panel, "keyboardShortcutsSheet");
QCOMPARE(sheet.width(), 660);
auto &undo = find<ShortcutRecorder>(sheet, "recorder:Menus:Undo");
QCOMPARE(undo.text(), QString("Ctrl+Z"));
QCOMPARE(undo.size(), QSize(150, 26));
QTRY_COMPARE(QApplication::activeWindow(), &panel);
undo.click();
QVERIFY(undo.text() == "Press keys…" && undo.accessibleName() == "Press a shortcut");
QTRY_VERIFY(undo.hasFocus());
// Save rests while a key is being recorded.
QVERIFY(!find<QPushButton>(sheet, "saveShortcuts").isEnabled());
QTest::keyClick(&undo, Qt::Key_K, Qt::ControlModifier | Qt::AltModifier);
QCOMPARE(undo.text(), QString("Ctrl+Alt+K"));
// Recording done, the button lets go of the keys.
QVERIFY(!undo.hasFocus());
// Nothing applies until Save.
QCOMPARE(bar.action("undo").shortcut(), QKeySequence(Qt::CTRL | Qt::Key_Z));
find<QPushButton>(sheet, "saveShortcuts").click();
QTRY_VERIFY(!shownPanel());
QCOMPARE(bar.action("undo").shortcut(), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_K));
QCOMPARE(ShortcutSettings::shared().overrides().value("Menus:Undo"), ShortcutChord("k", 3));
// An entry Swift does not list keeps its key.
QCOMPARE(bar.action("fit").shortcut(), QKeySequence(Qt::CTRL | Qt::Key_0));
// A new window reads the saved keys too.
Bar again;
QCOMPARE(again.action("undo").shortcut(), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_K));
ShortcutSettings::shared().save({});
QCOMPARE(bar.action("undo").shortcut(), QKeySequence(Qt::CTRL | Qt::Key_Z));
}
void KeyboardShortcutsSheetTests::recordingWaitsForARealKey()
{
Bar bar;
bar.session().createDocument(20, 20);
QWidget &panel = opened(bar);
auto &brush = find<ShortcutRecorder>(panel, "recorder:Canvas & Layers:Brush tool");
QCOMPARE(brush.text(), QString("B"));
brush.click();
// A modifier alone waits; a long key name is refused.
QTest::keyClick(&brush, Qt::Key_Control, Qt::ControlModifier);
QTest::keyClick(&brush, Qt::Key_F1);
QCOMPARE(brush.text(), QString("Press keys…"));
// A menu's own key is recorded, not run.
const size_t layers = bar.session().document().value().layers.size();
QVERIFY(bar.action("newBlankLayer").isEnabled());
QTest::keyClick(&brush, Qt::Key_N, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(bar.session().document().value().layers.size(), layers);
QCOMPARE(brush.text(), QString("Ctrl+Shift+N"));
// The clash with New Blank Layer rests Save.
QVERIFY(!find<QPushButton>(panel, "saveShortcuts").isEnabled());
QVERIFY(find<QLabel>(panel, "shortcutProblem").isVisible());
// Tab is recorded too, never moving the focus.
brush.click();
QTRY_VERIFY(brush.hasFocus());
QTest::keyClick(&brush, Qt::Key_Tab);
QCOMPARE(brush.text(), QString("Tab"));
}
void KeyboardShortcutsSheetTests::aClashRestsSaveAndDefaultsComeBack()
{
Bar bar;
QWidget &panel = opened(bar);
auto &brush = find<ShortcutRecorder>(panel, "recorder:Canvas & Layers:Brush tool");
auto &problem = find<QLabel>(panel, "shortcutProblem");
auto &save = find<QPushButton>(panel, "saveShortcuts");
QVERIFY(!problem.isVisible() && save.isEnabled() && save.isDefault());
brush.click();
QTest::keyClick(&brush, Qt::Key_V);
QCOMPARE(problem.text(), QString("V is assigned to both Move / Transform tool and Brush tool."));
QVERIFY(problem.isVisible() && !save.isEnabled());
QCOMPARE(problem.font().pixelSize(), 12);
// Search hides rows by title, whatever the case.
auto &search = find<QLineEdit>(panel, "shortcutSearch");
QCOMPARE(search.placeholderText(), QString("Search shortcuts"));
search.setText("BRUSH");
QVERIFY(brush.parentWidget()->isVisible());
QVERIFY(!find<ShortcutRecorder>(panel, "recorder:Menus:Undo").parentWidget()->isVisible());
search.clear();
QVERIFY(find<ShortcutRecorder>(panel, "recorder:Menus:Undo").parentWidget()->isVisible());
// Restore Defaults also ends a recording.
brush.click();
QCOMPARE(brush.text(), QString("Press keys…"));
find<QPushButton>(panel, "restoreShortcuts").click();
QVERIFY(!problem.isVisible() && save.isEnabled() && brush.text() == "B");
// Return saves, Save being the default.
QTRY_COMPARE(QApplication::activeWindow(), &panel);
QTest::keyClick(&search, Qt::Key_Return);
QTRY_VERIFY(!shownPanel());
QVERIFY(ShortcutSettings::shared().overrides().isEmpty());
}
void KeyboardShortcutsSheetTests::cancelKeepsWhatWasSaved()
{
Bar bar;
ShortcutSettings::shared().save({{"Canvas & Layers:Brush tool", ShortcutChord("k")}});
QWidget &panel = opened(bar);
auto &brush = find<ShortcutRecorder>(panel, "recorder:Canvas & Layers:Brush tool");
// The sheet starts from what is saved.
QCOMPARE(brush.text(), QString("K"));
brush.click();
QTest::keyClick(&brush, Qt::Key_Y);
find<QPushButton>(panel, "cancelShortcuts").click();
QTRY_VERIFY(!shownPanel());
QCOMPARE(ShortcutSettings::shared().overrides().value("Canvas & Layers:Brush tool"), ShortcutChord("k"));
// Escape closes it as Cancel does.
QWidget &again = opened(bar);
QTRY_COMPARE(QApplication::activeWindow(), &again);
QTest::keyClick(QApplication::focusWidget() ? QApplication::focusWidget() : &again, Qt::Key_Escape);
QTRY_VERIFY(!shownPanel());
QCOMPARE(ShortcutSettings::shared().overrides().size(), 1);
}
QTEST_MAIN(KeyboardShortcutsSheetTests)
#include "KeyboardShortcutsSheetTests.moc"
+223
View File
@@ -0,0 +1,223 @@
#include "UI/KeyboardShortcuts.h"
#include <QKeyEvent>
#include <QSet>
#include <QSettings>
#include <QSignalSpy>
#include <QStandardPaths>
#include <QtTest>
// Swift's shortcut model: chords, definitions, rules, rewriting.
namespace {
QKeyEvent press(Qt::Key key, Qt::KeyboardModifiers modifiers = Qt::NoModifier, const QString &text = QString())
{
return QKeyEvent(QEvent::KeyPress, key, modifiers, text);
}
const ShortcutDefinition &named(const QString &title)
{
for (const ShortcutDefinition &definition : ShortcutDefinition::all()) {
if (definition.title == title)
return definition;
}
throw std::runtime_error("no shortcut named " + title.toStdString());
}
void clear()
{
QSettings().remove(QLatin1String(ShortcutSettings::storageKey));
ShortcutSettings::shared().reload();
}
}
class KeyboardShortcutsTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void cleanup();
void chordsReadKeysAsSwiftDoes();
void theListIsSwiftsWithoutHide();
void problemsNameWhatIsWrong();
void savedOverridesPersistAndBadOnesAreIgnored();
void menusAndSheetsTakeTheirRemappedKeys();
void theCanvasTranslatesRemappedKeys();
void openTextTranslatesItsOwnKeys();
};
void KeyboardShortcutsTests::initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
clear();
}
void KeyboardShortcutsTests::cleanup()
{
clear();
}
void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes()
{
QCOMPARE(ShortcutChord(press(Qt::Key_B)), ShortcutChord("b"));
QCOMPARE(ShortcutChord(press(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier)), ShortcutChord("z", 9));
QCOMPARE(ShortcutChord(press(Qt::Key_H, Qt::AltModifier | Qt::MetaModifier)), ShortcutChord("h", 6));
// Shifted brackets and signs are their own keys.
QCOMPARE(ShortcutChord(press(Qt::Key_BraceLeft, Qt::ShiftModifier)), ShortcutChord("[", 8));
QCOMPARE(ShortcutChord(press(Qt::Key_BraceRight, Qt::ShiftModifier)), ShortcutChord("]", 8));
QCOMPARE(ShortcutChord(press(Qt::Key_Plus, Qt::ShiftModifier)), ShortcutChord("=", 8));
QCOMPARE(ShortcutChord(press(Qt::Key_Underscore, Qt::ShiftModifier)), ShortcutChord("-", 8));
// Both deletes, returns and tabs are one key each.
QCOMPARE(ShortcutChord(press(Qt::Key_Backspace)), ShortcutChord("\x7f"));
QCOMPARE(ShortcutChord(press(Qt::Key_Delete)), ShortcutChord("\x7f"));
QCOMPARE(ShortcutChord(press(Qt::Key_Enter)), ShortcutChord("\r"));
QCOMPARE(ShortcutChord(press(Qt::Key_Backtab, Qt::ShiftModifier)), ShortcutChord("\t", 8));
QCOMPARE(ShortcutChord(press(Qt::Key_Escape)), ShortcutChord("\x1b"));
QCOMPARE(ShortcutChord(press(Qt::Key_Left)), ShortcutChord(QString(QChar(0xf702))));
QCOMPARE(ShortcutChord(press(Qt::Key_Down)).key, QString(QChar(0xf701)));
QCOMPARE(ShortcutChord(press(Qt::Key_F1)).key.size(), 2);
// Labels and key combinations, as the menus show them.
QCOMPARE(ShortcutChord("s", 11).label(), QString("Ctrl+Alt+Shift+S"));
QCOMPARE(ShortcutChord("\x7f", 2).label(), QString("Alt+Backspace"));
QCOMPARE(ShortcutChord("[", 8).combination(), QKeyCombination(Qt::ShiftModifier, Qt::Key_BraceLeft));
QCOMPARE(ShortcutChord(QString(QChar(0xf700)), 1).combination(), QKeyCombination(Qt::ControlModifier, Qt::Key_Up));
QCOMPARE(ShortcutChord(QKeyCombination(Qt::ControlModifier, Qt::Key_Equal)), ShortcutChord("=", 1));
// A chord typed again carries its key, text and repeat.
const QKeyEvent like(QEvent::KeyPress, Qt::Key_K, Qt::NoModifier, QStringLiteral("k"), true);
const std::unique_ptr<QKeyEvent> typed = ShortcutChord("b").event(like);
QVERIFY(typed->key() == Qt::Key_B && typed->modifiers() == Qt::NoModifier && typed->text() == "b" && typed->isAutoRepeat());
const std::unique_ptr<QKeyEvent> space = ShortcutChord(" ").event(like);
QVERIFY(space->key() == Qt::Key_Space && space->text().isEmpty());
const std::unique_ptr<QKeyEvent> hard = ShortcutChord("]", 8).event(like);
QVERIFY(hard->key() == Qt::Key_BraceRight && hard->modifiers() == Qt::ShiftModifier && hard->text() == "}");
}
void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
{
const std::vector<ShortcutDefinition> &all = ShortcutDefinition::all();
// Swift's 107, less Hide Compositor.
QCOMPARE(int(all.size()), 106);
QSet<QString> ids;
for (const ShortcutDefinition &definition : all)
ids.insert(definition.id());
QCOMPARE(int(ids.size()), int(all.size()));
QVERIFY(!ids.contains("Menus:Hide Compositor"));
QCOMPARE(named("Export JPEG").original, ShortcutChord("s", 11));
QVERIFY(named("Export JPEG").isMenu());
QCOMPARE(named("Content-Aware Fill").original, ShortcutChord("\x7f", 8));
QCOMPARE(named("Temporary Hand tool (hold)").original, ShortcutChord(" "));
QCOMPARE(named("Opacity digit 7 (type two for exact %)").original, ShortcutChord("7"));
QCOMPARE(named("Move selected pixels Down 10 px").original, ShortcutChord(QString(QChar(0xf701)), 9));
QCOMPARE(named("Increase leading by 10").group, QString("Text Editing"));
QCOMPARE(named("Increase leading by 10").original, ShortcutChord(QString(QChar(0xf701)), 10));
QCOMPARE(named("Toggle Levels preview").group, QString("Canvas & Layers"));
QCOMPARE(named("Undo").id(), QString("Menus:Undo"));
// Swift's defaults hold together.
QVERIFY(!ShortcutSettings::problem({}));
}
void KeyboardShortcutsTests::problemsNameWhatIsWrong()
{
const QString brush = named("Brush tool").id(), finish = named("Finish editing text").id();
QCOMPARE(ShortcutSettings::problem({{brush, ShortcutChord("f1")}}).value(), QString("Choose a single key with optional modifiers."));
QCOMPARE(ShortcutSettings::problem({{brush, ShortcutChord("k", 16)}}).value(), QString("Choose a single key with optional modifiers."));
QCOMPARE(ShortcutSettings::problem({{brush, ShortcutChord("k", -1)}}).value(), QString("Choose a single key with optional modifiers."));
QCOMPARE(ShortcutSettings::problem({{finish, ShortcutChord("\r", 8)}}).value(),
QString("Text-editing shortcuts need Ctrl, Alt or Meta so they do not replace normal typing."));
QVERIFY(!ShortcutSettings::problem({{finish, ShortcutChord("\r", 4)}}));
QCOMPARE(ShortcutSettings::problem({{brush, ShortcutChord("v")}}).value(), QString("V is assigned to both Move / Transform tool and Brush tool."));
// Moving both apart is fine.
QVERIFY(!ShortcutSettings::problem({{brush, ShortcutChord("v")}, {named("Move / Transform tool").id(), ShortcutChord("b")}}));
}
void KeyboardShortcutsTests::savedOverridesPersistAndBadOnesAreIgnored()
{
ShortcutSettings &settings = ShortcutSettings::shared();
QSignalSpy changes(&settings, &ShortcutSettings::changed);
const QString brush = named("Brush tool").id();
QVERIFY(!settings.save({{brush, ShortcutChord("v")}}));
QVERIFY(settings.overrides().isEmpty() && changes.isEmpty());
QVERIFY(settings.save({{brush, ShortcutChord("k")}}));
QCOMPARE(changes.count(), 1);
QCOMPARE(settings.chord(named("Brush tool")), ShortcutChord("k"));
QCOMPARE(settings.chord(named("Eraser")), ShortcutChord("e"));
// Read again as at launch, from QSettings.
settings.reload();
QCOMPARE(settings.overrides(), (QHash<QString, ShortcutChord>{{brush, ShortcutChord("k")}}));
QCOMPARE(QSettings().value(QLatin1String(ShortcutSettings::storageKey)).toByteArray(),
QByteArray(R"({"Canvas & Layers:Brush tool":{"key":"k","modifiers":0}})"));
// Stored overrides that clash are dropped with a warning.
QSettings().setValue(QLatin1String(ShortcutSettings::storageKey), QByteArray(R"({"Canvas & Layers:Brush tool":{"key":"v","modifiers":0}})"));
QTest::ignoreMessage(QtWarningMsg, "stored keyboard shortcuts ignored: V is assigned to both Move / Transform tool and Brush tool.");
settings.reload();
QVERIFY(settings.overrides().isEmpty());
}
void KeyboardShortcutsTests::menusAndSheetsTakeTheirRemappedKeys()
{
ShortcutSettings &settings = ShortcutSettings::shared();
const QKeySequence undo(Qt::CTRL | Qt::Key_Z);
QCOMPARE(settings.menu(undo), undo);
QVERIFY(settings.save({{named("Undo").id(), ShortcutChord("u", 3)}, {named("Hue/Saturation").id(), ShortcutChord("h", 3)},
{named("Apply current canvas operation").id(), ShortcutChord("k")}}));
QCOMPARE(settings.menu(undo), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_U));
// An unlisted entry keeps its key; none stays none.
QCOMPARE(settings.menu(QKeySequence(Qt::CTRL | Qt::Key_L)), QKeySequence(Qt::CTRL | Qt::Key_L));
QCOMPARE(settings.menu(QKeySequence(Qt::CTRL | Qt::Key_H)), QKeySequence(Qt::CTRL | Qt::Key_H));
QCOMPARE(settings.menu(QKeySequence()), QKeySequence());
// Sheets read the canvas's Apply and Cancel.
QCOMPARE(settings.native(ShortcutChord("\r")), ShortcutChord("k"));
QCOMPARE(settings.native(ShortcutChord("\x1b")), ShortcutChord("\x1b"));
QCOMPARE(settings.native(ShortcutChord("y", 1)), ShortcutChord("y", 1));
// A menu's chord is no sheet's.
QCOMPARE(settings.native(ShortcutChord("z", 1)), ShortcutChord("z", 1));
}
void KeyboardShortcutsTests::theCanvasTranslatesRemappedKeys()
{
ShortcutSettings &settings = ShortcutSettings::shared();
// Without overrides every key passes as it is.
std::unique_ptr<QKeyEvent> same = settings.canvasEvent(press(Qt::Key_B, Qt::NoModifier, "b"));
QVERIFY(same && same->key() == Qt::Key_B);
QVERIFY(settings.save({{named("Brush tool").id(), ShortcutChord("k")}, {named("Undo").id(), ShortcutChord("u", 3)},
{named("Cycle shape kind").id(), ShortcutChord("y", 8)}}));
// The new key stands for the old; the old goes.
const std::unique_ptr<QKeyEvent> brush = settings.canvasEvent(press(Qt::Key_K, Qt::NoModifier, "k"));
QVERIFY(brush && brush->key() == Qt::Key_B && brush->modifiers() == Qt::NoModifier);
QVERIFY(!settings.canvasEvent(press(Qt::Key_B, Qt::NoModifier, "b")));
// A menu's old chord is swallowed at the canvas too.
QVERIFY(!settings.canvasEvent(press(Qt::Key_Z, Qt::ControlModifier)));
// Shift follows a letter home, unless Shift has its own.
const std::unique_ptr<QKeyEvent> shifted = settings.canvasEvent(press(Qt::Key_K, Qt::ShiftModifier, "K"));
QVERIFY(shifted && shifted->key() == Qt::Key_B && shifted->modifiers() == Qt::ShiftModifier);
QVERIFY(!settings.canvasEvent(press(Qt::Key_B, Qt::ShiftModifier, "B")));
const std::unique_ptr<QKeyEvent> kind = settings.canvasEvent(press(Qt::Key_Y, Qt::ShiftModifier, "Y"));
QVERIFY(kind && kind->key() == Qt::Key_U && kind->modifiers() == Qt::ShiftModifier);
QVERIFY(!settings.canvasEvent(press(Qt::Key_U, Qt::ShiftModifier, "U")));
// Keys nobody moved pass untouched.
const std::unique_ptr<QKeyEvent> eraser = settings.canvasEvent(press(Qt::Key_E, Qt::ShiftModifier, "E"));
QVERIFY(eraser && eraser->key() == Qt::Key_E && eraser->modifiers() == Qt::ShiftModifier);
const std::unique_ptr<QKeyEvent> other = settings.canvasEvent(press(Qt::Key_Q, Qt::NoModifier, "q"));
QVERIFY(other && other->key() == Qt::Key_Q);
}
void KeyboardShortcutsTests::openTextTranslatesItsOwnKeys()
{
ShortcutSettings &settings = ShortcutSettings::shared();
QVERIFY(settings.save({{named("Finish editing text").id(), ShortcutChord("\r", 4)}, {named("Brush tool").id(), ShortcutChord("k")},
{named("Cancel current canvas operation").id(), ShortcutChord("q", 1)}}));
const std::unique_ptr<QKeyEvent> finish = settings.textEvent(press(Qt::Key_Return, Qt::MetaModifier));
QVERIFY(finish && finish->key() == Qt::Key_Return && finish->modifiers() == Qt::ControlModifier);
QVERIFY(!settings.textEvent(press(Qt::Key_Return, Qt::ControlModifier)));
// Escape follows Cancel; tool letters stay letters.
const std::unique_ptr<QKeyEvent> cancel = settings.textEvent(press(Qt::Key_Q, Qt::ControlModifier));
QVERIFY(cancel && cancel->key() == Qt::Key_Escape && cancel->modifiers() == Qt::NoModifier);
QVERIFY(!settings.textEvent(press(Qt::Key_Escape)));
const std::unique_ptr<QKeyEvent> letter = settings.textEvent(press(Qt::Key_K, Qt::NoModifier, "k"));
QVERIFY(letter && letter->key() == Qt::Key_K && letter->text() == "k");
const std::unique_ptr<QKeyEvent> b = settings.textEvent(press(Qt::Key_B, Qt::NoModifier, "b"));
QVERIFY(b && b->key() == Qt::Key_B);
// The canvas leaves a text chord's old key alone.
const std::unique_ptr<QKeyEvent> canvas = settings.canvasEvent(press(Qt::Key_Return, Qt::ControlModifier));
QVERIFY(canvas && canvas->key() == Qt::Key_Return && canvas->modifiers() == Qt::ControlModifier);
}
QTEST_MAIN(KeyboardShortcutsTests)
#include "KeyboardShortcutsTests.moc"
+272
View File
@@ -0,0 +1,272 @@
#include "InlineTextFixtures.h"
#include "UI/CanvasSizeSheet.h"
#include "UI/ColorPickerSheet.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
#include "UI/HueSaturationSheet.h"
#include "UI/ImageSizeSheet.h"
#include "UI/JPEGExportSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/LevelsSheet.h"
#include "UI/NewCanvasSheet.h"
#include "UI/TransformInspector.h"
#include <QShortcut>
#include "UI/LassoControls.h"
#include "UI/NativeLayerList.h"
#include <QDialog>
#include <QSettings>
#include <QStandardPaths>
#include <QVBoxLayout>
// Remapped keys on canvas, list, sheets and open text.
namespace {
void remap(const QHash<QString, ShortcutChord> &values)
{
if (!ShortcutSettings::shared().save(values))
throw std::runtime_error("the overrides were refused");
}
}
class RemappedKeysTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void cleanup();
void theCanvasReadsTheNewKeysAndDropsTheOld();
void aRemappedSpacePansUntilItsOwnKeyIsLetGo();
void theListAndTheWindowReadTheNewKeys();
void aSheetsButtonsFollowApplyAndCancel();
void openTextReadsItsOwnNewKeys();
void everySheetBindsSwiftsKeys();
void aKeyLeftAloneReachesTheParent();
};
namespace {
// Counts the keys its children leave to it.
struct Parent : QWidget {
int keys = 0;
void keyPressEvent(QKeyEvent *event) override
{
keys += 1;
event->accept();
}
};
}
void RemappedKeysTests::initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
}
void RemappedKeysTests::cleanup()
{
QSettings().remove(QLatin1String(ShortcutSettings::storageKey));
ShortcutSettings::shared().reload();
}
void RemappedKeysTests::theCanvasReadsTheNewKeysAndDropsTheOld()
{
Canvas shown;
remap({{"Canvas & Layers:Brush tool", ShortcutChord("k")}, {"Canvas & Layers:Increase brush size", ShortcutChord("o")}});
QTRY_VERIFY(shown.canvas->hasFocus());
QTest::keyClick(shown.canvas, Qt::Key_B);
QCOMPARE(shown.session.tool(), NavigationTool::marquee);
QTest::keyClick(shown.canvas, Qt::Key_K);
QCOMPARE(shown.session.tool(), NavigationTool::brush);
const double size = shown.session.brushSettings().diameter;
QTest::keyClick(shown.canvas, Qt::Key_O);
QVERIFY(shown.session.brushSettings().diameter > size);
const double larger = shown.session.brushSettings().diameter;
QTest::keyClick(shown.canvas, Qt::Key_BracketRight);
QCOMPARE(shown.session.brushSettings().diameter, larger);
// A remapped hardness key steps hardness, not the size.
remap({{"Canvas & Layers:Brush tool", ShortcutChord("k")}, {"Canvas & Layers:Decrease brush hardness", ShortcutChord("o", 8)}});
const double hardness = shown.session.brushSettings().hardness;
QTest::keyClick(shown.canvas, Qt::Key_O, Qt::ShiftModifier);
QVERIFY(shown.session.brushSettings().hardness < hardness);
QCOMPARE(shown.session.brushSettings().diameter, larger);
}
void RemappedKeysTests::aRemappedSpacePansUntilItsOwnKeyIsLetGo()
{
Canvas shown;
remap({{"Canvas & Layers:Temporary Hand tool (hold)", ShortcutChord("q")}});
QTRY_VERIFY(shown.canvas->hasFocus());
const Qt::CursorShape resting = shown.canvas->cursor().shape();
QTest::keyPress(shown.canvas, Qt::Key_Q);
QCOMPARE(shown.canvas->cursor().shape(), Qt::OpenHandCursor);
// Space itself is swallowed; Q's release ends the pan.
QTest::keyRelease(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.canvas->cursor().shape(), Qt::OpenHandCursor);
QTest::keyRelease(shown.canvas, Qt::Key_Q);
QCOMPARE(shown.canvas->cursor().shape(), resting);
QTest::keyPress(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.canvas->cursor().shape(), resting);
// Unmapped again, Space pans and ends as before.
cleanup();
QTest::keyPress(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.canvas->cursor().shape(), Qt::OpenHandCursor);
QTest::keyRelease(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.canvas->cursor().shape(), resting);
}
void RemappedKeysTests::theListAndTheWindowReadTheNewKeys()
{
EditorSession session;
session.createDocument(100, 100, true);
QWidget window;
auto *layout = new QVBoxLayout(&window);
auto *list = new NativeLayerList(session, &window);
auto *field = new QLineEdit(&window);
layout->addWidget(list);
layout->addWidget(field);
CanvasView canvas(session, &window);
window.resize(300, 500);
window.show();
QVERIFY(QTest::qWaitForWindowActive(&window));
remap({{"Canvas & Layers:Crop tool", ShortcutChord("k")}, {"Canvas & Layers:Next blend mode", ShortcutChord("n", 8)}});
list->setFocus();
QTRY_VERIFY(list->hasFocus());
QTest::keyClick(list, Qt::Key_C);
QVERIFY(session.tool() != NavigationTool::crop);
QTest::keyClick(list, Qt::Key_K);
QCOMPARE(session.tool(), NavigationTool::crop);
// The window blend keys read the new chord anywhere.
session.selectTool(NavigationTool::move);
session.insert(ImportedImage(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Pixels"));
QTest::keyClick(list, Qt::Key_Plus, Qt::ShiftModifier);
QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal);
QTest::keyClick(list, Qt::Key_N, Qt::ShiftModifier);
QCOMPARE(session.activeLayer().value().blendMode, allLayerBlendModes[1]);
}
void RemappedKeysTests::aSheetsButtonsFollowApplyAndCancel()
{
const auto session = selectionSession();
lasso(*session, square(20, 20, 40));
session->promptSelectionAmount(SelectionAmountOperation::feather);
remap({{"Canvas & Layers:Apply current canvas operation", ShortcutChord("k", 1)}});
QDialog dialog;
auto *sheet = new SelectionAmountSheet(*session, SelectionAmountOperation::feather, &dialog);
(new QVBoxLayout(&dialog))->addWidget(sheet);
dialog.show();
QVERIFY(QTest::qWaitForWindowActive(&dialog));
auto &field = *sheet->findChild<QLineEdit *>("amountField");
QTRY_VERIFY(field.hasFocus());
// Return no longer takes OK; the new chord does.
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(session->selectionAmountOperation().has_value());
// A resting OK takes no key either.
field.setText(QStringLiteral("0"));
QTest::keyClick(&field, Qt::Key_K, Qt::ControlModifier);
QVERIFY(session->selectionAmountOperation().has_value());
field.setText(QStringLiteral("2"));
auto &ok = *sheet->findChild<QPushButton *>("amountOK");
ok.hide();
QTest::keyClick(&field, Qt::Key_K, Qt::ControlModifier);
QVERIFY(session->selectionAmountOperation().has_value());
ok.show();
QTest::keyClick(&field, Qt::Key_K, Qt::ControlModifier);
QVERIFY(!session->selectionAmountOperation());
QCOMPARE(session->selection().value().feather, 2.0);
// Back to Return, the default button takes it again.
cleanup();
session->promptSelectionAmount(SelectionAmountOperation::feather);
QTest::keyClick(&field, Qt::Key_K, Qt::ControlModifier);
QVERIFY(session->selectionAmountOperation().has_value());
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(!session->selectionAmountOperation());
// Cancel moved onto Return takes it; Apply is elsewhere.
remap({{"Canvas & Layers:Apply current canvas operation", ShortcutChord("k", 1)},
{"Canvas & Layers:Cancel current canvas operation", ShortcutChord("\r")}});
session->promptSelectionAmount(SelectionAmountOperation::feather);
const double feather = session->selection().value().feather;
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(!session->selectionAmountOperation());
QCOMPARE(session->selection().value().feather, feather);
}
void RemappedKeysTests::openTextReadsItsOwnNewKeys()
{
TextCanvas shown;
remap({{"Text Editing:Finish editing text", ShortcutChord("\r", 4)}, {"Text Editing:Increase tracking", ShortcutChord("t", 2)}});
QTRY_VERIFY(shown.canvas->hasFocus());
shown.type("Hi");
const double tracking = shown.session.textDraft().value().style.tracking;
shown.key(Qt::Key_T, Qt::AltModifier);
QVERIFY(shown.session.textDraft().value().style.tracking > tracking);
// The old chords do nothing; the new one finishes.
shown.key(Qt::Key_Return, Qt::ControlModifier);
QVERIFY(shown.session.textDraft().has_value());
QCOMPARE(shown.content(), QString("Hi"));
shown.key(Qt::Key_Return, Qt::MetaModifier);
QVERIFY(!shown.session.textDraft());
}
void RemappedKeysTests::everySheetBindsSwiftsKeys()
{
remap({{"Canvas & Layers:Apply current canvas operation", ShortcutChord("k", 1)},
{"Canvas & Layers:Cancel current canvas operation", ShortcutChord("q", 1)},
{"Canvas & Layers:Toggle Levels preview", ShortcutChord("o", 2)}});
// The keys each sheet's live shortcuts answer to.
const auto keys = [](const QWidget &sheet) {
QSet<QString> result;
for (const QShortcut *shortcut : sheet.findChildren<QShortcut *>()) {
if (shortcut->isEnabled())
result.insert(shortcut->key().toString());
}
return result;
};
const QSet<QString> both{"Ctrl+K", "Ctrl+Q"};
EditorSession session;
session.createDocument(20, 20);
QImage red(20, 20, QImage::Format_RGBA8888_Premultiplied);
red.fill(Qt::red);
session.insert(ImportedImage(red, red, "Red"));
const CanvasDocument canvas = session.document().value();
QCOMPARE(keys(CanvasSizeSheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {})), both);
QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both);
QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, [](std::optional<QByteArray>) {})), both);
QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both);
QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"});
QCOMPARE(keys(TransformInspector(session)), both);
QCOMPARE(keys(SelectionAmountSheet(session, SelectionAmountOperation::expand)), both);
session.addEffect(LayerEffectKind::stroke);
QCOMPARE(keys(EffectsSheet(session, LayerEffectKind::stroke)), both);
session.finishEffectsEditing(false);
session.beginLevels();
QCOMPARE(keys(LevelsSheet(session)), (QSet<QString>{"Ctrl+K", "Ctrl+Q", "Alt+O"}));
session.cancelLevels();
session.beginHueSaturation();
QCOMPARE(keys(HueSaturationSheet(session)), both);
session.cancelHueSaturation();
session.beginFilter(FilterKind::gaussianBlur);
QCOMPARE(keys(FilterSheet(session)), both);
session.cancelFilter();
}
void RemappedKeysTests::aKeyLeftAloneReachesTheParent()
{
EditorSession session;
session.createDocument(20, 20, true);
Parent parent;
auto *canvas = new CanvasView(session, &parent);
auto *list = new NativeLayerList(session, &parent);
list->move(0, 200);
parent.resize(300, 500);
parent.show();
QVERIFY(QTest::qWaitForWindowActive(&parent));
remap({{"Canvas & Layers:Brush tool", ShortcutChord("k")}});
QTest::keyClick(canvas, Qt::Key_Q);
QCOMPARE(parent.keys, 1);
QTest::keyClick(list, Qt::Key_Q);
QCOMPARE(parent.keys, 2);
// A key the canvas takes, or swallows, stays there.
QTest::keyClick(canvas, Qt::Key_K);
QTest::keyClick(canvas, Qt::Key_B);
QTest::keyClick(list, Qt::Key_K);
QCOMPARE(parent.keys, 2);
}
QTEST_MAIN(RemappedKeysTests)
#include "RemappedKeysTests.moc"