// // TerminalView.m // iSH // // Created by Theodore Dubois on 11/3/17. // #import "ScrollbarView.h" #import "TerminalView.h" #import "UserPreferences.h" #import "UIApplication+OpenURL.h" @interface TerminalView () @property (nonatomic) NSMutableArray *keyCommands; @property ScrollbarView *scrollbarView; @property (nullable) NSString *markedText; @end @implementation TerminalView @synthesize inputDelegate; @synthesize tokenizer; - (void)awakeFromNib { [super awakeFromNib]; self.inputAssistantItem.leadingBarButtonGroups = @[]; self.inputAssistantItem.trailingBarButtonGroups = @[]; ScrollbarView *scrollbarView = self.scrollbarView = [[ScrollbarView alloc] initWithFrame:self.bounds]; self.scrollbarView = scrollbarView; scrollbarView.delegate = self; scrollbarView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; scrollbarView.bounces = NO; [self addSubview:scrollbarView]; [UserPreferences.shared addObserver:self forKeyPath:@"capsLockMapping" options:0 context:nil]; [UserPreferences.shared addObserver:self forKeyPath:@"optionMapping" options:0 context:nil]; [UserPreferences.shared addObserver:self forKeyPath:@"backtickMapEscape" options:0 context:nil]; } - (void)dealloc { [UserPreferences.shared removeObserver:self forKeyPath:@"capsLockMapping"]; [UserPreferences.shared removeObserver:self forKeyPath:@"optionMapping"]; [UserPreferences.shared removeObserver:self forKeyPath:@"backtickMapEscape"]; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { _keyCommands = nil; } - (void)setTerminal:(Terminal *)terminal { NSArray* handlers = @[@"focus", @"newScrollHeight", @"newScrollTop", @"openLink"]; if (self.terminal) { // remove old terminal NSAssert(self.terminal.webView.superview == self.scrollbarView, @"old terminal view was not in our view"); [self.terminal.webView removeFromSuperview]; self.scrollbarView.contentView = nil; for (NSString *handler in handlers) { [self.terminal.webView.configuration.userContentController removeScriptMessageHandlerForName:handler]; } terminal.enableVoiceOverAnnounce = NO; } _terminal = terminal; WKWebView *webView = terminal.webView; terminal.enableVoiceOverAnnounce = YES; webView.scrollView.scrollEnabled = NO; webView.scrollView.delaysContentTouches = NO; webView.scrollView.canCancelContentTouches = NO; webView.scrollView.panGestureRecognizer.enabled = NO; for (NSString *handler in handlers) { [webView.configuration.userContentController addScriptMessageHandler:self name:handler]; } webView.frame = self.bounds; self.opaque = webView.opaque = NO; webView.backgroundColor = UIColor.clearColor; webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; self.scrollbarView.contentView = webView; [self.scrollbarView addSubview:webView]; } #pragma mark Focus and scrolling - (BOOL)canBecomeFirstResponder { return YES; } - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name isEqualToString:@"focus"]) { if (!self.isFirstResponder) { [self becomeFirstResponder]; } } else if ([message.name isEqualToString:@"newScrollHeight"]) { self.scrollbarView.contentSize = CGSizeMake(0, [message.body doubleValue]); } else if ([message.name isEqualToString:@"newScrollTop"]) { CGFloat newOffset = [message.body doubleValue]; if (self.scrollbarView.contentOffset.y == newOffset) return; [self.scrollbarView setContentOffset:CGPointMake(0, newOffset) animated:NO]; } else if ([message.name isEqualToString:@"openLink"]) { [UIApplication openURL:message.body]; } } - (BOOL)resignFirstResponder { [self.terminal.webView evaluateJavaScript:@"exports.blur()" completionHandler:nil]; return [super resignFirstResponder]; } - (IBAction)loseFocus:(id)sender { [self resignFirstResponder]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self.terminal.webView evaluateJavaScript:[NSString stringWithFormat:@"exports.newScrollTop(%f)", scrollView.contentOffset.y] completionHandler:nil]; } - (void)setKeyboardAppearance:(UIKeyboardAppearance)keyboardAppearance { _keyboardAppearance = keyboardAppearance; if (keyboardAppearance == UIKeyboardAppearanceLight) { self.scrollbarView.indicatorStyle = UIScrollViewIndicatorStyleBlack; } else { self.scrollbarView.indicatorStyle = UIScrollViewIndicatorStyleWhite; } } #pragma mark Keyboard Input // implementing these makes a keyboard pop up when this view is first responder - (void)insertText:(NSString *)text { self.markedText = nil; if (self.controlKey.selected) { self.controlKey.selected = NO; if (text.length == 1) { char ch = [text characterAtIndex:0]; if (strchr(controlKeys, ch) != NULL) { ch = toupper(ch) ^ 0x40; text = [NSString stringWithFormat:@"%c", ch]; } } } text = [text stringByReplacingOccurrencesOfString:@"\n" withString:@"\r"]; NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; [self.terminal sendInput:data.bytes length:data.length]; } - (void)deleteBackward { [self insertText:@"\x7f"]; } - (BOOL)hasText { return YES; // it's always ok to send a "delete" } #pragma mark IME Input - (void)setMarkedText:(nullable NSString *)markedText selectedRange:(NSRange)selectedRange { self.markedText = markedText; } - (UITextRange *)markedTextRange { if (self.markedText == nil) { return nil; } return [UITextRange new]; } - (NSString *)textInRange:(UITextRange *)range { return self.markedText; } - (void)unmarkText { [self insertText:self.markedText]; } #pragma mark Keyboard Actions - (void)paste:(id)sender { NSString *string = UIPasteboard.generalPasteboard.string; if (string) { [self insertText:string]; } } - (void)copy:(id)sender { [self.terminal.webView evaluateJavaScript:@"exports.copy()" completionHandler:nil]; } - (void)clearScrollback:(UIKeyCommand *)command { [self.terminal.webView evaluateJavaScript:@"exports.clearScrollback()" completionHandler:nil]; } - (id)forwardingTargetForSelector:(SEL)selector { if ([NSStringFromSelector(selector) hasPrefix:@"_accessibility"]) return self.terminal.webView; return nil; } - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if ([NSStringFromSelector(action) hasPrefix:@"_accessibility"] && [self.terminal.webView canPerformAction:action withSender:sender]) return YES; return [super canPerformAction:action withSender:sender]; } #pragma mark Keyboard Traits - (UITextSmartDashesType)smartDashesType API_AVAILABLE(ios(11)) { return UITextSmartDashesTypeNo; } - (UITextSmartQuotesType)smartQuotesType API_AVAILABLE(ios(11)) { return UITextSmartQuotesTypeNo; } - (UITextSmartInsertDeleteType)smartInsertDeleteType API_AVAILABLE(ios(11)) { return UITextSmartInsertDeleteTypeNo; } - (UITextAutocapitalizationType)autocapitalizationType { return UITextAutocapitalizationTypeNone; } - (UITextAutocorrectionType)autocorrectionType { return UITextAutocorrectionTypeNo; } #pragma mark Hardware Keyboard - (void)handleKeyCommand:(UIKeyCommand *)command { NSString *key = command.input; if (command.modifierFlags == 0) { if ([key isEqualToString:@"`"] && UserPreferences.shared.backtickMapEscape) key = UIKeyInputEscape; if ([key isEqualToString:UIKeyInputEscape]) key = @"\x1b"; else if ([key isEqualToString:UIKeyInputUpArrow]) key = [self.terminal arrow:'A']; else if ([key isEqualToString:UIKeyInputDownArrow]) key = [self.terminal arrow:'B']; else if ([key isEqualToString:UIKeyInputLeftArrow]) key = [self.terminal arrow:'D']; else if ([key isEqualToString:UIKeyInputRightArrow]) key = [self.terminal arrow:'C']; [self insertText:key]; } else if (command.modifierFlags & UIKeyModifierShift) { [self insertText:[key uppercaseString]]; } else if (command.modifierFlags & UIKeyModifierAlternate) { [self insertText:[@"\x1b" stringByAppendingString:key]]; } else if (command.modifierFlags & UIKeyModifierAlphaShift) { [self handleCapsLockWithCommand:command]; } else if (command.modifierFlags & UIKeyModifierControl || command.modifierFlags & UIKeyModifierAlphaShift) { if (key.length == 0) return; if ([key isEqualToString:@"2"]) key = @"@"; else if ([key isEqualToString:@"6"]) key = @"^"; else if ([key isEqualToString:@"-"]) key = @"_"; char ch = (char) [key.uppercaseString characterAtIndex:0] ^ 0x40; [self insertText:[NSString stringWithFormat:@"%c", ch]]; } } static const char *alphabet = "abcdefghijklmnopqrstuvwxyz"; static const char *controlKeys = "abcdefghijklmnopqrstuvwxyz26-=[]\\"; static const char *metaKeys = "abcdefghijklmnopqrstuvwxyz0123456789-=[]\\;',./"; - (NSArray *)keyCommands { if (_keyCommands != nil) return _keyCommands; _keyCommands = [NSMutableArray new]; [self addKeys:controlKeys withModifiers:UIKeyModifierControl]; for (NSString *specialKey in @[UIKeyInputEscape, UIKeyInputUpArrow, UIKeyInputDownArrow, UIKeyInputLeftArrow, UIKeyInputRightArrow, @"\t"]) { [self addKey:specialKey withModifiers:0]; } if (UserPreferences.shared.capsLockMapping != CapsLockMapNone) { [self addKeys:controlKeys withModifiers:UIKeyModifierAlphaShift]; [self addKeys:alphabet withModifiers:0]; [self addKeys:alphabet withModifiers:UIKeyModifierShift]; [self addKey:@"" withModifiers:UIKeyModifierAlphaShift]; // otherwise tap of caps lock can switch layouts } if (UserPreferences.shared.optionMapping == OptionMapEsc) { [self addKeys:metaKeys withModifiers:UIKeyModifierAlternate]; } if (UserPreferences.shared.backtickMapEscape) { [self addKey:@"`" withModifiers:0]; } [_keyCommands addObject:[UIKeyCommand keyCommandWithInput:@"k" modifierFlags:UIKeyModifierCommand|UIKeyModifierShift action:@selector(clearScrollback:) discoverabilityTitle:@"Clear Scrollback"]]; return _keyCommands; } - (void)addKeys:(const char *)keys withModifiers:(UIKeyModifierFlags)modifiers { for (size_t i = 0; keys[i] != '\0'; i++) { [self addKey:[NSString stringWithFormat:@"%c", keys[i]] withModifiers:modifiers]; } } - (void)addKey:(NSString *)key withModifiers:(UIKeyModifierFlags)modifiers { UIKeyCommand *command = [UIKeyCommand keyCommandWithInput:key modifierFlags:modifiers action:@selector(handleKeyCommand:)]; [_keyCommands addObject:command]; } - (void)keyCommandTriggered:(UIKeyCommand *)sender { dispatch_async(dispatch_get_main_queue(), ^{ [self handleKeyCommand:sender]; }); } - (void)handleCapsLockWithCommand:(UIKeyCommand *)command { CapsLockMapping target = UserPreferences.shared.capsLockMapping; NSString *newInput = command.input ? command.input : @""; UIKeyModifierFlags flags = command.modifierFlags; flags ^= UIKeyModifierAlphaShift; if(target == CapsLockMapEscape) { newInput = UIKeyInputEscape; } else if(target == CapsLockMapControl) { if([newInput length] == 0) { return; } flags |= UIKeyModifierControl; } else { return; } UIKeyCommand *newCommand = [UIKeyCommand keyCommandWithInput:newInput modifierFlags:flags action:@selector(keyCommandTriggered:)]; [self handleKeyCommand:newCommand]; } #pragma mark UITextInput stubs #if 0 #define LogStub() NSLog(@"%s", __func__) #else #define LogStub() #endif - (NSWritingDirection)baseWritingDirectionForPosition:(nonnull UITextPosition *)position inDirection:(UITextStorageDirection)direction { LogStub(); return NSWritingDirectionLeftToRight; } - (void)setBaseWritingDirection:(NSWritingDirection)writingDirection forRange:(nonnull UITextRange *)range { LogStub(); } - (UITextPosition *)beginningOfDocument { LogStub(); return nil; } - (CGRect)caretRectForPosition:(nonnull UITextPosition *)position { LogStub(); return CGRectZero; } - (nullable UITextRange *)characterRangeAtPoint:(CGPoint)point { LogStub(); return nil; } - (nullable UITextRange *)characterRangeByExtendingPosition:(nonnull UITextPosition *)position inDirection:(UITextLayoutDirection)direction { LogStub(); return nil; } - (nullable UITextPosition *)closestPositionToPoint:(CGPoint)point { LogStub(); return nil; } - (nullable UITextPosition *)closestPositionToPoint:(CGPoint)point withinRange:(nonnull UITextRange *)range { LogStub(); return nil; } - (NSComparisonResult)comparePosition:(nonnull UITextPosition *)position toPosition:(nonnull UITextPosition *)other { LogStub(); return NSOrderedSame; } - (UITextPosition *)endOfDocument { LogStub(); return nil; } - (CGRect)firstRectForRange:(nonnull UITextRange *)range { LogStub(); return CGRectZero; } - (NSDictionary *)markedTextStyle { LogStub(); return nil; } - (void)setMarkedTextStyle:(NSDictionary *)markedTextStyle { LogStub(); } - (NSInteger)offsetFromPosition:(nonnull UITextPosition *)from toPosition:(nonnull UITextPosition *)toPosition { LogStub(); return 0; } - (nullable UITextPosition *)positionFromPosition:(nonnull UITextPosition *)position inDirection:(UITextLayoutDirection)direction offset:(NSInteger)offset { LogStub(); return nil; } - (nullable UITextPosition *)positionFromPosition:(nonnull UITextPosition *)position offset:(NSInteger)offset { LogStub(); return nil; } - (nullable UITextPosition *)positionWithinRange:(nonnull UITextRange *)range farthestInDirection:(UITextLayoutDirection)direction { LogStub(); return nil; } - (void)replaceRange:(nonnull UITextRange *)range withText:(nonnull NSString *)text { LogStub(); } - (UITextRange *)selectedTextRange { LogStub(); return nil; } - (void)setSelectedTextRange:(UITextRange *)selectedTextRange { LogStub(); } - (nonnull NSArray *)selectionRectsForRange:(nonnull UITextRange *)range { LogStub(); return @[]; } - (nullable UITextRange *)textRangeFromPosition:(nonnull UITextPosition *)fromPosition toPosition:(nonnull UITextPosition *)toPosition { LogStub(); return nil; } // conforming to UITextInput makes this view default to being an accessibility element, which blocks selecting anything in it - (BOOL)isAccessibilityElement { return NO; } @end