1313#include " RegisterStore.h"
1414#include " RegisterWire.h"
1515#include " Settings.h"
16+ #include " TextTyper.h"
17+ #include " TypeLayout.h"
18+ #include " TypePlan.h"
1619#include " platform/uiClippPage.h"
1720#include " platform/uistrings.h"
1821#include " UiHelpers.h"
@@ -264,6 +267,7 @@ bool TextFitsInRowNS(NSString* full) {
264267 NSData * imageData = nil ; // BinaryHeader image stream, or nil
265268 bool contentRow = false ; // previewText is content: find matches + re-windows it
266269 bool isPrivate = false ;
270+ bool isBinary = false ; // image/stream register: nothing to type out
267271 uint64_t touchedWallMs = 0 ; // same clock `clipp ls` shows
268272};
269273
@@ -333,6 +337,11 @@ @interface ClippPopupController : NSObject <NSSearchFieldDelegate> {
333337 bool flyoutUpdatePending_;
334338 bool axJourneyStarted_;
335339 int axPollTicks_;
340+ // "Type it out" confirmation state: armed until the second click, the
341+ // 4s timeout, or a selection change.
342+ bool typeArmed_;
343+ std::string typeArmedKey_;
344+ NSTimer * typeArmTimer_;
336345}
337346@property (nonatomic , strong ) ClippPopupMainPanel* panel;
338347@property (nonatomic , strong ) NSSearchField * filterField;
@@ -353,6 +362,10 @@ @interface ClippPopupController : NSObject <NSSearchFieldDelegate> {
353362@property (nonatomic , strong ) NSButton * privateButton;
354363@property (nonatomic , strong ) NSButton * deleteButton;
355364@property (nonatomic , strong ) NSButton * undoButton;
365+ @property (nonatomic , strong ) NSButton * typeButton;
366+ // Confirm/error bubble anchored under the Type button.
367+ @property (nonatomic , strong ) NSView * typeBubble;
368+ @property (nonatomic , strong ) NSTextField * typeBubbleLabel;
356369@property (nonatomic , strong ) NSTextField * renameField;
357370@property (nonatomic , strong ) NSMutableArray <ClippPopupRowView*>* registerRowViews;
358371@property (nonatomic , strong ) NSMutableArray <ClippPopupRowView*>* historyRowViews;
@@ -370,6 +383,10 @@ @interface ClippPopupController : NSObject <NSSearchFieldDelegate> {
370383- (void )toggle ;
371384- (void )dismiss ;
372385- (void )handleF2 ;
386+ - (void )typeSelected ;
387+ - (void )hideTypeBubble ;
388+ - (void )setTypeArmed : (BOOL )armed ;
389+ - (std::string)selectedItemKey ;
373390- (BOOL )handleCommandEquivalent : (NSEvent *)event ;
374391- (void )rowClicked : (ClippPopupRowView*)row clickCount : (NSInteger )clicks ;
375392- (NSMenu *)contextMenuForRow : (ClippPopupRowView*)row ;
@@ -535,6 +552,8 @@ - (void)buildPanel {
535552 self, @selector (deleteClicked: ));
536553 self.undoButton = MacOSMakeIconButton (@" arrow.uturn.backward" , CLP_NS (CLP_UI_POPUP_UNDO_TIP_MAC ),
537554 self, @selector (undoClicked: ));
555+ self.typeButton = MacOSMakeIconButton (@" keyboard" , CLP_NS (CLP_UI_POPUP_TYPE_TIP_MAC ),
556+ self, @selector (typeClicked: ));
538557 NSStackView * toolbar = [[NSStackView alloc ] initWithFrame: NSZeroRect ];
539558 toolbar.translatesAutoresizingMaskIntoConstraints = NO ;
540559 toolbar.orientation = NSUserInterfaceLayoutOrientationHorizontal;
@@ -544,9 +563,11 @@ - (void)buildPanel {
544563 [toolbar addArrangedSubview: self .renameButton];
545564 [toolbar addArrangedSubview: self .privateButton];
546565 [toolbar addArrangedSubview: self .deleteButton];
566+ [toolbar addArrangedSubview: self .typeButton];
547567 [toolbar addArrangedSubview: self .undoButton];
548568 for (NSButton * button in @[ self .saveButton, self .activateButton, self .renameButton,
549- self .privateButton, self .deleteButton, self .undoButton ]) {
569+ self .privateButton, self .deleteButton, self .undoButton,
570+ self .typeButton ]) {
550571 // Toolbar clicks must never move the keyboard out of the filter field
551572 // (or steal the focus the rename editor is about to take).
552573 button.refusesFirstResponder = YES ;
@@ -608,9 +629,27 @@ - (void)buildPanel {
608629 [historyColumn.heightAnchor constraintEqualToAnchor: columns.heightAnchor].active = YES ;
609630
610631 [root addSubview: title];
632+ // Confirm/error bubble for the Type action, anchored under its toolbar
633+ // button so the second click lands where the finger already is. A plain
634+ // subview (not a popover): it can never take the keyboard from the filter
635+ // field or dismiss the panel.
636+ self.typeBubble = [[NSView alloc ] initWithFrame: NSZeroRect ];
637+ self.typeBubble .translatesAutoresizingMaskIntoConstraints = NO ;
638+ self.typeBubble .wantsLayer = YES ;
639+ self.typeBubble .layer .cornerRadius = 6.0 ;
640+ self.typeBubble .layer .backgroundColor = [NSColor controlAccentColor ].CGColor ;
641+ self.typeBubble .hidden = YES ;
642+ self.typeBubbleLabel = [NSTextField wrappingLabelWithString: @" " ];
643+ self.typeBubbleLabel .translatesAutoresizingMaskIntoConstraints = NO ;
644+ self.typeBubbleLabel .font = [NSFont systemFontOfSize: 11.0 ];
645+ self.typeBubbleLabel .textColor = [NSColor whiteColor ];
646+ self.typeBubbleLabel .selectable = NO ;
647+ [self .typeBubble addSubview: self .typeBubbleLabel];
648+
611649 [root addSubview: toolbar];
612650 [root addSubview: filter];
613651 [root addSubview: columns];
652+ [root addSubview: self .typeBubble positioned: NSWindowAbove relativeTo: nil ];
614653
615654 [NSLayoutConstraint activateConstraints: @[
616655 [title.leadingAnchor constraintEqualToAnchor: root.leadingAnchor constant: 14.0 ],
@@ -621,6 +660,15 @@ - (void)buildPanel {
621660 [toolbar.trailingAnchor constraintLessThanOrEqualToAnchor: root.trailingAnchor constant: -12.0 ],
622661 [toolbar.topAnchor constraintEqualToAnchor: title.bottomAnchor constant: 6.0 ],
623662
663+ [self .typeBubble.leadingAnchor constraintEqualToAnchor: self .typeButton.leadingAnchor],
664+ [self .typeBubble.topAnchor constraintEqualToAnchor: self .typeButton.bottomAnchor constant: 2.0 ],
665+ [self .typeBubble.trailingAnchor constraintLessThanOrEqualToAnchor: root.trailingAnchor constant: -12.0 ],
666+ [self .typeBubbleLabel.leadingAnchor constraintEqualToAnchor: self .typeBubble.leadingAnchor constant: 9.0 ],
667+ [self .typeBubbleLabel.trailingAnchor constraintEqualToAnchor: self .typeBubble.trailingAnchor constant: -9.0 ],
668+ [self .typeBubbleLabel.topAnchor constraintEqualToAnchor: self .typeBubble.topAnchor constant: 5.0 ],
669+ [self .typeBubbleLabel.bottomAnchor constraintEqualToAnchor: self .typeBubble.bottomAnchor constant: -6.0 ],
670+ [self .typeBubbleLabel.widthAnchor constraintLessThanOrEqualToConstant: 280.0 ],
671+
624672 [filter.leadingAnchor constraintEqualToAnchor: root.leadingAnchor constant: 12.0 ],
625673 [filter.trailingAnchor constraintEqualToAnchor: root.trailingAnchor constant: -12.0 ],
626674 [filter.topAnchor constraintEqualToAnchor: toolbar.bottomAnchor constant: 8.0 ],
@@ -945,6 +993,10 @@ - (void)dismiss {
945993 object: self .panel];
946994 [self endWatcher ];
947995 [self .toastPanel orderOut: nil ];
996+ // A pending Type confirmation does not survive the popup closing: the next
997+ // summon must start from a clean, unarmed state.
998+ [self setTypeArmed: NO ];
999+ [self hideTypeBubble ];
9481000 // The onboarding toast normally lives and dies with the popup — EXCEPT
9491001 // mid-journey (grant button clicked): System Settings stealing focus is
9501002 // the very thing that triggered this dismiss, and the toast must stay
@@ -988,6 +1040,7 @@ - (void)rebuildFromStores {
9881040 RegisterRowInfo info;
9891041 info.name = MacOSToNSString (rec.name );
9901042 info.isPrivate = rec.IsPrivate ();
1043+ info.isBinary = rec.IsBinary ();
9911044 info.touchedWallMs = rec.touched .wallMs ;
9921045
9931046 PopupItem item;
@@ -1544,6 +1597,21 @@ - (void)updateToolbar {
15441597 ? CLP_NS (CLP_UI_POPUP_MAKE_PUBLIC ) : CLP_NS (CLP_UI_POPUP_MAKE_PRIVATE );
15451598 self.deleteButton .enabled = item != nullptr ;
15461599
1600+ // Text only: an image has no keystrokes.
1601+ bool canType = false ;
1602+ if (item != nullptr && item->actionable ) {
1603+ if (registerSelected) {
1604+ const auto cached = registerCache_.find (item->registerName );
1605+ canType = cached == registerCache_.end () || !cached->second .isBinary ;
1606+ } else {
1607+ const auto cached = displayCache_.find (item->historyId );
1608+ canType = cached != displayCache_.end ()
1609+ && (cached->second .kind == ClipboardActivityPayloadKind::Text
1610+ || cached->second .kind == ClipboardActivityPayloadKind::Link);
1611+ }
1612+ }
1613+ self.typeButton .enabled = canType;
1614+
15471615 const auto undoKind = clipp::PendingUndoKind ();
15481616 self.undoButton .enabled = undoKind != clipp::UndoSlotKind::None;
15491617 NSString * undoTip = CLP_NS (CLP_UI_POPUP_UNDO_TIP_MAC );
@@ -1887,6 +1955,157 @@ - (void)activateSelected {
18871955 }
18881956}
18891957
1958+ // ---- type it out ----------------------------------------------------------
1959+ // Delivers the selection as real keystrokes instead of a paste, for windows
1960+ // that cannot take one (iKVM/BMC consoles, SPICE/VNC viewers, RDP without
1961+ // clipboard redirection). Never touches any clipboard.
1962+
1963+ - (void )typeClicked : (id )sender {
1964+ (void )sender;
1965+ [self typeSelected ];
1966+ }
1967+
1968+ - (void )showTypeBubble : (NSString *)text {
1969+ self.typeBubbleLabel .stringValue = text ?: @" " ;
1970+ self.typeBubble .hidden = NO ;
1971+ }
1972+
1973+ - (void )hideTypeBubble {
1974+ self.typeBubble .hidden = YES ;
1975+ }
1976+
1977+ // Armed = the button is lit and the bubble is up; the next click runs.
1978+ - (void )setTypeArmed : (BOOL )armed {
1979+ typeArmed_ = armed;
1980+ self.typeButton .contentTintColor =
1981+ armed ? [NSColor controlAccentColor ] : [NSColor secondaryLabelColor ];
1982+ typeArmedKey_ = armed ? [self selectedItemKey ] : std::string{};
1983+ [typeArmTimer_ invalidate ];
1984+ typeArmTimer_ = nil ;
1985+ if (armed) {
1986+ typeArmTimer_ = [NSTimer scheduledTimerWithTimeInterval: 4.0
1987+ repeats: NO
1988+ block: ^(NSTimer * timer) {
1989+ (void )timer;
1990+ // The confirming click never came; stand down quietly.
1991+ [self setTypeArmed: NO ];
1992+ [self hideTypeBubble ];
1993+ }];
1994+ }
1995+ }
1996+
1997+ // Identity of the selected row, so an arm can't survive a selection change
1998+ // (the confirmation quoted counts for a DIFFERENT item).
1999+ - (std::string)selectedItemKey {
2000+ const PopupItem* item = model_.SelectedItem ();
2001+ if (item == nullptr ) {
2002+ return {};
2003+ }
2004+ return item->kind == PopupItem::Kind::Register
2005+ ? " R:" + item->registerName
2006+ : " H:" + std::to_string (item->historyId );
2007+ }
2008+
2009+ - (void )typeSelected {
2010+ [self dismissToast ];
2011+ [self commitOrCancelRename ];
2012+ // Re-summoning mid-run and hitting the button again means "stop".
2013+ if (clipp::IsTyping ()) {
2014+ clipp::CancelTyping ();
2015+ [self setTypeArmed: NO ];
2016+ [self hideTypeBubble ];
2017+ return ;
2018+ }
2019+ const PopupItem* item = model_.SelectedItem ();
2020+ if (item == nullptr || !item->actionable ) {
2021+ return ;
2022+ }
2023+ // Selection moved since the confirmation was raised: that bubble's counts
2024+ // described another item, so make them earn a fresh one.
2025+ if (typeArmed_ && typeArmedKey_ != [self selectedItemKey ]) {
2026+ [self setTypeArmed: NO ];
2027+ }
2028+
2029+ std::string utf8;
2030+ const bool haveText = item->kind == PopupItem::Kind::History
2031+ ? clipp::TryGetActivityItemText (item->historyId , utf8)
2032+ : clipp::TryGetRegisterText (item->registerName , utf8);
2033+ if (!haveText) {
2034+ [self setTypeArmed: NO ];
2035+ [self showTypeBubble: CLP_NS (CLP_UI_POPUP_TYPE_NO_TEXT )];
2036+ return ;
2037+ }
2038+ // An IME has no character-to-keystroke map at all; refuse rather than type
2039+ // garbage into a composition window.
2040+ if (clipp::ActiveLayoutIsIme ()) {
2041+ [self setTypeArmed: NO ];
2042+ [self showTypeBubble: CLP_NS (CLP_UI_POPUP_TYPE_IME )];
2043+ return ;
2044+ }
2045+
2046+ const clipp::TypeResult result = clipp::TranslateTextToPlan (Utf8ToWideString (utf8));
2047+ if (!result.ok ) {
2048+ [self setTypeArmed: NO ];
2049+ [self showTypeBubble: [self describeTypeError: result.error]];
2050+ return ;
2051+ }
2052+ clipp::TypeSchedule schedule = clipp::BuildTypeSchedule (result.plan );
2053+ const int seconds = clipp::EstimateTypeSeconds (schedule);
2054+
2055+ // Arm-and-confirm for anything with consequences: every Return may run a
2056+ // command on the far side, and a run measured in tens of seconds is
2057+ // usually a mis-click. Short, Return-free text types on the first click.
2058+ if (!typeArmed_ && (schedule.enterCount > 0 || seconds >= 10 )) {
2059+ [self setTypeArmed: YES ];
2060+ [self showTypeBubble: [self describeTypeConfirmation: schedule seconds: seconds]];
2061+ return ;
2062+ }
2063+
2064+ [self setTypeArmed: NO ];
2065+ [self hideTypeBubble ];
2066+ [self dismiss ];
2067+ // Same beat the paste keystroke takes: our non-activating panel never held
2068+ // the app's focus, so the target is already frontmost — this only lets the
2069+ // panel finish resigning key.
2070+ __block clipp::TypeSchedule pending = std::move (schedule);
2071+ dispatch_after (dispatch_time (DISPATCH_TIME_NOW , (int64_t )(0.06 * NSEC_PER_SEC )),
2072+ dispatch_get_main_queue (), ^{
2073+ clipp::StartTyping (std::move (pending));
2074+ });
2075+ }
2076+
2077+ - (NSString *)describeTypeConfirmation : (const clipp::TypeSchedule&)schedule seconds : (int )seconds {
2078+ NSString * detail = nil ;
2079+ if (schedule.enterCount > 0 ) {
2080+ detail = [NSString stringWithFormat: @" %@ %d ×" , CLP_NS (CLP_UI_POPUP_TYPE_ENTER_PREFIX ),
2081+ schedule.enterCount];
2082+ if (seconds >= 10 ) {
2083+ detail = [NSString stringWithFormat: @" %@ , ~%d s" , detail, seconds];
2084+ }
2085+ } else {
2086+ detail = [NSString stringWithFormat: @" %zu %@ %d %@ " , schedule.events.size (),
2087+ CLP_NS (CLP_UI_POPUP_TYPE_KEYSTROKES_MIDDLE ), seconds,
2088+ CLP_NS (CLP_UI_POPUP_TYPE_KEYSTROKES_SUFFIX )];
2089+ }
2090+ return [NSString stringWithFormat: @" %@%@ " , detail, CLP_NS (CLP_UI_POPUP_TYPE_CONFIRM_SUFFIX )];
2091+ }
2092+
2093+ // "Can't type 'ß' (U+00DF) - line 3, col 14 [ABC]"
2094+ - (NSString *)describeTypeError : (const clipp::TypeError&)error {
2095+ NSMutableString * text = [NSMutableString stringWithString: CLP_NS (CLP_UI_POPUP_TYPE_CANT_PREFIX )];
2096+ if (error.codepoint >= 0x20 && error.codepoint != 0x7F && error.codepoint <= 0xFFFF ) {
2097+ [text appendFormat: @" '%C ' " , (unichar )error.codepoint];
2098+ }
2099+ [text appendFormat: @" (U+%04X )" , (unsigned )error.codepoint];
2100+ [text appendFormat: @" %@ %d %@ %d " , CLP_NS (CLP_UI_POPUP_TYPE_CANT_LINE ), error.line,
2101+ CLP_NS (CLP_UI_POPUP_TYPE_CANT_COLUMN ), error.column];
2102+ const std::wstring layout = clipp::ActiveKeyboardLayoutName ();
2103+ if (!layout.empty ()) {
2104+ [text appendFormat: @" [%@ ]" , MacOSToNSString (layout)];
2105+ }
2106+ return text;
2107+ }
2108+
18902109// Post ⌘V to the frontmost app. The nonactivating panel never took the app's
18912110// focus, so the target is already frontmost — the only timing need is a beat
18922111// for our key panel to finish resigning. Requires the user to have granted
@@ -2148,6 +2367,10 @@ - (BOOL)handleCommandEquivalent:(NSEvent*)event {
21482367 [self saveSelected ];
21492368 return YES ;
21502369 }
2370+ if ([chars isEqualToString: @" t" ]) {
2371+ [self typeSelected ];
2372+ return YES ;
2373+ }
21512374 if ([chars isEqualToString: @" z" ]
21522375 && clipp::PendingUndoKind () != clipp::UndoSlotKind::None) {
21532376 [self undoLastDelete ];
0 commit comments