- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高 - 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现 - 设置页应用信息补「构建」一行,版本号全程不带括号 - 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带 - 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾 - 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
247 lines
8.4 KiB
Swift
247 lines
8.4 KiB
Swift
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
|
|
// Usage: swift Design/generate_app_icon.swift <output.png>
|
|
//
|
|
// The mark is the web favicon grown up: a flat gold SIM plate with three
|
|
// ascending slots cut out of it, on a neutral graphite field. Two colours, one
|
|
// light source from the upper left, no metal texture, so it still reads at the
|
|
// 29pt settings size. The plate keeps the classic single SIM cut, top-left.
|
|
//
|
|
// The field is warm-neutral, not mathematically neutral. A perfectly grey R=G=B
|
|
// plate sitting next to saturated gold loses the comparison and reads cool on a
|
|
// lit home screen — that is how "there is no blue in it" still looked "blue" to
|
|
// the eye. Red runs 2..4 levels over blue instead, which is invisible as a cast
|
|
// but impossible to misread. The gold stays warm and deep so it reads metal
|
|
// rather than mustard.
|
|
//
|
|
// Every coordinate is in visual space (origin top-left, y grows down).
|
|
import AppKit
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
let side: CGFloat = 1024
|
|
let output = CommandLine.arguments.count > 1
|
|
? CommandLine.arguments[1]
|
|
: "Design/AppIcon.png"
|
|
|
|
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
|
|
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
|
|
return CGFloat(fallback)
|
|
}
|
|
return CGFloat(value)
|
|
}
|
|
|
|
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
|
|
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
|
|
}
|
|
|
|
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
|
|
let ctx = CGContext(
|
|
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
|
|
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
|
else {
|
|
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
|
|
ctx.translateBy(x: 0, y: side)
|
|
ctx.scaleBy(x: 1, y: -1)
|
|
|
|
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
|
|
|
|
func makeGradient(_ stops: [Stop]) -> CGGradient? {
|
|
var components: [CGFloat] = []
|
|
for stop in stops {
|
|
components.append(CGFloat(stop.color.0) / 255)
|
|
components.append(CGFloat(stop.color.1) / 255)
|
|
components.append(CGFloat(stop.color.2) / 255)
|
|
components.append(CGFloat(stop.color.3) / 255)
|
|
}
|
|
let locations = stops.map(\.location)
|
|
return CGGradient(
|
|
colorSpace: space,
|
|
colorComponents: components,
|
|
locations: locations,
|
|
count: stops.count
|
|
)
|
|
}
|
|
|
|
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
|
|
guard let gradient = makeGradient(stops) else { return }
|
|
ctx.saveGState()
|
|
ctx.addPath(path)
|
|
ctx.clip()
|
|
ctx.drawLinearGradient(
|
|
gradient, start: start, end: end,
|
|
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
|
|
)
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
|
|
guard let gradient = makeGradient(stops) else { return }
|
|
ctx.saveGState()
|
|
ctx.drawRadialGradient(
|
|
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
|
|
options: [.drawsAfterEndLocation]
|
|
)
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
|
|
|
|
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
|
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
|
}
|
|
|
|
/// SIM plate outline: rounded square with the top-left corner cut at 45 degrees.
|
|
func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
|
|
let bevel = corner * 0.26
|
|
let vertices: [(point: CGPoint, radius: CGFloat)] = [
|
|
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
|
|
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
|
|
(CGPoint(x: rect.maxX, y: rect.minY), corner),
|
|
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
|
|
(CGPoint(x: rect.minX, y: rect.maxY), corner),
|
|
]
|
|
let path = CGMutablePath()
|
|
path.move(to: vertices[0].point)
|
|
for index in vertices.indices {
|
|
path.addArc(
|
|
tangent1End: vertices[index].point,
|
|
tangent2End: vertices[(index + 1) % vertices.count].point,
|
|
radius: vertices[index].radius
|
|
)
|
|
}
|
|
path.closeSubpath()
|
|
return path
|
|
}
|
|
|
|
/// Bright along the upper-left edges, gone by the lower-right: an even outline
|
|
/// reads like a sticker cut rather than a plate lit from above.
|
|
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
|
|
ctx.saveGState()
|
|
ctx.addPath(path)
|
|
ctx.clip()
|
|
ctx.addPath(path)
|
|
ctx.setLineWidth(width)
|
|
ctx.replacePathWithStrokedPath()
|
|
ctx.clip()
|
|
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
|
|
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
// MARK: - Field
|
|
|
|
fillLinear(
|
|
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
|
|
[
|
|
// 石墨底走暖一档:R 比 B 高 4 级,肉眼看不出偏色,但再也不可能被读成冷调。
|
|
(0.00, (34, 32, 30, 255)),
|
|
(0.55, (20, 19, 18, 255)),
|
|
(1.00, (11, 10, 10, 255)),
|
|
] as [Stop],
|
|
CGPoint(x: side * 0.06, y: 0),
|
|
CGPoint(x: side * 0.94, y: side)
|
|
)
|
|
fillRadial(
|
|
CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86),
|
|
[(0.0, (232, 226, 214, 24)), (0.60, (232, 226, 214, 6)), (1.0, (232, 226, 214, 0))] as [Stop]
|
|
)
|
|
|
|
// MARK: - Plate
|
|
|
|
let plateSide = envDouble("ICON_PLATE_SIDE", 592)
|
|
let plateCenterY = envDouble("ICON_PLATE_Y", 500)
|
|
let plateCorner = envDouble("ICON_PLATE_CORNER", 122)
|
|
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168)
|
|
let plateRect = CGRect(
|
|
x: (side - plateSide) / 2,
|
|
y: plateCenterY - plateSide / 2,
|
|
width: plateSide,
|
|
height: plateSide
|
|
)
|
|
let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer)
|
|
|
|
ctx.saveGState()
|
|
ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(2, 1, 0, 150))
|
|
ctx.addPath(plate)
|
|
ctx.setFillColor(rgb(0, 0, 0))
|
|
ctx.fillPath()
|
|
ctx.restoreGState()
|
|
|
|
fillLinear(
|
|
plate,
|
|
[
|
|
// 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。
|
|
(0.00, (252, 229, 170, 255)),
|
|
(0.46, (240, 199, 111, 255)),
|
|
(1.00, (211, 155, 61, 255)),
|
|
] as [Stop],
|
|
CGPoint(x: plateRect.minX, y: plateRect.minY),
|
|
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
|
|
)
|
|
directionalRim(
|
|
plate, plateRect, plateSide * 0.007,
|
|
[
|
|
(0.00, (255, 246, 224, 140)),
|
|
(0.36, (255, 240, 206, 32)),
|
|
(0.68, (255, 236, 196, 0)),
|
|
] as [Stop]
|
|
)
|
|
|
|
// MARK: - Slots
|
|
|
|
let slotWidth = envDouble("ICON_SLOT_W", 92)
|
|
let slotGap = envDouble("ICON_SLOT_GAP", 54)
|
|
let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640)
|
|
let slotHeights: [CGFloat] = [136, 232, 328]
|
|
// 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265),
|
|
// 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。
|
|
let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap
|
|
let slotsStartX = (side - slotsWidth) / 2
|
|
|
|
for (index, height) in slotHeights.enumerated() {
|
|
let rect = CGRect(
|
|
x: slotsStartX + CGFloat(index) * (slotWidth + slotGap),
|
|
y: slotBottom - height,
|
|
width: slotWidth,
|
|
height: height
|
|
)
|
|
let radius = slotWidth * 0.30
|
|
let path = roundedPath(rect, radius)
|
|
|
|
fillLinear(
|
|
path,
|
|
[
|
|
// 插槽与底同色温,避免金面板上的洞看起来发青。
|
|
(0.00, (17, 16, 14, 255)),
|
|
(1.00, (34, 32, 30, 255)),
|
|
] as [Stop],
|
|
CGPoint(x: rect.minX, y: rect.minY),
|
|
CGPoint(x: rect.minX, y: rect.maxY)
|
|
)
|
|
|
|
directionalRim(
|
|
path, rect, radius * 0.30,
|
|
[
|
|
(0.00, (120, 82, 24, 96)),
|
|
(0.52, (120, 82, 24, 16)),
|
|
(1.00, (255, 240, 202, 64)),
|
|
] as [Stop]
|
|
)
|
|
}
|
|
|
|
guard let image = ctx.makeImage() else {
|
|
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
|
|
let rep = NSBitmapImageRep(cgImage: image)
|
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
|
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
try! data.write(to: URL(fileURLWithPath: output))
|
|
print(output)
|