Skip to content

Commit 11b9284

Browse files
authored
Add custom layout support for resizing by mouse (#1744)
1 parent 2d99844 commit 11b9284

File tree

6 files changed

+166
-22
lines changed

6 files changed

+166
-22
lines changed

Amethyst.xcodeproj/project.pbxproj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
40111CC9223370FD003D20BD /* SIWindow+AmethystTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40111CC8223370FD003D20BD /* SIWindow+AmethystTests.swift */; };
2525
40111CCB22342CC4003D20BD /* DebugPreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40111CCA22342CC4003D20BD /* DebugPreferencesViewController.swift */; };
2626
40111CCD22342CF3003D20BD /* DebugPreferencesViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 40111CCC22342CF3003D20BD /* DebugPreferencesViewController.xib */; };
27+
401ADBAF2D55A1B6001FF53A /* recommended-main-pane-ratio.js in Resources */ = {isa = PBXBuildFile; fileRef = 401ADBAE2D55A1B6001FF53A /* recommended-main-pane-ratio.js */; };
2728
401BBCB62333067F005118F8 /* ColumnLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401BBCB52333067F005118F8 /* ColumnLayoutTests.swift */; };
2829
401BC8981CE7E45300F89B3F /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401BC8971CE7E45300F89B3F /* WindowManager.swift */; };
2930
401BC89A1CE8C6AE00F89B3F /* HotKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401BC8991CE8C6AE00F89B3F /* HotKeyManager.swift */; };
@@ -153,6 +154,7 @@
153154
40111CCA22342CC4003D20BD /* DebugPreferencesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPreferencesViewController.swift; sourceTree = "<group>"; };
154155
40111CCC22342CF3003D20BD /* DebugPreferencesViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DebugPreferencesViewController.xib; sourceTree = "<group>"; };
155156
401A529824D3B63A004359A4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
157+
401ADBAE2D55A1B6001FF53A /* recommended-main-pane-ratio.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "recommended-main-pane-ratio.js"; sourceTree = "<group>"; };
156158
401BBCB52333067F005118F8 /* ColumnLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnLayoutTests.swift; sourceTree = "<group>"; };
157159
401BC8971CE7E45300F89B3F /* WindowManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = "<group>"; };
158160
401BC8991CE8C6AE00F89B3F /* HotKeyManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HotKeyManager.swift; path = ../Events/HotKeyManager.swift; sourceTree = "<group>"; };
@@ -459,12 +461,13 @@
459461
404541722697C16B00861BE8 /* CustomLayouts */ = {
460462
isa = PBXGroup;
461463
children = (
462-
4087BB032D447F480062A52B /* static-ratio-tall-native-commands.js */,
463464
40D82FF029739C5300F3C18B /* extended.js */,
464-
40DA8B6D27D5AA7300C291AF /* subset.js */,
465465
404541772697CDD000861BE8 /* fullscreen.js */,
466466
404541732697C22A00861BE8 /* null.js */,
467+
401ADBAE2D55A1B6001FF53A /* recommended-main-pane-ratio.js */,
467468
4045417D2698030A00861BE8 /* static-ratio-tall.js */,
469+
4087BB032D447F480062A52B /* static-ratio-tall-native-commands.js */,
470+
40DA8B6D27D5AA7300C291AF /* subset.js */,
468471
404541792697EBC500861BE8 /* undefined.js */,
469472
4045417B2697EE7800861BE8 /* uniform-columns.js */,
470473
);
@@ -748,6 +751,7 @@
748751
4045417C2697EE7800861BE8 /* uniform-columns.js in Resources */,
749752
40DA8B6E27D5AA7300C291AF /* subset.js in Resources */,
750753
40D82FF129739C5300F3C18B /* extended.js in Resources */,
754+
401ADBAF2D55A1B6001FF53A /* recommended-main-pane-ratio.js in Resources */,
751755
404541782697CDD000861BE8 /* fullscreen.js in Resources */,
752756
);
753757
runOnlyForDeploymentPostprocessing = 0;

Amethyst/Layout/Layout.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,11 @@ extension Layout {
116116
- Note: This does not necessarily correspond to the final frame of the window as windows do not necessarily take the exact frame the layout provides.
117117
*/
118118
func assignedFrame(_ window: Window, of windowSet: WindowSet<Window>, on screen: Screen) -> FrameAssignment<Window>? {
119-
return frameAssignments(windowSet, on: screen)?
120-
.map { $0.frameAssignment }
121-
.first { $0.window.id == window.id() }
119+
guard let assignments = frameAssignments(windowSet, on: screen) else {
120+
return nil
121+
}
122+
123+
return assignments.map { $0.frameAssignment }.first { $0.window.id == window.id() }
122124
}
123125
}
124126

Amethyst/Layout/Layouts/CustomLayout.swift

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
5454

5555
private lazy var context: JSContext? = {
5656
guard let context = JSContext() else {
57+
log.error("Failed to create javascript context")
5758
return nil
5859
}
5960

@@ -65,6 +66,7 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
6566
}
6667

6768
context.evaluateScript("var console = { log: function(message) { _consoleLog(message) } }")
69+
6870
let consoleLog: @convention(block) (String) -> Void = { message in
6971
log.debug(message)
7072
}
@@ -73,14 +75,30 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
7375
do {
7476
context.evaluateScript(try String(contentsOf: self.fileURL))
7577
} catch {
78+
log.error(error)
7679
return nil
7780
}
7881

82+
context.evaluateScript("""
83+
function sanitizeArguments(fn) {
84+
return function(...args) {
85+
const sanitizedArgs = args.map(arg => !!arg ? JSON.parse(JSON.stringify(arg)) : undefined);
86+
return fn(...sanitizedArgs);
87+
};
88+
}
89+
90+
function normalizedLayout() {
91+
const l = layout();
92+
l.getFrameAssignments = sanitizeArguments(l.getFrameAssignments);
93+
return l;
94+
}
95+
""")
96+
7997
return context
8098
}()
8199

82100
private lazy var layout: JSValue? = {
83-
return self.context?.objectForKeyedSubscript("layout")?.call(withArguments: [])
101+
return self.context?.objectForKeyedSubscript("normalizedLayout")?.call(withArguments: [])
84102
}()
85103

86104
private lazy var state: JSValue? = {
@@ -136,13 +154,6 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
136154
}
137155

138156
override func frameAssignments(_ windowSet: WindowSet<Window>, on screen: Screen) -> [FrameAssignmentOperation<Window>]? {
139-
guard
140-
let getFrameAssignments = layout?.objectForKeyedSubscript("getFrameAssignments"),
141-
!getFrameAssignments.isNull && !getFrameAssignments.isUndefined
142-
else {
143-
return nil
144-
}
145-
146157
let windows = windowSet.windows
147158

148159
guard !windows.isEmpty else {
@@ -183,21 +194,43 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
183194
extendedFrames ?? JSValue(undefinedIn: context)!
184195
]
185196

186-
guard
187-
let frameAssignmentsValue = getFrameAssignments.call(withArguments: args),
188-
frameAssignmentsValue.isObject
189-
else {
197+
guard let getAssignments = layout?.objectForKeyedSubscript("getFrameAssignments"), !getAssignments.isNull && !getAssignments.isUndefined else {
198+
return nil
199+
}
200+
201+
guard let assignments = getAssignments.call(withArguments: args), assignments.isObject else {
190202
return nil
191203
}
192204

193-
let resizeRules = ResizeRules(isMain: true, unconstrainedDimension: .horizontal, scaleFactor: 1)
194-
return jsWindows.values.compactMap { jsWindow in
195-
guard let frame = frameAssignmentsValue.objectForKeyedSubscript(jsWindow.id)?.toRoundedRect() else {
205+
return windows.compactMap { window -> FrameAssignmentOperation<Window>? in
206+
guard let jsWindow = jsWindows[window.id] else {
196207
return nil
197208
}
198209

210+
guard let frame = assignments.objectForKeyedSubscript(jsWindow.id) else {
211+
return nil
212+
}
213+
214+
var unconstrainedDimension: UnconstrainedDimension = .horizontal
215+
var scaleFactor = screenFrame.width / frame.toRoundedRect().width
216+
217+
if let dimension = frame.objectForKeyedSubscript("unconstrainedDimension")?.toString() {
218+
switch dimension {
219+
case "horizontal":
220+
unconstrainedDimension = .horizontal
221+
case "vertical":
222+
unconstrainedDimension = .vertical
223+
scaleFactor = screenFrame.height / frame.toRoundedRect().height
224+
default:
225+
log.warning("Encountered unknown unconstrainedDimension value: \(dimension), defaulting to horizontal")
226+
unconstrainedDimension = .horizontal
227+
}
228+
}
229+
230+
let isMain = frame.objectForKeyedSubscript("isMain")?.toBool() ?? true
231+
let resizeRules = ResizeRules(isMain: isMain, unconstrainedDimension: unconstrainedDimension, scaleFactor: scaleFactor)
199232
let frameAssignment = FrameAssignment<Window>(
200-
frame: frame,
233+
frame: frame.toRoundedRect(),
201234
window: jsWindow.window,
202235
screenFrame: screenFrame,
203236
resizeRules: resizeRules
@@ -329,7 +362,21 @@ class CustomLayout<Window: WindowType>: StatefulLayout<Window>, PanedLayout {
329362
}
330363

331364
func recommendMainPaneRawRatio(rawRatio: CGFloat) {
365+
guard
366+
let recommendMainPaneRatio = layout?.objectForKeyedSubscript("recommendMainPaneRatio"),
367+
!recommendMainPaneRatio.isNull && !recommendMainPaneRatio.isUndefined
368+
else {
369+
return
370+
}
332371

372+
let recommendMainPaneRatioArgs: [Any]? = state.flatMap { [rawRatio, $0] }
373+
374+
guard let updatedState = recommendMainPaneRatio.call(withArguments: recommendMainPaneRatioArgs ?? []), !updatedState.isNull && !updatedState.isUndefined else {
375+
log.error("\(layoutKey) — recommendMainPaneRawRatio: received invalid updated state")
376+
return
377+
}
378+
379+
state = updatedState
333380
}
334381

335382
func increaseMainPaneCount() {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
function layout() {
2+
return {
3+
name: "Ratio",
4+
initialState: {
5+
mainPaneRatio: 0.5
6+
},
7+
recommendMainPaneRatio: (ratio, state) => {
8+
return { ...state, mainPaneRatio: ratio };
9+
},
10+
getFrameAssignments: (windows, screenFrame, state) => {
11+
return windows.reduce((frames, window, index) => {
12+
if (index === 0) {
13+
const frame = {
14+
x: screenFrame.x,
15+
y: screenFrame.y,
16+
width: screenFrame.width * state.mainPaneRatio,
17+
height: screenFrame.height,
18+
isMain: true,
19+
unconstrainedDimension: "horizontal"
20+
};
21+
return { ...frames, [window.id]: frame };
22+
} else {
23+
const frame = {
24+
x: screenFrame.x + screenFrame.width * state.mainPaneRatio,
25+
y: screenFrame.y,
26+
width: screenFrame.width - screenFrame.width * state.mainPaneRatio,
27+
height: screenFrame.height,
28+
isMain: false,
29+
unconstrainedDimension: "horizontal"
30+
};
31+
return { ...frames, [window.id]: frame };
32+
}
33+
}, {});
34+
}
35+
};
36+
}

AmethystTests/Tests/Layout/CustomLayoutTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,5 +695,44 @@ class CustomLayoutTests: QuickSpec {
695695
}
696696
}
697697
}
698+
699+
describe("recommend main pane ratio") {
700+
it("receives correct ratio") {
701+
let screen = TestScreen(frame: CGRect(origin: .zero, size: CGSize(width: 2000, height: 1000)))
702+
TestScreen.availableScreens = [screen]
703+
704+
let window = TestWindow(element: nil)!
705+
let layoutWindow = LayoutWindow<TestWindow>(id: window.id(), frame: window.frame(), isFocused: false)
706+
let windowSet = WindowSet<TestWindow>(
707+
windows: [layoutWindow],
708+
isWindowWithIDActive: { _ in return true },
709+
isWindowWithIDFloating: { _ in return false },
710+
windowForID: { _ in window }
711+
)
712+
let layout = CustomLayout<TestWindow>(key: "recommended-main-pane-ratio", fileURL: Bundle.layoutFile(key: "recommended-main-pane-ratio")!)
713+
var frameAssignments = layout.frameAssignments(windowSet, on: screen)!
714+
var mainAssignment = frameAssignments.forWindows([window])
715+
716+
mainAssignment.verify(frames: [
717+
CGRect(x: 0, y: 0, width: 1000, height: 1000)
718+
])
719+
720+
layout.recommendMainPaneRawRatio(rawRatio: 0.25)
721+
frameAssignments = layout.frameAssignments(windowSet, on: screen)!
722+
mainAssignment = frameAssignments.forWindows([window])
723+
724+
mainAssignment.verify(frames: [
725+
CGRect(x: 0, y: 0, width: 500, height: 1000)
726+
])
727+
728+
layout.recommendMainPaneRawRatio(rawRatio: 0.75)
729+
frameAssignments = layout.frameAssignments(windowSet, on: screen)!
730+
mainAssignment = frameAssignments.forWindows([window])
731+
732+
mainAssignment.verify(frames: [
733+
CGRect(x: 0, y: 0, width: 1500, height: 1000)
734+
])
735+
}
736+
}
698737
}
699738
}

docs/custom-layouts.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ A function that takes two arguments—`change` and `state`—and must return a n
5252

5353
* `change`: the particular change the layout needs to respond to.
5454

55+
#### `recommendMainPaneRatio`
56+
57+
A function that takes two arguments—`ratio` and `state`—and must return a new layout state based on the recommended ratio.
58+
59+
* `ratio`: the ratio recommended for the layout based on windows being resized by mouse controls.
60+
61+
### Mouse Resizing
62+
63+
Amethyst supports changing the relative ratios of windows when changing the size of windows by dragging them with the cursor. By default, these ratios are recommended by calling the `recommendMainPaneRatio` layout property, and happen on the horizontal axis. When the window is resized, the system determines what ratio is appropriate for the new width given the dimensions of the screen it is on. These values are clamped to [0, 1].
64+
65+
To scale along a different axis, you can specify the `unconstrainedDimension` and `isMain` properties of each window's frame. The dimension determines the axis along which window frame changes will cause recommended ratio changes, and the `isMain` property determines which part of the ratio the window applies to.
66+
67+
Note that currently the recommended ratio is global to the layout and not specific to a given window, so it is not particularly meaningful to specify multiple `unconstrainedDimension` values among frames.
68+
5569
### Common Structures
5670

5771
#### Windows
@@ -64,12 +78,14 @@ A window is an object with three properties.
6478

6579
#### Frames
6680

67-
A frame is an object with four properties.
81+
A frame is an object with four required properties and two optional properties.
6882

6983
* `x`: x-coordinate in the screen space
7084
* `y`: y-coordinate in the screen space
7185
* `width`: pixel width
7286
* `height`: pixel height
87+
* (optional) `isMain`: boolean indicating whether the window is in the main pane (default: `true`)
88+
* (optional) `unconstrainedDimension`: a string indicating on which axis the window is able to be resized via mouse (values: `horizontal`, `vertical`; default: `horizontal`)
7389

7490
Note that frames are in a global space, not relative to a given screen.
7591

0 commit comments

Comments
 (0)