From 6f7b6d7951f8f8f1379d39b11161e51cc4be55e0 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Wed, 1 Jul 2026 05:45:45 -0400 Subject: [PATCH 1/2] fix(menubar): realign tray icon geometry The old tray glyph placed a lock badge outside the keyboard shape, which pulled the optical center low in the macOS menu bar. Replace it with a centered keyhole panel so both states share the same frame and alignment. Update the generator to write directly into the asset catalog and regenerate the template PNGs from that source of truth. Closes #47 Signed-off-by: Kevin Cui --- .../TrayLocked.imageset/tray-locked.png | Bin 516 -> 438 bytes .../TrayLocked.imageset/tray-locked@2x.png | Bin 929 -> 843 bytes .../TrayUnlocked.imageset/tray-unlocked.png | Bin 512 -> 455 bytes .../tray-unlocked@2x.png | Bin 936 -> 860 bytes Sources/LockIME/LockIMEApp.swift | 5 +- scripts/icon-tools/MakeTrayIcon.swift | 185 +++++++++++------- 6 files changed, 114 insertions(+), 76 deletions(-) diff --git a/Sources/LockIME/Assets.xcassets/TrayLocked.imageset/tray-locked.png b/Sources/LockIME/Assets.xcassets/TrayLocked.imageset/tray-locked.png index f20c87787e4988014f23a528839fe09cb3310166..6d7aecc028ed385702c4ae54e94909a539776d91 100644 GIT binary patch delta 330 zcmV-Q0k!^w1hxZ^b$={LL_t(I5zUi3N<%>yM#pDoC5m7n77|3kMq7J9#6m$_iA%5x zxB@|N4|Xv&f|Zqsf?(xi1L6Zc=lYM_Tyhi3d~oi3|C51CQW`W3l`gc7KY#@oE9JY{ z50ZCq2yGkkq80oVjKUMVLoba}oPkeRgKH?%=*!Bz#cWH$Vz8eXA8`9{7WWxNk9*alzlWS}?K0(+{nq*6Kp zmu(tUbV@8$*+c7$vw!lL``qOgE+Id{W4tJH*6hjstN0FW5M6-(wMSS4H4*5|S+l1N c&J3u20FW0s6Y=yw1poj507*qoM6N<$g6lh#vH$=8 delta 409 zcmV;K0cQTT1B3*Sb$@zEL_t(I5tY+TD@0Kk!13``7?G98VzQv*Wr2mV7#1un*;q(p zW1;u}qWK0atSDACma?;ylAV;LB0@=GWa)*AXn#BLi3S8-Xv8GqJkzAF zqh7233{=u>E%H#4V=ffN4ifPEr3^7wGOW=iV%WnYO2r!!&qXF33tM@2a)2e23yVFA z#_WUNw8vQW-lN1Zfg{vm0lP5Pv+S>-7nWoSzPUN1f)+bMl5egC%jiZA(vjPdHxA|| zhUs@WNJP;BOMl@`+_lG?L>#V>KbrlMEc>_EhHuqctY82pxtO)GzhztaWzRF1izuB} zB+-u>_$3eG3YNu%XOOSLi!it1zlg^vlwiz_gnt2YWD;AQ!T-ZG00000NkvXXu0mjf DG{eR_ diff --git a/Sources/LockIME/Assets.xcassets/TrayLocked.imageset/tray-locked@2x.png b/Sources/LockIME/Assets.xcassets/TrayLocked.imageset/tray-locked@2x.png index b71eb6c24d202d0e2ca82ff7002de8b09105b138..b4c8806d9882229e9f4e0d0417f6190f575e6825 100644 GIT binary patch delta 739 zcmV<90v!FJ2g?SKbbkWDNkl?sM)lXU=&ho9AEuxnA$C);(wbFJCyN2BzZ1*U-ju# zi;arD7w2*iUz=X^ED#^V6-7=J(JcEZeNtqr`vH!Am$<+cyDI&(vkP^xv?odeXau5qT^LdAyX-KL@paFM8*gXNu$+r=vcO?0%Y0qGCnw`7W@?>8QUV zJ7sQD;7ADGlz)7dN_2jnO^>*D9y_7GejA7ViiCbfeg?O7==f#=wQUWO-Z}h{nXjo_ zuf#FY?=m_*Vy)0+po8kTu%atIVy)0+pdU3I>tZ#_vaHKM6RP~NqB=fet$;EopYYIz_<`a#u*JS>^(Aq8s1hH^Ci%xhY*g}f z&=F(e?tfrEm82H1+C8iHWomjM4@mQGVEd#f5)uk|wyw<@*3!F#W%B$Mt@v%}OG{a0 z$7~0pCbg{(J?WjpZyneD4e?knlwWvk|6S2O@+YNV5Sw0nT$1NRKI{P>`Fq8c7y;KM zldqa#1!->6SIP6CkcoXRAKio8sv72_7d>mie_ZfJv_-MJSh1Qx;$cW5H(qZxJ?J$J zL({B(23vhnJS1)vmy6_bWM@V0pC(wVBz~bgMF#mIz7QwGYhu%*SLy(R9cZ=#e*vfA V)G9L85bgi~002ovPDHLkV1jzVXdwUq delta 826 zcmV-A1I7Hy2B8O#bbkXENkl|? z3zISn*(e(;MzUZdY!povv%}5~rDQ27iBhB}7Si}Pp5Jwz&+D6er~BRc?#Eltec$t* zbKdjJd*1Jy@0(e*>ZGovb_rXBdBSU9La1DNwc=&rhX4WNiGS>kLgjK(E1E=Je^6h& zN=DlPnfJ1Xh26s4;J+@rGC8M}oRytnRkra}>iZb{wVAQe1$#w^c`w-GvLB`PLTQS; zA>AevE!GDkYNkIq_Dhc?y9~8fL8!}Chu~-R zT@^+JZBY({eUQcaQuW%v(+rq`VUTD;X zoh|ew`OPCUa9Cc*pbg_?$q7l0dHna24+R+q!?rw-fuFkcmMxb3P?+SzZ9EmLP4;?^U**^$aGn&|vUmAFxpw@-hD+C8&~oceYLXdV20132p%F&$7{3ipcUG<8 z05g|{4Cwb?l45N*{3WpC{d&5eLG5OicP-W$5wgtu0^`?L^rLI3~& diff --git a/Sources/LockIME/Assets.xcassets/TrayUnlocked.imageset/tray-unlocked.png b/Sources/LockIME/Assets.xcassets/TrayUnlocked.imageset/tray-unlocked.png index 0338abf760499f4fba619b6acb82063990f0b4c3..1bd1cf29e53645e833d33778241158ea07b5eec4 100644 GIT binary patch delta 347 zcmV-h0i^zb1jhrAb$>lcL_t(I5zUglN&`U@M#o>Uuu&AjO2k4GY_zo(EG!fxkKhyd z2zH`{wUyXg*~QohR#qY+f>qEL5P#5fHZ#lak~Nn3;LLpY&b^n}WJ2gS4HPc4j^Bp` z7%Akt*-w&>Z~!d}(x4UmEet~&KHx8naySEDum*RK>5%mYwtqX}zt)}DV{aIYEwgdY zt{HvW<}gy#RZL{Peu_#1zdK>R?&9WS^u&IipM;QVv)(kQa9*xn#cqAA(r;5&u*|_kWT~#me775!n}S!khj&oVFa*=^1|_8%`G%Bn z1Ddb}zTnA--c(!@tf}^r3gH+WwrNn&8L?Dltv&YU@|pYGLT tC2bI0LmOUT5!84@Z_YDoT3}C~>KEH0IFDMyFR}mt002ovPDHLkV1oXTrQHAk delta 405 zcmV;G0c!ro1AqjOb$@nAL_t(I5tY-=D+FN}!147fvInIoa^OM{4!AFgiyIdQX5kH{79q1zHm4< zXYrhnMx_suA^*Zfmf+l~e-V#UDof6dB!2-^Dq^Cbw+{Py00000NkvXXu0mjfF6YA^ diff --git a/Sources/LockIME/Assets.xcassets/TrayUnlocked.imageset/tray-unlocked@2x.png b/Sources/LockIME/Assets.xcassets/TrayUnlocked.imageset/tray-unlocked@2x.png index 8d1f80064260219f48ebac9eebd0a81f9ec77ef1..e6302dcdbae022ed639a7c046f3bf37eb351854e 100644 GIT binary patch delta 756 zcmVUw zrnD8b^G^_KZ2~4(Ss5&Xf{29(TKIsVD2fU`z($4m1pR({&#aT(yPLhc8*J_e-<>%# zb7tnAvwO})Q5W46sJ8+=In%KZiHF3^;tDY@R<2U`qo~i~D}V8ZI4NSA726c?NF;bJ z`B3?(RfrApeh}xf5uck{)J%}xhRgDtNW4+>Q~H$1Qa1s%euucgB^#4|(v1^!k+dJ8 z{R5VCZQ^ZCAt$-54mGKr!7f=W2zfLU(VT(Yz81A(%rixDwbN0brp8SUpQVx|wdXs> zLZ_qtN{#b|eSdNc2j_Lk7kPos&a>%})b=an1Ip?n3Vb3S2t*$d8^JMU@%!38OV)Mr zPmB9q96C09MIg7YMeP`MR%o9xJQi=YQpLaM*zi?Corb=v;?g!%vEi$PIt|@aMc$6n zt};3{e3jtevQ86x)r7<1Rq_+zuV| zNn-mi&k#NA6d#}jpku3yx+}yT#5TPS9c+?h`|gBs$D(dSqpHa1>~Y@@I@QZI#L2rp zBxf}~1^uHW_g5ZP$-%0DcjEh4EBGnX&|=N!!9@a+T63|X;D=M&u90c!y&^IfzM=FD zY`$-reSb;Z8nUFfWTWi51Gc&0l!KiKkd0&gNB_ z!(3`-uuPWUq9x}|S(1s$8ai!=oaDAT)TDL{zjPe;6XKDUE7~K;^4}BfBY#Tzd9m!l z$02z}qEL(O;TfQQXOf?|F@H&J6Q+$WDw0*^l#9As z@~<#=)ajFpcSBIB)R~L6RpVnqM^2w~ct_r;&`+VOOr)7*j7!+w3%2+_Q(|2*)A{oJ zPOyD65F2SmtYI5=Brz7&eHK40d<|n`UnJ1pkETe?pDKV=sTF@Mn`NmJ!T&0uRT6pq zSxYF@rpEtb3x5*9GuxC2MdUHj#3!^!({Yt)gY!@<)4VvnRFz+kqHHSq}7j(h+PxJv!um z#_F36EK|5AlHDKR6X)*ZZ;Wq^hP4gCabYC`M}NB{Q>hN8n=AQ<&>wVJ4*%VzE_)7L z)i0Jn_D%I~P;6y+YZ0q1WR>|1!IN4)B(2l=00000 LNkvXXu0mjfCqt9# diff --git a/Sources/LockIME/LockIMEApp.swift b/Sources/LockIME/LockIMEApp.swift index a09a7a1..e25a2b2 100644 --- a/Sources/LockIME/LockIMEApp.swift +++ b/Sources/LockIME/LockIMEApp.swift @@ -28,9 +28,8 @@ struct LockIMEApp: App { MenuBarView() .localized(with: appState) } label: { - // The mascot is the state: hugging the keyboard = locked, - // snacking on bamboo = unlocked. Monochrome template glyphs so the - // system supplies the menu-bar tint (light/dark/active). + // Centered monochrome template glyphs let the system supply the + // menu-bar tint for light, dark, and active states. Image(appState.isLocked ? "TrayLocked" : "TrayUnlocked") .background(SettingsActionBridge(appState: appState)) } diff --git a/scripts/icon-tools/MakeTrayIcon.swift b/scripts/icon-tools/MakeTrayIcon.swift index ebff617..2c76c31 100644 --- a/scripts/icon-tools/MakeTrayIcon.swift +++ b/scripts/icon-tools/MakeTrayIcon.swift @@ -1,103 +1,142 @@ -// MakeTrayIcon.swift — renders LockIME's menu-bar (tray) template glyphs: -// a keyboard with a small padlock badge at the top-right corner. Locked shows -// a closed shackle; unlocked pops the shackle open. Drawn vector-crisp with -// CoreGraphics at 1x/2x — solid black + alpha, for template rendering. +// MakeTrayIcon.swift - renders LockIME's menu-bar (tray) template glyphs: +// a centered rounded input panel with a keyhole symbol. Both states share the +// same panel geometry; locked uses a filled keyhole, while unlocked uses an +// outlined keyhole. Drawn vector-crisp with CoreGraphics at 1x/2x as solid +// black + alpha for template rendering. // // swift scripts/icon-tools/MakeTrayIcon.swift -// → tray-locked.png/tray-locked@2x.png/tray-unlocked.png/tray-unlocked@2x.png +// -> tray-locked.png/tray-locked@2x.png/tray-unlocked.png/tray-unlocked@2x.png +// +// If is an asset catalog containing TrayLocked.imageset and +// TrayUnlocked.imageset, files are written directly into those imagesets. import AppKit import CoreGraphics -let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "." -let pt: CGFloat = 18 +let outputPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "." +let outRoot = URL(fileURLWithPath: outputPath, isDirectory: true) +let pointSize: CGFloat = 18 + +struct TrayState { + let locked: Bool + let baseName: String + let imagesetName: String +} + +let states = [ + TrayState(locked: true, baseName: "tray-locked", imagesetName: "TrayLocked.imageset"), + TrayState(locked: false, baseName: "tray-unlocked", imagesetName: "TrayUnlocked.imageset"), +] + +func outputURL(for state: TrayState, scale: CGFloat) -> URL { + let imageset = outRoot.appendingPathComponent(state.imagesetName, isDirectory: true) + let targetDir = FileManager.default.fileExists(atPath: imageset.path) ? imageset : outRoot + let suffix = scale == 2 ? "@2x" : "" + return targetDir.appendingPathComponent("\(state.baseName)\(suffix).png") +} + +func keyholePath(inset: CGFloat = 0) -> CGPath { + let path = CGMutablePath() + let centerX: CGFloat = 9 + let circleRadius = max(0.55, 1.7 - inset) + let circleCenterY: CGFloat = 10.55 + let stemTopY: CGFloat = 9.0 - inset * 0.3 + let stemBottomY: CGFloat = 6.85 + inset * 0.85 + let stemTopHalfWidth = max(0.35, 0.82 - inset * 0.28) + let stemBottomHalfWidth = max(0.5, 1.28 - inset * 0.6) + + path.addEllipse(in: CGRect( + x: centerX - circleRadius, + y: circleCenterY - circleRadius, + width: circleRadius * 2, + height: circleRadius * 2 + )) + + path.move(to: CGPoint(x: centerX - stemTopHalfWidth, y: stemTopY)) + path.addLine(to: CGPoint(x: centerX + stemTopHalfWidth, y: stemTopY)) + path.addLine(to: CGPoint(x: centerX + stemBottomHalfWidth, y: stemBottomY + 0.25)) + path.addQuadCurve( + to: CGPoint(x: centerX + stemBottomHalfWidth - 0.35, y: stemBottomY), + control: CGPoint(x: centerX + stemBottomHalfWidth, y: stemBottomY) + ) + path.addLine(to: CGPoint(x: centerX - stemBottomHalfWidth + 0.35, y: stemBottomY)) + path.addQuadCurve( + to: CGPoint(x: centerX - stemBottomHalfWidth, y: stemBottomY + 0.25), + control: CGPoint(x: centerX - stemBottomHalfWidth, y: stemBottomY) + ) + path.closeSubpath() + return path +} + +func drawPanel(in ctx: CGContext) { + ctx.setLineWidth(1.75) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + + let left: CGFloat = 2.2 + let right: CGFloat = 15.8 + let bottom: CGFloat = 2.95 + let top: CGFloat = 15.0 + let radius: CGFloat = 2.45 + + let panel = CGMutablePath() + panel.move(to: CGPoint(x: 5.45, y: bottom)) + panel.addLine(to: CGPoint(x: left + radius, y: bottom)) + panel.addQuadCurve(to: CGPoint(x: left, y: bottom + radius), control: CGPoint(x: left, y: bottom)) + panel.addLine(to: CGPoint(x: left, y: top - radius)) + panel.addQuadCurve(to: CGPoint(x: left + radius, y: top), control: CGPoint(x: left, y: top)) + panel.addLine(to: CGPoint(x: right - radius, y: top)) + panel.addQuadCurve(to: CGPoint(x: right, y: top - radius), control: CGPoint(x: right, y: top)) + panel.addLine(to: CGPoint(x: right, y: bottom + radius)) + panel.addQuadCurve(to: CGPoint(x: right - radius, y: bottom), control: CGPoint(x: right, y: bottom)) + panel.addLine(to: CGPoint(x: 12.55, y: bottom)) + ctx.addPath(panel) + ctx.strokePath() + + let keyCorner: CGFloat = 0.48 + for key in [ + CGRect(x: 4.45, y: 5.15, width: 2.05, height: 0.95), + CGRect(x: 11.5, y: 5.15, width: 2.05, height: 0.95), + CGRect(x: 8.35, y: 2.85, width: 1.3, height: 2.6), + ] { + ctx.addPath(CGPath(roundedRect: key, cornerWidth: keyCorner, cornerHeight: keyCorner, transform: nil)) + ctx.fillPath() + } +} func draw(locked: Bool, scale: CGFloat) -> CGImage? { - let px = Int(pt * scale) + let px = Int(pointSize * scale) guard let ctx = CGContext( data: nil, width: px, height: px, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } ctx.scaleBy(x: scale, y: scale) - ctx.setFillColor(.black) ctx.setStrokeColor(.black) + ctx.setFillColor(.black) - // — Keyboard: a wide rounded rect in the lower ~2/3, outline + key dots. - let kb = CGRect(x: 0.75, y: 0.75, width: 16.5, height: 10.5) - ctx.setLineWidth(1.5) - ctx.addPath(CGPath(roundedRect: kb, cornerWidth: 2.1, cornerHeight: 2.1, transform: nil)) - ctx.strokePath() - - // Key dots: two rows of five. - let dotR: CGFloat = 0.8 - for (rowIdx, y) in [CGFloat(8.0), 5.55].enumerated() { - for i in 0..<5 { - let x = 3.3 + CGFloat(i) * 2.85 + (rowIdx == 1 ? 0.7 : 0) - ctx.fillEllipse(in: CGRect(x: x - dotR, y: y - dotR, width: dotR * 2, height: dotR * 2)) - } - } - // Space bar. - let space = CGRect(x: 5.4, y: 2.45, width: 7.2, height: 1.5) - ctx.addPath(CGPath(roundedRect: space, cornerWidth: 0.75, cornerHeight: 0.75, transform: nil)) + drawPanel(in: ctx) + ctx.addPath(keyholePath()) ctx.fillPath() + if locked { + return ctx.makeImage() + } - // — Badge knockout: clear a halo behind the padlock so it reads as a badge. - let bodyRect = CGRect(x: 11.4, y: 9.6, width: 5.4, height: 4.6) - let badgeBounds = CGRect(x: 10.2, y: 8.4, width: 7.8, height: 9.4) ctx.setBlendMode(.clear) - ctx.addPath(CGPath(roundedRect: badgeBounds, cornerWidth: 2.6, cornerHeight: 2.6, transform: nil)) + ctx.addPath(keyholePath(inset: 0.55)) ctx.fillPath() ctx.setBlendMode(.normal) - // — Padlock body. - ctx.addPath(CGPath(roundedRect: bodyRect, cornerWidth: 1.2, cornerHeight: 1.2, transform: nil)) - ctx.fillPath() - - // — Shackle: closed = ∩ arch seated on the body; open = right leg free, - // arch swung up-right leaving a clear gap. - ctx.setLineWidth(1.35) - ctx.setLineCap(.round) - let cx = bodyRect.midX - let r: CGFloat = 1.75 - let bodyTop = bodyRect.maxY - if locked { - let archY = bodyTop + 1.1 - ctx.beginPath() - ctx.move(to: CGPoint(x: cx - r, y: bodyTop - 0.4)) - ctx.addLine(to: CGPoint(x: cx - r, y: archY)) - ctx.addArc(center: CGPoint(x: cx, y: archY), radius: r, - startAngle: .pi, endAngle: 0, clockwise: true) - ctx.addLine(to: CGPoint(x: cx + r, y: bodyTop - 0.4)) - ctx.strokePath() - } else { - // Hinged at the left leg, swung open ~35° to the right. - let archY = bodyTop + 1.1 - ctx.saveGState() - ctx.translateBy(x: cx - r, y: bodyTop - 0.2) - ctx.rotate(by: 35 * .pi / 180) - ctx.translateBy(x: -(cx - r), y: -(bodyTop - 0.2)) - ctx.beginPath() - ctx.move(to: CGPoint(x: cx - r, y: bodyTop - 0.4)) - ctx.addLine(to: CGPoint(x: cx - r, y: archY)) - ctx.addArc(center: CGPoint(x: cx, y: archY), radius: r, - startAngle: .pi, endAngle: 0, clockwise: true) - ctx.addLine(to: CGPoint(x: cx + r, y: archY - 0.2)) - ctx.strokePath() - ctx.restoreGState() - } - return ctx.makeImage() } -for locked in [true, false] { +for state in states { for scale in [CGFloat(1), 2] { - guard let img = draw(locked: locked, scale: scale) else { continue } + guard let img = draw(locked: state.locked, scale: scale) else { continue } let rep = NSBitmapImageRep(cgImage: img) guard let png = rep.representation(using: .png, properties: [:]) else { continue } - let name = "tray-\(locked ? "locked" : "unlocked")\(scale == 2 ? "@2x" : "").png" - let path = "\(outDir)/\(name)" - try? png.write(to: URL(fileURLWithPath: path)) - print("wrote \(path)") + let url = outputURL(for: state, scale: scale) + try? png.write(to: url) + print("wrote \(url.path)") } } From 1d833c7e584bcf60e94cd5ce4521306607fa4cac Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Wed, 1 Jul 2026 05:59:43 -0400 Subject: [PATCH 2/2] fix(menubar): report tray icon write failures Signed-off-by: Kevin Cui --- scripts/icon-tools/MakeTrayIcon.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/icon-tools/MakeTrayIcon.swift b/scripts/icon-tools/MakeTrayIcon.swift index 2c76c31..652c02e 100644 --- a/scripts/icon-tools/MakeTrayIcon.swift +++ b/scripts/icon-tools/MakeTrayIcon.swift @@ -136,7 +136,12 @@ for state in states { let rep = NSBitmapImageRep(cgImage: img) guard let png = rep.representation(using: .png, properties: [:]) else { continue } let url = outputURL(for: state, scale: scale) - try? png.write(to: url) - print("wrote \(url.path)") + do { + try png.write(to: url) + print("wrote \(url.path)") + } catch { + FileHandle.standardError.write(Data("failed to write \(url.path): \(error)\n".utf8)) + exit(1) + } } }