@@ -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( ) {
0 commit comments