TBKeyPathSearchController.m 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. //
  2. // FLEXKeyPathSearchController.m
  3. // FLEX
  4. //
  5. // Created by Tanner on 3/23/17.
  6. // Copyright © 2017 Tanner Bennett. All rights reserved.
  7. //
  8. #import "TBKeyPathSearchController.h"
  9. #import "TBKeyPathTokenizer.h"
  10. #import "TBRuntimeController.h"
  11. #import "NSString+KeyPaths.h"
  12. #import "NSArray+Functional.h"
  13. #import "UITextField+Range.h"
  14. #import "NSTimer+Blocks.h"
  15. #import "FLEXTableView.h"
  16. #import "FLEXUtility.h"
  17. #import "FLEXObjectExplorerFactory.h"
  18. @interface TBKeyPathSearchController ()
  19. @property (nonatomic, readonly, weak) id<TBKeyPathSearchControllerDelegate> delegate;
  20. @property (nonatomic, readonly) NSTimer *timer;
  21. @property (nonatomic) NSArray<NSString*> *bundlesOrClasses;
  22. @property (nonatomic) TBKeyPath *keyPath;
  23. /// Used to track which methods go with which classes. This is used in
  24. /// two scenarios: (1) when the target class is absolute and has classes,
  25. /// (this list will include the "leaf" class as well as parent classes in this case)
  26. /// or (2) when the class key is a wildcard and we're searching methods in many
  27. /// classes at once. Each list in \c classesToMethods correspnds to a class here.
  28. @property (nonatomic) NSArray<NSString *> *classes;
  29. // We use this regardless of whether the target class is absolute, just as above
  30. @property (nonatomic) NSArray<NSArray<FLEXMethod *> *> *classesToMethods;
  31. @end
  32. #warning TODO there's no code to handle refreshing the table after manually appending ".bar" to "Bundle"
  33. @implementation TBKeyPathSearchController
  34. + (instancetype)delegate:(id<TBKeyPathSearchControllerDelegate>)delegate {
  35. TBKeyPathSearchController *controller = [self new];
  36. controller->_bundlesOrClasses = [TBRuntimeController allBundleNames];
  37. controller->_delegate = delegate;
  38. NSParameterAssert(delegate.tableView);
  39. NSParameterAssert(delegate.searchController);
  40. delegate.tableView.delegate = controller;
  41. delegate.tableView.dataSource = controller;
  42. delegate.searchController.searchBar.delegate = controller;
  43. return controller;
  44. }
  45. - (void)scrollViewDidScroll:(UIScrollView *)scrollView {
  46. if (scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating) {
  47. [self.delegate.searchController.searchBar resignFirstResponder];
  48. }
  49. }
  50. - (void)setToolbar:(TBKeyPathToolbar *)toolbar {
  51. _toolbar = toolbar;
  52. self.delegate.searchController.searchBar.inputAccessoryView = toolbar;
  53. }
  54. - (NSArray<NSString *> *)classesOf:(NSString *)className {
  55. Class baseClass = NSClassFromString(className);
  56. if (!baseClass) {
  57. return @[];
  58. }
  59. // Find classes
  60. NSMutableArray<NSString*> *classes = [NSMutableArray arrayWithObject:className];
  61. while ([baseClass superclass]) {
  62. [classes addObject:NSStringFromClass([baseClass superclass])];
  63. baseClass = [baseClass superclass];
  64. }
  65. return classes;
  66. }
  67. #pragma mark Key path stuff
  68. - (void)didSelectKeyPathOption:(NSString *)text {
  69. [_timer invalidate]; // Still might be waiting to refresh when method is selected
  70. // Change "Bundle.fooba" to "Bundle.foobar."
  71. NSString *orig = self.delegate.searchController.searchBar.text;
  72. NSString *keyPath = [orig stringByReplacingLastKeyPathComponent:text];
  73. self.delegate.searchController.searchBar.text = keyPath;
  74. self.keyPath = [TBKeyPathTokenizer tokenizeString:keyPath];
  75. // Get classes if class was selected
  76. if (self.keyPath.classKey.isAbsolute && self.keyPath.methodKey.isAny) {
  77. [self didSelectAbsoluteClass:text];
  78. } else {
  79. self.classes = nil;
  80. }
  81. [self updateTable];
  82. }
  83. - (void)didSelectAbsoluteClass:(NSString *)name {
  84. self.classes = [self classesOf:name];
  85. self.bundlesOrClasses = nil;
  86. self.classesToMethods = nil;
  87. }
  88. - (void)didPressButton:(NSString *)text insertInto:(UISearchBar *)searchBar {
  89. // Available since at least iOS 9, still present in iOS 13
  90. UITextField *field = [searchBar valueForKey:@"_searchBarTextField"];
  91. if ([self searchBar:searchBar shouldChangeTextInRange:field.selectedRange replacementText:text]) {
  92. [field replaceRange:field.selectedTextRange withText:text];
  93. }
  94. }
  95. #pragma mark - Filtering + UISearchBarDelegate
  96. - (void)updateTable {
  97. // Compute the method, class, or bundle lists on a background thread
  98. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
  99. if (self.classes) {
  100. // Here, our class key is 'absolute'; .classes is a list of superclasses
  101. // and we want to show the methods for those classes specifically
  102. // TODO: add caching to this somehow
  103. self.classesToMethods = [TBRuntimeController
  104. methodsForToken:self.keyPath.methodKey
  105. instance:self.keyPath.instanceMethods
  106. inClasses:self.classes
  107. ];
  108. }
  109. else {
  110. TBKeyPath *keyPath = self.keyPath;
  111. NSArray *models = [TBRuntimeController dataForKeyPath:keyPath];
  112. if (keyPath.methodKey) { // We're looking at methods
  113. self.bundlesOrClasses = nil;
  114. NSMutableArray *methods = models.mutableCopy;
  115. NSMutableArray *classes = [TBRuntimeController classesForKeyPath:keyPath];
  116. [self setNonEmptyMethodLists:methods withClasses:classes];
  117. } else { // We're looking at bundles or classes
  118. self.bundlesOrClasses = models;
  119. self.classesToMethods = nil;
  120. }
  121. }
  122. // Finally, reload the table on the main thread
  123. dispatch_async(dispatch_get_main_queue(), ^{
  124. [self.delegate.tableView reloadData];
  125. });
  126. });
  127. }
  128. /// Assign .classes and .classesToMethods after removing empty sections
  129. - (void)setNonEmptyMethodLists:(NSMutableArray<NSArray *> *)methods withClasses:(NSMutableArray *)classes {
  130. // Remove sections with no methods
  131. NSIndexSet *allEmpty = [methods indexesOfObjectsPassingTest:^BOOL(NSArray *list, NSUInteger idx, BOOL *stop) {
  132. return list.count == 0;
  133. }];
  134. [methods removeObjectsAtIndexes:allEmpty];
  135. [classes removeObjectsAtIndexes:allEmpty];
  136. self.classes = classes;
  137. self.classesToMethods = methods;
  138. }
  139. - (BOOL)searchBar:(UISearchBar *)searchBar shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
  140. // Check if character is even legal
  141. if (![TBKeyPathTokenizer allowedInKeyPath:text]) {
  142. return NO;
  143. }
  144. BOOL terminatedToken = NO;
  145. BOOL isAppending = range.length == 0 && range.location == searchBar.text.length;
  146. if (isAppending && [text isEqualToString:@"."]) {
  147. terminatedToken = YES;
  148. }
  149. // Actually parse input
  150. @try {
  151. text = [searchBar.text stringByReplacingCharactersInRange:range withString:text] ?: text;
  152. self.keyPath = [TBKeyPathTokenizer tokenizeString:text];
  153. if (self.keyPath.classKey.isAbsolute && terminatedToken) {
  154. [self didSelectAbsoluteClass:self.keyPath.classKey.string];
  155. }
  156. } @catch (id e) {
  157. return NO;
  158. }
  159. return YES;
  160. }
  161. - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
  162. [_timer invalidate];
  163. // Update toolbar buttons
  164. [self.toolbar setKeyPath:self.keyPath animated:YES];
  165. // Schedule update timer
  166. if (searchText.length) {
  167. if (!self.keyPath.methodKey) {
  168. self.classes = nil;
  169. }
  170. _timer = [NSTimer fireSecondsFromNow:0.15 block:^{
  171. [self updateTable];
  172. }];
  173. }
  174. // ... or remove all rows
  175. else {
  176. _bundlesOrClasses = [TBRuntimeController allBundleNames];
  177. _classesToMethods = nil;
  178. _classes = nil;
  179. _keyPath = nil;
  180. [self.delegate.tableView reloadData];
  181. }
  182. }
  183. - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar {
  184. self.keyPath = [TBKeyPath empty];
  185. [self updateTable];
  186. }
  187. /// Restore key path when going "back" and activating search bar again
  188. - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar {
  189. searchBar.text = self.keyPath.description;
  190. }
  191. - (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar {
  192. [_timer invalidate];
  193. [searchBar resignFirstResponder];
  194. [self updateTable];
  195. }
  196. #pragma mark UITableViewDataSource
  197. - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
  198. return self.classes.count ?: 1;
  199. }
  200. - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
  201. return self.classes.count ? 1 : self.bundlesOrClasses.count;
  202. }
  203. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  204. UITableViewCell *cell = [tableView
  205. dequeueReusableCellWithIdentifier:kFLEXMultilineDetailCell
  206. forIndexPath:indexPath
  207. ];
  208. if (self.bundlesOrClasses.count) {
  209. cell.accessoryType = UITableViewCellAccessoryDetailButton;
  210. cell.textLabel.text = self.bundlesOrClasses[indexPath.row];
  211. cell.detailTextLabel.text = nil;
  212. if (self.keyPath.classKey) {
  213. cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
  214. }
  215. }
  216. // One row per section
  217. else if (self.classes.count) {
  218. NSArray<FLEXMethod *> *methods = self.classesToMethods[indexPath.section];
  219. NSMutableString *summary = [NSMutableString new];
  220. [methods enumerateObjectsUsingBlock:^(FLEXMethod *method, NSUInteger idx, BOOL *stop) {
  221. NSString *format = nil;
  222. if (idx == methods.count-1) {
  223. format = @"%@%@";
  224. *stop = YES;
  225. } else if (idx < 3) {
  226. format = @"%@%@\n";
  227. } else {
  228. format = @"%@%@\n…";
  229. *stop = YES;
  230. }
  231. [summary appendFormat:format, method.isInstanceMethod ? @"-" : @"+", method.selectorString];
  232. }];
  233. cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
  234. cell.textLabel.text = self.classes[indexPath.section];
  235. cell.detailTextLabel.text = summary.length ? summary : nil;
  236. }
  237. else {
  238. @throw NSInternalInconsistencyException;
  239. }
  240. return cell;
  241. }
  242. - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
  243. if (self.classes || self.keyPath.methodKey) {
  244. return @" ";
  245. } else if (self.bundlesOrClasses) {
  246. NSInteger count = self.bundlesOrClasses.count;
  247. if (self.keyPath.classKey) {
  248. return FLEXPluralString(count, @"classes", @"class");
  249. } else {
  250. return FLEXPluralString(count, @"bundles", @"bundle");
  251. }
  252. }
  253. return [self.delegate tableView:tableView titleForHeaderInSection:section];
  254. }
  255. - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
  256. if (self.classes || self.keyPath.methodKey) {
  257. if (section == 0) {
  258. return 55;
  259. }
  260. return 0;
  261. }
  262. return 55;
  263. }
  264. #pragma mark UITableViewDelegate
  265. - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
  266. if (self.bundlesOrClasses) {
  267. NSString *bundleSuffixOrClass = self.bundlesOrClasses[indexPath.row];
  268. if (self.keyPath.classKey) {
  269. NSParameterAssert(NSClassFromString(bundleSuffixOrClass));
  270. [self.delegate didSelectClass:NSClassFromString(bundleSuffixOrClass)];
  271. } else {
  272. // Selected a bundle
  273. [self didSelectKeyPathOption:bundleSuffixOrClass];
  274. }
  275. } else {
  276. if (self.classes) {
  277. Class cls = NSClassFromString(self.classes[indexPath.section]);
  278. NSParameterAssert(cls);
  279. [self.delegate didSelectClass:cls];
  280. } else {
  281. @throw NSInternalInconsistencyException;
  282. }
  283. }
  284. }
  285. - (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath {
  286. NSString *bundleSuffixOrClass = self.bundlesOrClasses[indexPath.row];
  287. NSString *imagePath = [TBRuntimeController imagePathWithShortName:bundleSuffixOrClass];
  288. NSBundle *bundle = [NSBundle bundleWithPath:imagePath.stringByDeletingLastPathComponent];
  289. if (bundle) {
  290. [self.delegate didSelectBundle:bundle];
  291. } else {
  292. [self.delegate didSelectImagePath:imagePath shortName:bundleSuffixOrClass];
  293. }
  294. }
  295. @end