From 08c410f7dc0eeb0ab82915c3321eb19395924438 Mon Sep 17 00:00:00 2001 From: LEO Yoon-Tsaw Date: Fri, 28 Jun 2024 22:39:26 -0400 Subject: [PATCH] add paging indicators (#953) --- .github/workflows/commit-ci.yml | 2 +- .github/workflows/pull-request-ci.yml | 2 +- .github/workflows/release-ci.yml | 2 +- action-install.sh | 2 +- data/squirrel.yaml | 2 + sources/BridgingFunctions.swift | 23 +++++ sources/SquirrelInputController.swift | 10 ++- sources/SquirrelPanel.swift | 52 +++++++---- sources/SquirrelTheme.swift | 10 +++ sources/SquirrelView.swift | 120 ++++++++++++++++++++------ 10 files changed, 176 insertions(+), 49 deletions(-) diff --git a/.github/workflows/commit-ci.yml b/.github/workflows/commit-ci.yml index e770200a7..339095617 100644 --- a/.github/workflows/commit-ci.yml +++ b/.github/workflows/commit-ci.yml @@ -29,7 +29,7 @@ jobs: run: brew install peripheryapp/periphery/periphery - name: Check Unused Code - run: periphery scan --skip-build --index-store-path build/Index.noindex/DataStore + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore - name: Upload Squirrel artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 4ce152efc..f7b1e8b64 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -26,7 +26,7 @@ jobs: run: brew install peripheryapp/periphery/periphery - name: Check Unused Code - run: periphery scan --skip-build --index-store-path build/Index.noindex/DataStore + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore - name: Upload Squirrel artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 3929ef525..86d66e329 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -34,7 +34,7 @@ jobs: run: brew install peripheryapp/periphery/periphery - name: Check Unused Code - run: periphery scan --skip-build --index-store-path build/Index.noindex/DataStore + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore - name: Build changelog id: release_log diff --git a/action-install.sh b/action-install.sh index 0f884d6f4..5f314f242 100755 --- a/action-install.sh +++ b/action-install.sh @@ -3,7 +3,7 @@ set -e rime_version=latest -rime_git_hash=6b1b41f +rime_git_hash=2f89098 sparkle_version=2.6.2 rime_archive="rime-${rime_git_hash}-macOS-universal.tar.bz2" diff --git a/data/squirrel.yaml b/data/squirrel.yaml index 08ce72c26..a97de2932 100644 --- a/data/squirrel.yaml +++ b/data/squirrel.yaml @@ -33,6 +33,8 @@ style: mutual_exclusive: false # Whether to use a translucent background. Only visible when background color is transparent translucency: false + # Enable to show small arrows that indicates if paging up/down is possible + show_paging: false corner_radius: 7 hilited_corner_radius: 0 diff --git a/sources/BridgingFunctions.swift b/sources/BridgingFunctions.swift index 39fdcdffd..a4b15aaa3 100644 --- a/sources/BridgingFunctions.swift +++ b/sources/BridgingFunctions.swift @@ -62,3 +62,26 @@ func ?=(left: inout T?, right: T?) { extension NSRange { static let empty = NSRange(location: NSNotFound, length: 0) } + +extension NSPoint { + static func += (lhs: inout Self, rhs: Self) { + lhs.x += rhs.x + lhs.y += rhs.y + } + static func - (lhs: Self, rhs: Self) -> Self { + Self.init(x: lhs.x - rhs.x, y: lhs.y - rhs.y) + } + static func -= (lhs: inout Self, rhs: Self) { + lhs.x -= rhs.x + lhs.y -= rhs.y + } + static func * (lhs: Self, rhs: CGFloat) -> Self { + Self.init(x: lhs.x * rhs, y: lhs.y * rhs) + } + static func / (lhs: Self, rhs: CGFloat) -> Self { + Self.init(x: lhs.x / rhs, y: lhs.y / rhs) + } + var length: CGFloat { + sqrt(pow(self.x, 2) + pow(self.y, 2)) + } +} diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift index 3b4861a11..91a8b1bd4 100644 --- a/sources/SquirrelInputController.swift +++ b/sources/SquirrelInputController.swift @@ -501,10 +501,13 @@ private extension SquirrelInputController { } } // swiftlint:enable identifier_name + let page = Int(ctx.menu.page_no) + let lastPage = ctx.menu.is_last_page let selRange = NSRange(location: start.utf16Offset(in: preedit), length: preedit.utf16.distance(from: start, to: end)) showPanel(preedit: inlinePreedit ? "" : preedit, selRange: selRange, caretPos: caretPos.utf16Offset(in: preedit), - candidates: candidates, comments: comments, labels: labels, highlighted: Int(ctx.menu.highlighted_candidate_index)) + candidates: candidates, comments: comments, labels: labels, highlighted: Int(ctx.menu.highlighted_candidate_index), + page: page, lastPage: lastPage) _ = rimeAPI.free_context(&ctx) } else { hidePalettes() @@ -544,7 +547,7 @@ private extension SquirrelInputController { } // swiftlint:disable:next function_parameter_count - func showPanel(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted: Int) { + func showPanel(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted: Int, page: Int, lastPage: Bool) { // print("[DEBUG] showPanelWithPreedit:...:") guard let client = client else { return } var inputPos = NSRect() @@ -552,7 +555,8 @@ private extension SquirrelInputController { if let panel = NSApp.squirrelAppDelegate.panel { panel.position = inputPos panel.inputController = self - panel.update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: highlighted, update: true) + panel.update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, + highlighted: highlighted, page: page, lastPage: lastPage, update: true) } } } diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index f0e1e3f3f..008ba640b 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -29,6 +29,9 @@ final class SquirrelPanel: NSPanel { 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 @@ -68,20 +71,31 @@ final class SquirrelPanel: NSPanel { override func sendEvent(_ event: NSEvent) { switch event.type { case .leftMouseDown: - let (index, _) = view.click(at: mousePosition()) - if let index = index, index >= 0 && index < candidates.count { + 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) = view.click(at: mousePosition()) - if let preeditIndex = preeditIndex, preeditIndex >= 0 && preeditIndex < preedit.utf16.count { + 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, index == self.index && index >= 0 && index < candidates.count { + if let index, index == self.index && index >= 0 && index < candidates.count { _ = inputController?.selectCandidate(index) } case .mouseEntered: @@ -89,12 +103,13 @@ final class SquirrelPanel: NSPanel { case .mouseExited: acceptsMouseMovedEvents = false if cursorIndex != index { - update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, update: false) + 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()) + 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, update: false) + 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 { @@ -104,7 +119,7 @@ final class SquirrelPanel: NSPanel { 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) + _ = inputController?.page(up: scrollDirection.dy > 0) } scrollDirection = .zero // Mouse scroll wheel @@ -141,7 +156,7 @@ final class SquirrelPanel: NSPanel { // 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, update: Bool) { + 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 @@ -150,6 +165,8 @@ final class SquirrelPanel: NSPanel { self.comments = comments self.labels = labels self.index = index + self.page = page + self.lastPage = lastPage } cursorIndex = index @@ -266,7 +283,7 @@ final class SquirrelPanel: NSPanel { // text done! view.textView.textContentStorage?.attributedString = text view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) - view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange) + view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage) show() } @@ -359,11 +376,12 @@ private extension SquirrelPanel { 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)) + 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 + panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset } else { panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight } @@ -376,7 +394,8 @@ private extension SquirrelPanel { } 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.origin = NSPoint(x: position.minX, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height) + 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 @@ -412,10 +431,13 @@ private extension SquirrelPanel { 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 { @@ -434,7 +456,7 @@ private extension SquirrelPanel { 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) + preeditRange: .empty, highlightedPreeditRange: .empty, canPageUp: false, canPageDown: false) show() statusTimer?.invalidate() diff --git a/sources/SquirrelTheme.swift b/sources/SquirrelTheme.swift index 401e28ea3..60af2236a 100644 --- a/sources/SquirrelTheme.swift +++ b/sources/SquirrelTheme.swift @@ -65,6 +65,7 @@ final class SquirrelTheme { private(set) var vertical = false private(set) var inlinePreedit = false private(set) var inlineCandidate = false + private(set) var showPaging = false private var fonts = [NSFont]() private var labelFonts = [NSFont]() @@ -182,6 +183,13 @@ final class SquirrelTheme { _candidateFormat = newTemplate } } + var pagingOffset: CGFloat { + if showPaging { + (labelFontSize ?? fontSize ?? Self.defaultFontSize) * 1.5 + } else { + 0 + } + } func load(config: SquirrelConfig, dark: Bool) { linear ?= config.getString("style/candidate_list_layout").map { $0 == "linear" } @@ -191,6 +199,7 @@ final class SquirrelTheme { translucency ?= config.getBool("style/translucency") mutualExclusive ?= config.getBool("style/mutual_exclusive") memorizeSize ?= config.getBool("style/memorize_size") + showPaging ?= config.getBool("style/show_paging") statusMessageType ?= .init(rawValue: config.getString("style/status_message_type") ?? "") candidateFormat ?= config.getString("style/candidate_format") @@ -244,6 +253,7 @@ final class SquirrelTheme { inlineCandidate ?= config.getBool("\(prefix)/inline_candidate") translucency ?= config.getBool("\(prefix)/translucency") mutualExclusive ?= config.getBool("\(prefix)/mutual_exclusive") + showPaging ?= config.getBool("\(prefix)/show_paging") candidateFormat ?= config.getString("\(prefix)/candidate_format") fontName ?= config.getString("\(prefix)/font_face") fontSize ?= config.getDouble("\(prefix)/font_point") diff --git a/sources/SquirrelView.swift b/sources/SquirrelView.swift index 4f590764f..45fe1e69d 100644 --- a/sources/SquirrelView.swift +++ b/sources/SquirrelView.swift @@ -29,9 +29,13 @@ final class SquirrelView: NSView { var candidateRanges: [NSRange] = [] var hilightedIndex = 0 var preeditRange: NSRange = .empty + var canPageUp: Bool = false + var canPageDown: Bool = false var highlightedPreeditRange: NSRange = .empty var separatorWidth: CGFloat = 0 var shape = CAShapeLayer() + private var downPath: CGPath? + private var upPath: CGPath? var lightTheme = SquirrelTheme() var darkTheme = SquirrelTheme() @@ -112,11 +116,14 @@ final class SquirrelView: NSView { } // Will triger - (void)drawRect:(NSRect)dirtyRect - func drawView(candidateRanges: [NSRange], hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange) { + // swiftlint:disable:next function_parameter_count + func drawView(candidateRanges: [NSRange], hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange, canPageUp: Bool, canPageDown: Bool) { self.candidateRanges = candidateRanges self.hilightedIndex = hilightedIndex self.preeditRange = preeditRange self.highlightedPreeditRange = highlightedPreeditRange + self.canPageUp = canPageUp + self.canPageDown = canPageDown self.needsDisplay = true } @@ -130,8 +137,9 @@ final class SquirrelView: NSView { var highlightedPreeditPath: CGMutablePath? let theme = currentTheme - let backgroundRect = dirtyRect var containingRect = dirtyRect + containingRect.size.width -= theme.pagingOffset + let backgroundRect = containingRect // Draw preedit Rect var preeditRect = NSRect.zero @@ -210,7 +218,6 @@ final class SquirrelView: NSView { NSBezierPath.defaultLineWidth = 0 backgroundPath = drawSmoothLines(rectVertex(of: backgroundRect), straightCorner: Set(), alpha: 0.3 * theme.cornerRadius, beta: 1.4 * theme.cornerRadius) - shape.path = backgroundPath self.layer?.sublayers = nil let backPath = backgroundPath?.mutableCopy() @@ -280,14 +287,39 @@ final class SquirrelView: NSView { } panelLayer.addSublayer(layer) } + panelLayer.setAffineTransform(CGAffineTransform(translationX: theme.pagingOffset, y: 0)) + let panelPath = CGMutablePath() + panelPath.addPath(backgroundPath!, transform: panelLayer.affineTransform().scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height)) + + let (pagingLayer, downPath, upPath) = pagingLayer(theme: theme, preeditRect: preeditRect) + if let sublayers = pagingLayer.sublayers, !sublayers.isEmpty { + self.layer?.addSublayer(pagingLayer) + } + let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height) + if let downPath { + panelPath.addPath(downPath, transform: flipTransform) + self.downPath = downPath.copy() + } + if let upPath { + panelPath.addPath(upPath, transform: flipTransform) + self.upPath = upPath.copy() + } + + shape.path = panelPath } - func click(at clickPoint: NSPoint) -> (Int?, Int?) { + func click(at clickPoint: NSPoint) -> (Int?, Int?, Bool?) { var index = 0 var candidateIndex: Int? var preeditIndex: Int? + if let downPath = self.downPath, downPath.contains(clickPoint) { + return (nil, nil, false) + } + if let upPath = self.upPath, upPath.contains(clickPoint) { + return (nil, nil, true) + } if let path = shape.path, path.contains(clickPoint) { - var point = NSPoint(x: clickPoint.x - textView.textContainerInset.width, + var point = NSPoint(x: clickPoint.x - textView.textContainerInset.width - currentTheme.pagingOffset, y: clickPoint.y - textView.textContainerInset.height) let fragment = textLayoutManager.textLayoutFragment(for: point) if let fragment = fragment { @@ -313,17 +345,15 @@ final class SquirrelView: NSView { } } } - return (candidateIndex, preeditIndex) + return (candidateIndex, preeditIndex, nil) } } private extension SquirrelView { // A tweaked sign function, to winddown corner radius when the size is small - func sign(_ number: CGFloat) -> CGFloat { - if number >= 2 { - return 1 - } else if number <= -2 { - return -1 + func sign(_ number: NSPoint) -> NSPoint { + if number.length >= 2 { + return number / number.length } else { return number / 2 } @@ -331,7 +361,7 @@ private extension SquirrelView { // Bezier cubic curve, which has continuous roundness func drawSmoothLines(_ vertex: [NSPoint], straightCorner: Set, alpha: CGFloat, beta rawBeta: CGFloat) -> CGPath? { - guard vertex.count >= 4 else { + guard vertex.count >= 3 else { return nil } let beta = max(0.00001, rawBeta) @@ -342,10 +372,9 @@ private extension SquirrelView { var control1: NSPoint var control2: NSPoint var target = previousPoint - var diff = NSPoint(x: point.x - previousPoint.x, y: point.y - previousPoint.y) + var diff = point - previousPoint if straightCorner.isEmpty || !straightCorner.contains(vertex.count-1) { - target.x += sign(diff.x/beta)*beta - target.y += sign(diff.y/beta)*beta + target += sign(diff / beta) * beta } path.move(to: target) for i in 0.. (Array, Array, Set, Set) { let highlightedPoints, highlightedPoints2: [NSPoint] let rightCorners, rightCorners2: Set @@ -699,4 +725,44 @@ private extension SquirrelView { newRect.origin.y += currentTheme.hilitedCornerRadius + currentTheme.borderWidth return newRect } + + func triangle(center: NSPoint, radius: CGFloat) -> [NSPoint] { + [NSPoint(x: center.x, y: center.y + radius), + NSPoint(x: center.x + 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius), + NSPoint(x: center.x - 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius)] + } + + func pagingLayer(theme: SquirrelTheme, preeditRect: CGRect) -> (CAShapeLayer, CGPath?, CGPath?) { + let layer = CAShapeLayer() + guard theme.showPaging && (canPageUp || canPageDown) else { return (layer, nil, nil) } + guard let firstCandidate = candidateRanges.first, let range = convert(range: firstCandidate) else { return (layer, nil, nil) } + var height = contentRect(range: range).height + let preeditHeight = max(0, preeditRect.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 - theme.edgeInset.height) + theme.edgeInset.height - theme.linespace / 2 + height += theme.linespace + let radius = min(0.5 * theme.pagingOffset, 2 * height / 9) + let effectiveRadius = min(theme.cornerRadius, 0.6 * radius) + guard let trianglePath = drawSmoothLines( + triangle(center: .zero, radius: radius), + straightCorner: [], alpha: 0.3 * effectiveRadius, beta: 1.4 * effectiveRadius + ) else { + return (layer, nil, nil) + } + var downPath: CGPath? + var upPath: CGPath? + if canPageDown { + var downTransform = CGAffineTransform(translationX: 0.5 * theme.pagingOffset, y: 2 * height / 3 + preeditHeight) + let downLayer = shapeFromPath(path: trianglePath.copy(using: &downTransform)) + downLayer.fillColor = theme.backgroundColor.cgColor + downPath = trianglePath.copy(using: &downTransform) + layer.addSublayer(downLayer) + } + if canPageUp { + var upTransform = CGAffineTransform(rotationAngle: .pi).translatedBy(x: -0.5 * theme.pagingOffset, y: -height / 3 - preeditHeight) + let upLayer = shapeFromPath(path: trianglePath.copy(using: &upTransform)) + upLayer.fillColor = theme.backgroundColor.cgColor + upPath = trianglePath.copy(using: &upTransform) + layer.addSublayer(upLayer) + } + return (layer, downPath, upPath) + } }