forked from rime/squirrel
-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathSquirrelPanel.swift
473 lines (431 loc) · 17.6 KB
/
SquirrelPanel.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//
// SquirrelPanel.swift
// Squirrel
//
// Created by Leo Liu on 5/10/24.
//
import AppKit
final class SquirrelPanel: NSPanel {
private let view: SquirrelView
private let back: NSVisualEffectView
var inputController: SquirrelInputController?
var position: NSRect
private var screenRect: NSRect = .zero
private var maxHeight: CGFloat = 0
private var statusMessage: String = ""
private var statusTimer: Timer?
private var preedit: String = ""
private var selRange: NSRange = .empty
private var caretPos: Int = 0
private var candidates: [String] = .init()
private var comments: [String] = .init()
private var labels: [String] = .init()
private var index: Int = 0
private var cursorIndex: Int = 0
private var scrollDirection: CGVector = .zero
private var scrollTime: Date = .distantPast
private var page: Int = 0
private var lastPage: Bool = true
private var pagingUp: Bool?
init(position: NSRect) {
self.position = position
self.view = SquirrelView(frame: position)
self.back = NSVisualEffectView()
super.init(contentRect: position, styleMask: .nonactivatingPanel, backing: .buffered, defer: true)
self.level = .init(Int(CGShieldingWindowLevel()))
self.hasShadow = true
self.isOpaque = false
self.backgroundColor = .clear
back.blendingMode = .behindWindow
back.material = .hudWindow
back.state = .active
back.wantsLayer = true
back.layer?.mask = view.shape
let contentView = NSView()
contentView.addSubview(back)
contentView.addSubview(view)
contentView.addSubview(view.textView)
self.contentView = contentView
}
var linear: Bool {
view.currentTheme.linear
}
var vertical: Bool {
view.currentTheme.vertical
}
var inlinePreedit: Bool {
view.currentTheme.inlinePreedit
}
var inlineCandidate: Bool {
view.currentTheme.inlineCandidate
}
// swiftlint:disable:next cyclomatic_complexity
override func sendEvent(_ event: NSEvent) {
switch event.type {
case .leftMouseDown:
let (index, _, pagingUp) = view.click(at: mousePosition())
if let pagingUp {
self.pagingUp = pagingUp
} else {
self.pagingUp = nil
}
if let index, index >= 0 && index < candidates.count {
self.index = index
}
case .leftMouseUp:
let (index, preeditIndex, pagingUp) = view.click(at: mousePosition())
if let pagingUp, pagingUp == self.pagingUp {
_ = inputController?.page(up: pagingUp)
} else {
self.pagingUp = nil
}
if let preeditIndex, preeditIndex >= 0 && preeditIndex < preedit.utf16.count {
if preeditIndex < caretPos {
_ = inputController?.moveCaret(forward: true)
} else if preeditIndex > caretPos {
_ = inputController?.moveCaret(forward: false)
}
}
if let index, index == self.index && index >= 0 && index < candidates.count {
_ = inputController?.selectCandidate(index)
}
case .mouseEntered:
acceptsMouseMovedEvents = true
case .mouseExited:
acceptsMouseMovedEvents = false
if cursorIndex != index {
update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false)
}
pagingUp = nil
case .mouseMoved:
let (index, _, _) = view.click(at: mousePosition())
if let index = index, cursorIndex != index && index >= 0 && index < candidates.count {
update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false)
}
case .scrollWheel:
if event.phase == .began {
scrollDirection = .zero
// Scrollboard span
} else if event.phase == .ended || (event.phase == .init(rawValue: 0) && event.momentumPhase != .init(rawValue: 0)) {
if abs(scrollDirection.dx) > abs(scrollDirection.dy) && abs(scrollDirection.dx) > 10 {
_ = inputController?.page(up: (scrollDirection.dx < 0) == vertical)
} else if abs(scrollDirection.dx) < abs(scrollDirection.dy) && abs(scrollDirection.dy) > 10 {
_ = inputController?.page(up: scrollDirection.dx > 0)
}
scrollDirection = .zero
// Mouse scroll wheel
} else if event.phase == .init(rawValue: 0) && event.momentumPhase == .init(rawValue: 0) {
if scrollTime.timeIntervalSinceNow < -1 {
scrollDirection = .zero
}
scrollTime = .now
if (scrollDirection.dy >= 0 && event.scrollingDeltaY > 0) || (scrollDirection.dy <= 0 && event.scrollingDeltaY < 0) {
scrollDirection.dy += event.scrollingDeltaY
} else {
scrollDirection = .zero
}
if abs(scrollDirection.dy) > 10 {
_ = inputController?.page(up: scrollDirection.dy > 0)
scrollDirection = .zero
}
} else {
scrollDirection.dx += event.scrollingDeltaX
scrollDirection.dy += event.scrollingDeltaY
}
default:
break
}
super.sendEvent(event)
}
func hide() {
statusTimer?.invalidate()
statusTimer = nil
orderOut(nil)
maxHeight = 0
}
// Main function to add attributes to text output from librime
// swiftlint:disable:next cyclomatic_complexity function_parameter_count
func update(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted index: Int, page: Int, lastPage: Bool, update: Bool) {
if update {
self.preedit = preedit
self.selRange = selRange
self.caretPos = caretPos
self.candidates = candidates
self.comments = comments
self.labels = labels
self.index = index
self.page = page
self.lastPage = lastPage
}
cursorIndex = index
if !candidates.isEmpty || !preedit.isEmpty {
statusMessage = ""
statusTimer?.invalidate()
statusTimer = nil
} else {
if !statusMessage.isEmpty {
show(status: statusMessage)
statusMessage = ""
} else if statusTimer == nil {
hide()
}
return
}
let theme = view.currentTheme
currentScreen()
let text = NSMutableAttributedString()
let preeditRange: NSRange
let highlightedPreeditRange: NSRange
// preedit
if !preedit.isEmpty {
preeditRange = NSRange(location: 0, length: preedit.utf16.count)
highlightedPreeditRange = selRange
let line = NSMutableAttributedString(string: preedit)
line.addAttributes(theme.preeditAttrs, range: preeditRange)
line.addAttributes(theme.preeditHighlightedAttrs, range: selRange)
text.append(line)
text.addAttribute(.paragraphStyle, value: theme.preeditParagraphStyle, range: NSRange(location: 0, length: text.length))
if !candidates.isEmpty {
text.append(NSAttributedString(string: "\n", attributes: theme.preeditAttrs))
}
} else {
preeditRange = .empty
highlightedPreeditRange = .empty
}
// candidates
var candidateRanges = [NSRange]()
for i in 0..<candidates.count {
let attrs = i == index ? theme.highlightedAttrs : theme.attrs
let labelAttrs = i == index ? theme.labelHighlightedAttrs : theme.labelAttrs
let commentAttrs = i == index ? theme.commentHighlightedAttrs : theme.commentAttrs
let label = if theme.candidateFormat.contains(/\[label\]/) {
if labels.count > 1 && i < labels.count {
labels[i]
} else if labels.count == 1 && i < labels.first!.count {
// custom: A. B. C...
String(labels.first![labels.first!.index(labels.first!.startIndex, offsetBy: i)])
} else {
// default: 1. 2. 3...
"\(i+1)"
}
} else {
""
}
let candidate = candidates[i].precomposedStringWithCanonicalMapping
let comment = comments[i].precomposedStringWithCanonicalMapping
let line = NSMutableAttributedString(string: theme.candidateFormat, attributes: labelAttrs)
for range in line.string.ranges(of: /\[candidate\]/) {
let convertedRange = convert(range: range, in: line.string)
line.addAttributes(attrs, range: convertedRange)
if candidate.count <= 5 {
line.addAttribute(.noBreak, value: true, range: NSRange(location: convertedRange.location+1, length: convertedRange.length-1))
}
}
for range in line.string.ranges(of: /\[comment\]/) {
line.addAttributes(commentAttrs, range: convert(range: range, in: line.string))
}
line.mutableString.replaceOccurrences(of: "[label]", with: label, range: NSRange(location: 0, length: line.length))
let labeledLine = line.copy() as! NSAttributedString
line.mutableString.replaceOccurrences(of: "[candidate]", with: candidate, range: NSRange(location: 0, length: line.length))
line.mutableString.replaceOccurrences(of: "[comment]", with: comment, range: NSRange(location: 0, length: line.length))
if line.length <= 10 {
line.addAttribute(.noBreak, value: true, range: NSRange(location: 1, length: line.length-1))
}
let lineSeparator = NSAttributedString(string: linear ? " " : "\n", attributes: attrs)
if i > 0 {
text.append(lineSeparator)
}
let str = lineSeparator.mutableCopy() as! NSMutableAttributedString
if vertical {
str.addAttribute(.verticalGlyphForm, value: 1, range: NSRange(location: 0, length: str.length))
}
view.separatorWidth = str.boundingRect(with: .zero).width
let paragraphStyleCandidate = (i == 0 ? theme.firstParagraphStyle : theme.paragraphStyle).mutableCopy() as! NSMutableParagraphStyle
if linear {
paragraphStyleCandidate.paragraphSpacingBefore -= theme.linespace
paragraphStyleCandidate.lineSpacing = theme.linespace
}
if !linear, let labelEnd = labeledLine.string.firstMatch(of: /\[(candidate|comment)\]/)?.range.lowerBound {
let labelString = labeledLine.attributedSubstring(from: NSRange(location: 0, length: labelEnd.utf16Offset(in: labeledLine.string)))
let labelWidth = labelString.boundingRect(with: .zero, options: [.usesLineFragmentOrigin]).width
paragraphStyleCandidate.headIndent = labelWidth
}
line.addAttribute(.paragraphStyle, value: paragraphStyleCandidate, range: NSRange(location: 0, length: line.length))
candidateRanges.append(NSRange(location: text.length, length: line.length))
text.append(line)
}
// text done!
view.textView.textContentStorage?.attributedString = text
view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal)
view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage)
show()
}
func updateStatus(long longMessage: String, short shortMessage: String) {
let theme = view.currentTheme
switch theme.statusMessageType {
case .mix:
statusMessage = shortMessage.isEmpty ? longMessage : shortMessage
case .long:
statusMessage = longMessage
case .short:
if !shortMessage.isEmpty {
statusMessage = shortMessage
} else if let initial = longMessage.first {
statusMessage = String(initial)
} else {
statusMessage = ""
}
}
}
func load(config: SquirrelConfig, forDarkMode isDark: Bool) {
if isDark {
view.darkTheme = SquirrelTheme()
view.darkTheme.load(config: config, dark: true)
} else {
view.lightTheme = SquirrelTheme()
view.lightTheme.load(config: config, dark: isDark)
}
}
}
private extension SquirrelPanel {
func mousePosition() -> NSPoint {
var point = NSEvent.mouseLocation
point = self.convertPoint(fromScreen: point)
return view.convert(point, from: nil)
}
func currentScreen() {
if let screen = NSScreen.main {
screenRect = screen.frame
}
for screen in NSScreen.screens where screen.frame.contains(position.origin) {
screenRect = screen.frame
break
}
}
func maxTextWidth() -> CGFloat {
let theme = view.currentTheme
let font: NSFont = theme.font
let fontScale = font.pointSize / 12
let textWidthRatio = min(1, 1 / (vertical ? 4 : 3) + fontScale / 12)
let maxWidth = if vertical {
screenRect.height * textWidthRatio - theme.edgeInset.height * 2
} else {
screenRect.width * textWidthRatio - theme.edgeInset.width * 2
}
return maxWidth
}
// Get the window size, the windows will be the dirtyRect in
// SquirrelView.drawRect
// swiftlint:disable:next cyclomatic_complexity
func show() {
currentScreen()
let theme = view.currentTheme
if !view.darkTheme.available {
self.appearance = NSAppearance(named: .aqua)
}
// Break line if the text is too long, based on screen size.
let textWidth = maxTextWidth()
let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2
view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight)
var panelRect = NSRect.zero
// in vertical mode, the width and height are interchanged
var contentRect = view.contentRect
if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) ||
(vertical && position.minX + max(contentRect.width, maxHeight) + theme.edgeInset.width * 2 > screenRect.maxX) {
if contentRect.width >= maxHeight {
maxHeight = contentRect.width
} else {
contentRect.size.width = maxHeight
view.textContainer.size = NSSize(width: maxHeight, height: maxTextHeight)
}
}
if vertical {
panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.height + theme.edgeInset.height * 2),
height: min(0.95 * screenRect.height, contentRect.width + theme.edgeInset.width * 2) + theme.pagingOffset)
// To avoid jumping up and down while typing, use the lower screen when
// typing on upper, and vice versa
if position.midY / screenRect.height >= 0.5 {
panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset
} else {
panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight
}
// Make the first candidate fixed at the left of cursor
panelRect.origin.x = position.minX - panelRect.width - SquirrelTheme.offsetHeight
if view.preeditRange.length > 0, let preeditTextRange = view.convert(range: view.preeditRange) {
let preeditRect = view.contentRect(range: preeditTextRange)
panelRect.origin.x += preeditRect.height + theme.edgeInset.width
}
} else {
panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.width + theme.edgeInset.width * 2),
height: min(0.95 * screenRect.height, contentRect.height + theme.edgeInset.height * 2))
panelRect.size.width += theme.pagingOffset
panelRect.origin = NSPoint(x: position.minX - theme.pagingOffset, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height)
}
if panelRect.maxX > screenRect.maxX {
panelRect.origin.x = screenRect.maxX - panelRect.width
}
if panelRect.minX < screenRect.minX {
panelRect.origin.x = screenRect.minX
}
if panelRect.minY < screenRect.minY {
if vertical {
panelRect.origin.y = screenRect.minY
} else {
panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight
}
}
if panelRect.maxY > screenRect.maxY {
panelRect.origin.y = screenRect.maxY - panelRect.height
}
if panelRect.minY < screenRect.minY {
panelRect.origin.y = screenRect.minY
}
self.setFrame(panelRect, display: true)
// rotate the view, the core in vertical mode!
if vertical {
contentView!.boundsRotation = -90
contentView!.setBoundsOrigin(NSPoint(x: 0, y: panelRect.width))
} else {
contentView!.boundsRotation = 0
contentView!.setBoundsOrigin(.zero)
}
view.textView.boundsRotation = 0
view.textView.setBoundsOrigin(.zero)
view.frame = contentView!.bounds
view.textView.frame = contentView!.bounds
view.textView.frame.size.width -= theme.pagingOffset
view.textView.frame.origin.x += theme.pagingOffset
view.textView.textContainerInset = theme.edgeInset
if theme.translucency {
back.frame = contentView!.bounds
back.frame.size.width += theme.pagingOffset
back.appearance = NSApp.effectiveAppearance
back.isHidden = false
} else {
back.isHidden = true
}
alphaValue = theme.alpha
invalidateShadow()
orderFront(nil)
// voila!
}
func show(status message: String) {
let theme = view.currentTheme
let text = NSMutableAttributedString(string: message, attributes: theme.attrs)
text.addAttribute(.paragraphStyle, value: theme.paragraphStyle, range: NSRange(location: 0, length: text.length))
view.textContentStorage.attributedString = text
view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal)
view.drawView(candidateRanges: [NSRange(location: 0, length: text.length)], hilightedIndex: -1,
preeditRange: .empty, highlightedPreeditRange: .empty, canPageUp: false, canPageDown: false)
show()
statusTimer?.invalidate()
statusTimer = Timer.scheduledTimer(withTimeInterval: SquirrelTheme.showStatusDuration, repeats: false) { _ in
self.hide()
}
}
func convert(range: Range<String.Index>, in string: String) -> NSRange {
let startPos = range.lowerBound.utf16Offset(in: string)
let endPos = range.upperBound.utf16Offset(in: string)
return NSRange(location: startPos, length: endPos - startPos)
}
}