Skip to content

Commit b436e6f

Browse files
committed
feat: type out text on macOS
1 parent 0dc52d2 commit b436e6f

8 files changed

Lines changed: 845 additions & 26 deletions

File tree

src/TypeLayout.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#pragma once
2+
3+
#include "TypePlan.h"
4+
5+
#include <string>
6+
7+
namespace clipp {
8+
9+
// Translates text into a keystroke plan using the CURRENTLY ACTIVE keyboard
10+
// layout (hitsc offers a layout picker; clipp deliberately doesn't — if the
11+
// receiving side expects another layout, switch layouts and retype). Newlines
12+
// become Return (CRLF folds to one), tab becomes Tab. Everything else is
13+
// reverse-mapped through the OS layout — Windows: VkKeyScanEx; macOS:
14+
// UCKeyTranslate over the layout's key/modifier space — and any character the
15+
// layout cannot produce fails the WHOLE translation with its codepoint and
16+
// line/column. Partial typing would punch silent holes in the text.
17+
//
18+
// Backends differ in how a chord's modifiers reach the OS, and the difference
19+
// is invisible above this line: Windows emits real modifier key events, while
20+
// macOS carries them as CGEvent flags on the character event (the platform's
21+
// own convention — and it means a cancelled run there can't strand a modifier).
22+
TypeResult TranslateTextToPlan(const std::wstring& text);
23+
24+
// Human name of the active layout ("United States-International", "ABC"), for
25+
// the "can't type X in this layout" message. Empty if it can't be resolved.
26+
std::wstring ActiveKeyboardLayoutName();
27+
28+
// True when the active input profile is a real IME (Pinyin, Japanese, ...).
29+
// An IME has no 1:1 character-to-keystroke map, so typing is refused outright.
30+
bool ActiveLayoutIsIme();
31+
32+
} // namespace clipp

src/platform/macos/AppMenu.mm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "ClipboardFlowUi.h"
77
#include "Logger.h"
88
#include "Settings.h"
9+
#include "TextTyper.h"
910
#include "AboutPage.h"
1011
#include "ClippPage.h"
1112
#include "NetworkPage.h"
@@ -1223,6 +1224,7 @@ static void StopMacOSAppOnMainThread(bool unregisterAutoStart) {
12231224
}
12241225

12251226
clipp::DestroyPopupPanel();
1227+
clipp::ShutdownTyping(); // stops any run in flight
12261228

12271229
NSApplication* app = [NSApplication sharedApplication];
12281230
[app stop:nil];

src/platform/macos/PopupPanel.mm

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
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

Comments
 (0)