Skip to content

Commit d896c03

Browse files
committed
Flash menu-bar icon background on successful conversion
1 parent 938fca9 commit d896c03

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

Sources/AppDelegate.swift

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2020
)
2121

2222
textSwitcher = TextSwitcher()
23+
textSwitcher.onConversionSucceeded = { [weak self] in
24+
self?.flashStatusIcon()
25+
}
2326
hotkeyManager = HotkeyManager { [weak self] in
2427
self?.textSwitcher.switchSelectedText()
2528
}
@@ -104,6 +107,159 @@ class AppDelegate: NSObject, NSApplicationDelegate {
104107
return "Горячая клавиша: \(HotkeyDisplayHelper.format(keyCode: keyCode, modifiers: modifiers))"
105108
}
106109

110+
// MARK: - Status icon flash
111+
112+
/// How long the flash image stays on the status button before the
113+
/// original template icon is restored.
114+
private static let flashDuration: TimeInterval = 0.3
115+
116+
/// Flash background color: RGB 255, 150, 0.
117+
private static let flashBackgroundColor = NSColor(
118+
deviceRed: 1.0, green: 150.0 / 255.0, blue: 0.0, alpha: 1.0
119+
)
120+
121+
/// Corner radius (points) of the rounded-square flash fill.
122+
private static let flashCornerRadius: CGFloat = 4
123+
124+
/// Inset (points) between the rounded fill and the icon's edge.
125+
private static let flashInset: CGFloat = 1
126+
127+
/// Restore work currently scheduled, cancelled when a newer flash
128+
/// supersedes it so rapid successive hotkeys never restore early.
129+
private var flashRestoreWork: DispatchWorkItem?
130+
/// The template icon the flash temporarily replaced.
131+
private var flashBaseImage: NSImage?
132+
133+
/// Briefly swap the status-button icon for an orange-background version
134+
/// after a successful conversion, then restore the template icon. Runs on
135+
/// the main thread (the conversion flow completes there).
136+
private func flashStatusIcon() {
137+
guard let button = statusItem?.button, let base = button.image else { return }
138+
flashRestoreWork?.cancel()
139+
flashBaseImage = base
140+
141+
// Defer the swap to the next main-runloop turn so the 300 ms hold
142+
// starts only after the synchronous conversion flow returns and the
143+
// button actually repaints.
144+
DispatchQueue.main.async { [weak self] in
145+
guard let self,
146+
let flash = Self.makeFlashImage(
147+
base: base,
148+
size: NSSize(width: 18, height: 18),
149+
background: Self.flashBackgroundColor,
150+
cornerRadius: Self.flashCornerRadius,
151+
inset: Self.flashInset
152+
)
153+
else { return }
154+
button.image = flash
155+
button.image?.isTemplate = false
156+
157+
let work = DispatchWorkItem { [weak self] in
158+
guard let self, let button = self.statusItem?.button else { return }
159+
if let base = self.flashBaseImage {
160+
button.image = base
161+
button.image?.isTemplate = true
162+
}
163+
self.flashRestoreWork = nil
164+
}
165+
self.flashRestoreWork = work
166+
DispatchQueue.main.asyncAfter(
167+
deadline: .now() + Self.flashDuration,
168+
execute: work
169+
)
170+
}
171+
}
172+
173+
/// Composite a rounded orange square behind a white copy of the template
174+
/// glyph. The result is non-template so it renders identically in both
175+
/// light and dark menu bars. Rendered at 2x pixel scale for retina.
176+
static func makeFlashImage(
177+
base: NSImage,
178+
size: NSSize,
179+
background: NSColor,
180+
cornerRadius: CGFloat,
181+
inset: CGFloat
182+
) -> NSImage? {
183+
guard size.width > 0, size.height > 0 else { return nil }
184+
let pixelsWide = Int(size.width * 2)
185+
let pixelsHigh = Int(size.height * 2)
186+
guard let rep = NSBitmapImageRep(
187+
bitmapDataPlanes: nil,
188+
pixelsWide: pixelsWide,
189+
pixelsHigh: pixelsHigh,
190+
bitsPerSample: 8,
191+
samplesPerPixel: 4,
192+
hasAlpha: true,
193+
isPlanar: false,
194+
colorSpaceName: .deviceRGB,
195+
bytesPerRow: 0,
196+
bitsPerPixel: 0
197+
) else { return nil }
198+
rep.size = size
199+
200+
NSGraphicsContext.saveGraphicsState()
201+
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
202+
NSGraphicsContext.restoreGraphicsState()
203+
return nil
204+
}
205+
NSGraphicsContext.current = context
206+
207+
let rect = NSRect(origin: .zero, size: size)
208+
let fillRect = rect.insetBy(dx: inset, dy: inset)
209+
210+
// Tint the glyph white on a transparent canvas first, so the white
211+
// never bleeds into the orange fill (sourceAtop over the opaque
212+
// background would whiten the whole square).
213+
let glyph = Self.whiteGlyph(from: base, in: fillRect)
214+
215+
let rounded = NSBezierPath(
216+
roundedRect: fillRect,
217+
xRadius: cornerRadius,
218+
yRadius: cornerRadius
219+
)
220+
background.setFill()
221+
rounded.fill()
222+
223+
glyph.draw(in: fillRect)
224+
225+
NSGraphicsContext.restoreGraphicsState()
226+
227+
let image = NSImage(size: size)
228+
image.addRepresentation(rep)
229+
image.isTemplate = false
230+
return image
231+
}
232+
233+
/// Render `base` as a white glyph on a transparent canvas sized to `rect`.
234+
private static func whiteGlyph(from base: NSImage, in rect: NSRect) -> NSImage {
235+
let pixelsWide = Int(rect.width * 2)
236+
let pixelsHigh = Int(rect.height * 2)
237+
let rep = NSBitmapImageRep(
238+
bitmapDataPlanes: nil,
239+
pixelsWide: pixelsWide,
240+
pixelsHigh: pixelsHigh,
241+
bitsPerSample: 8,
242+
samplesPerPixel: 4,
243+
hasAlpha: true,
244+
isPlanar: false,
245+
colorSpaceName: .deviceRGB,
246+
bytesPerRow: 0,
247+
bitsPerPixel: 0
248+
)!
249+
rep.size = rect.size
250+
251+
NSGraphicsContext.saveGraphicsState()
252+
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
253+
base.draw(in: NSRect(origin: .zero, size: rect.size))
254+
NSColor.white.set()
255+
NSRect(origin: .zero, size: rect.size).fill(using: .sourceAtop)
256+
NSGraphicsContext.restoreGraphicsState()
257+
258+
let image = NSImage(size: rect.size)
259+
image.addRepresentation(rep)
260+
return image
261+
}
262+
107263
// MARK: - Helpers
108264

109265
private func activateApp() {

Sources/TextSwitcher.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import Carbon
33

44
class TextSwitcher {
55

6+
/// Invoked on the main thread after every successfully typed conversion;
7+
/// failure paths never reach it, so their feedback is unchanged.
8+
var onConversionSucceeded: (() -> Void)?
9+
610
// MARK: - Tunables
711

812
/// Maximum time to wait for the focused app to fill the pasteboard after
@@ -380,7 +384,12 @@ class TextSwitcher {
380384
Self.injectUnicode(converted)
381385
}
382386
Self.diag("injection done")
387+
finishConversion(targetLayout: targetLayout)
388+
}
383389

390+
/// Post-injection success tail: report success + optional layout switch.
391+
private func finishConversion(targetLayout: LayoutInfo?) {
392+
onConversionSucceeded?()
384393
if UserDefaults.standard.switchLayoutAfterConversion, let targetLayout {
385394
InputSourceSwitcher.switchTo(target: targetLayout)
386395
}

Tests/StatusIconFlashTests.swift

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import XCTest
2+
import AppKit
3+
4+
final class StatusIconFlashTests: XCTestCase {
5+
6+
/// A minimal template glyph shaped like the real menu-bar icon: a
7+
/// horizontal black band in the middle of a 32×32 transparent canvas.
8+
private func makeTemplateBase() -> NSImage {
9+
let image = NSImage(size: NSSize(width: 32, height: 32))
10+
image.isTemplate = true
11+
image.lockFocus()
12+
NSColor.black.setFill()
13+
NSRect(x: 4, y: 12, width: 24, height: 8).fill()
14+
image.unlockFocus()
15+
return image
16+
}
17+
18+
private func makeFlash(size: NSSize = NSSize(width: 18, height: 18)) -> NSImage? {
19+
AppDelegate.makeFlashImage(
20+
base: makeTemplateBase(),
21+
size: size,
22+
background: NSColor(
23+
deviceRed: 1.0, green: 150.0 / 255.0, blue: 0.0, alpha: 1.0
24+
),
25+
cornerRadius: 4,
26+
inset: 1
27+
)
28+
}
29+
30+
/// Sample a color at a point in points (the rep is rendered at 2x).
31+
private func pixel(_ image: NSImage, point: CGPoint) -> NSColor? {
32+
guard let rep = image.representations.first as? NSBitmapImageRep else { return nil }
33+
let x = Int(point.x * 2)
34+
let y = Int(point.y * 2)
35+
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return nil }
36+
return rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB)
37+
}
38+
39+
private func isClose(_ value: CGFloat, to expected: CGFloat, tolerance: CGFloat = 0.05) -> Bool {
40+
abs(value - expected) <= tolerance
41+
}
42+
43+
// MARK: - Basic properties
44+
45+
func testMakeFlash_ReturnsNonNilForValidInput() {
46+
XCTAssertNotNil(makeFlash())
47+
}
48+
49+
func testMakeFlash_HasRequestedPointSize() {
50+
let image = makeFlash()
51+
XCTAssertEqual(image?.size.width, 18)
52+
XCTAssertEqual(image?.size.height, 18)
53+
}
54+
55+
func testMakeFlash_IsNotATemplate() {
56+
XCTAssertEqual(makeFlash()?.isTemplate, false)
57+
}
58+
59+
func testMakeFlash_ReturnsNilForZeroSize() {
60+
XCTAssertNil(makeFlash(size: .zero))
61+
}
62+
63+
func testMakeFlash_ReturnsNilForNegativeSize() {
64+
XCTAssertNil(makeFlash(size: NSSize(width: -18, height: 18)))
65+
}
66+
67+
// MARK: - Pixel colors
68+
69+
func testMakeFlash_FillIsOrange() {
70+
guard let image = makeFlash() else { return XCTFail("flash image nil") }
71+
// Top-center: inside the rounded fill (inset 1, starts at y=1) but
72+
// above the glyph band (which starts near y=7).
73+
guard let color = pixel(image, point: CGPoint(x: 9, y: 2.5)) else {
74+
return XCTFail("no pixel data")
75+
}
76+
XCTAssertTrue(isClose(color.redComponent, to: 1.0), "red was \(color.redComponent)")
77+
XCTAssertTrue(isClose(color.greenComponent, to: 150.0 / 255.0), "green was \(color.greenComponent)")
78+
XCTAssertTrue(isClose(color.blueComponent, to: 0.0), "blue was \(color.blueComponent)")
79+
XCTAssertTrue(isClose(color.alphaComponent, to: 1.0), "alpha was \(color.alphaComponent)")
80+
}
81+
82+
func testMakeFlash_GlyphIsWhite() {
83+
guard let image = makeFlash() else { return XCTFail("flash image nil") }
84+
// Center: inside the glyph band (y 7...11).
85+
guard let color = pixel(image, point: CGPoint(x: 9, y: 9)) else {
86+
return XCTFail("no pixel data")
87+
}
88+
XCTAssertTrue(isClose(color.redComponent, to: 1.0), "red was \(color.redComponent)")
89+
XCTAssertTrue(isClose(color.greenComponent, to: 1.0), "green was \(color.greenComponent)")
90+
XCTAssertTrue(isClose(color.blueComponent, to: 1.0), "blue was \(color.blueComponent)")
91+
XCTAssertTrue(isClose(color.alphaComponent, to: 1.0), "alpha was \(color.alphaComponent)")
92+
}
93+
94+
func testMakeFlash_RoundedCornersAreTransparent() {
95+
guard let image = makeFlash() else { return XCTFail("flash image nil") }
96+
// The rounded corner cuts the 1×1 image corner away from the fill.
97+
guard let color = pixel(image, point: CGPoint(x: 1, y: 1)) else {
98+
return XCTFail("no pixel data")
99+
}
100+
XCTAssertTrue(color.alphaComponent < 0.05, "alpha was \(color.alphaComponent)")
101+
}
102+
}

0 commit comments

Comments
 (0)