diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift index 7fe8d47be453..0f3813197cbe 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift @@ -13,6 +13,28 @@ final class AccessoryActionButton: UIButton { /// The configurable item this button triggers. let item: ResolvedToolbarItem + /// Whether this modifier is double-tap *sticky-locked* (vs. single-tap armed). + /// + /// A sticky-locked modifier stays applied to every keystroke until the user + /// taps it off, whereas an armed modifier is consumed by the next key. On + /// iOS 26 both states share the same prominent-glass blue fill, so the lock + /// needs its own visual cue: a white capsule border drawn on the button's + /// layer, *over* the glass, mirroring the 2pt white stroke the pre-26 flat + /// style already used for the locked state. The border is drawn at the layer + /// level (not via `UIButton.Configuration.background.strokeColor`) so it + /// composites on top of Liquid Glass regardless of how the glass material + /// renders its own background, and adds zero intrinsic width so it does not + /// fight the bar's min-width sizing. + var isStickyLocked = false { + didSet { + guard oldValue != isStickyLocked else { return } + updateStickyLockBorder() + } + } + + /// Width of the sticky-lock capsule border, matching the pre-26 flat stroke. + private static let stickyLockBorderWidth: CGFloat = 2 + /// Creates a button bound to a resolved toolbar item. /// - Parameter item: The built-in or custom action the button represents. init(item: ResolvedToolbarItem) { @@ -24,4 +46,28 @@ final class AccessoryActionButton: UIButton { required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func layoutSubviews() { + super.layoutSubviews() + // Keep the lock border a true capsule that hugs the glass pill as the + // button's bounds settle (height is fixed, but the corner radius is + // derived here so the border tracks any future sizing change). + updateStickyLockBorder() + } + + /// Sync the layer-level white capsule border to ``isStickyLocked``. + /// + /// Always clears the border when not locked, so a button that transitions + /// locked → armed → resting never keeps a stale border. + private func updateStickyLockBorder() { + if isStickyLocked { + layer.cornerRadius = bounds.height / 2 + layer.cornerCurve = .continuous + layer.borderColor = UIColor.white.cgColor + layer.borderWidth = Self.stickyLockBorderWidth + } else { + layer.borderWidth = 0 + layer.borderColor = nil + } + } } diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index defbf4047396..9b4f6e794be0 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -575,6 +575,20 @@ final class TerminalInputTextView: UITextView { } config.contentInsets = Self.accessoryButtonContentInsets button.configuration = config + if let actionButton = button as? AccessoryActionButton { + // On iOS 26 the armed and sticky states share the same + // prominent-glass blue fill, so the double-tap *lock* is + // distinguished by a white capsule border drawn over the glass (see + // ``AccessoryActionButton/isStickyLocked``). On earlier OSes the + // flat style already renders the locked white stroke through the + // background configuration, so the layer border stays off to avoid + // a doubled stroke. + if #available(iOS 26.0, *) { + actionButton.isStickyLocked = sticky + } else { + actionButton.isStickyLocked = false + } + } } private static func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration {