model.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. IIterator, IterableOrArrayLike, iter, map, toArray
  5. } from '@phosphor/algorithm';
  6. import {
  7. JSONExt
  8. } from '@phosphor/coreutils';
  9. import {
  10. StringExt
  11. } from '@phosphor/algorithm';
  12. import {
  13. ISignal, Signal
  14. } from '@phosphor/signaling';
  15. import {
  16. h
  17. } from '@phosphor/virtualdom';
  18. import {
  19. CompleterWidget
  20. } from './widget';
  21. /**
  22. * An implementation of a completer model.
  23. */
  24. export
  25. class CompleterModel implements CompleterWidget.IModel {
  26. /**
  27. * A signal emitted when state of the completer menu changes.
  28. */
  29. get stateChanged(): ISignal<this, void> {
  30. return this._stateChanged;
  31. }
  32. /**
  33. * The original completion request details.
  34. */
  35. get original(): CompleterWidget.ITextState {
  36. return this._original;
  37. }
  38. set original(newValue: CompleterWidget.ITextState) {
  39. let unchanged = this._original && newValue &&
  40. JSONExt.deepEqual(newValue, this._original);
  41. if (unchanged) {
  42. return;
  43. }
  44. this._reset();
  45. this._original = newValue;
  46. this._stateChanged.emit(void 0);
  47. }
  48. /**
  49. * The current text change details.
  50. */
  51. get current(): CompleterWidget.ITextState {
  52. return this._current;
  53. }
  54. set current(newValue: CompleterWidget.ITextState) {
  55. let unchanged = this._current && newValue &&
  56. JSONExt.deepEqual(newValue, this._current);
  57. if (unchanged) {
  58. return;
  59. }
  60. // Original request must always be set before a text change. If it isn't
  61. // the model fails silently.
  62. if (!this.original) {
  63. return;
  64. }
  65. // Cursor must always be set before a text change. This happens
  66. // automatically in the completer handler, but since `current` is a public
  67. // attribute, this defensive check is necessary.
  68. if (!this._cursor) {
  69. return;
  70. }
  71. this._current = newValue;
  72. if (!this._current) {
  73. this._stateChanged.emit(void 0);
  74. return;
  75. }
  76. let original = this._original;
  77. let current = this._current;
  78. let originalLine = original.text.split('\n')[original.line];
  79. let currentLine = current.text.split('\n')[current.line];
  80. // If the text change means that the original start point has been preceded,
  81. // then the completion is no longer valid and should be reset.
  82. if (currentLine.length < originalLine.length) {
  83. this.reset();
  84. return;
  85. }
  86. let { start, end } = this._cursor;
  87. // Clip the front of the current line.
  88. let query = current.text.substring(start);
  89. // Clip the back of the current line by calculating the end of the original.
  90. let ending = original.text.substring(end);
  91. query = query.substring(0, query.lastIndexOf(ending));
  92. this._query = query;
  93. this._stateChanged.emit(void 0);
  94. }
  95. /**
  96. * The cursor details that the API has used to return matching options.
  97. */
  98. get cursor(): CompleterWidget.ICursorSpan {
  99. return this._cursor;
  100. }
  101. set cursor(newValue: CompleterWidget.ICursorSpan) {
  102. // Original request must always be set before a cursor change. If it isn't
  103. // the model fails silently.
  104. if (!this.original) {
  105. return;
  106. }
  107. this._cursor = newValue;
  108. }
  109. /**
  110. * The query against which items are filtered.
  111. */
  112. get query(): string {
  113. return this._query;
  114. }
  115. set query(newValue: string) {
  116. this._query = newValue;
  117. }
  118. /**
  119. * A flag that is true when the model value was modified by a subset match.
  120. */
  121. get subsetMatch(): boolean {
  122. return this._subsetMatch;
  123. }
  124. set subsetMatch(newValue: boolean) {
  125. this._subsetMatch = newValue;
  126. }
  127. /**
  128. * Get whether the model is disposed.
  129. */
  130. get isDisposed(): boolean {
  131. return this._isDisposed;
  132. }
  133. /**
  134. * Dispose of the resources held by the model.
  135. */
  136. dispose(): void {
  137. // Do nothing if already disposed.
  138. if (this._isDisposed) {
  139. return;
  140. }
  141. this._isDisposed = true;
  142. Signal.clearData(this);
  143. }
  144. /**
  145. * The list of visible items in the completer menu.
  146. *
  147. * #### Notes
  148. * This is a read-only property.
  149. */
  150. items(): IIterator<CompleterWidget.IItem> {
  151. return this._filter();
  152. }
  153. /**
  154. * The unfiltered list of all available options in a completer menu.
  155. */
  156. options(): IIterator<string> {
  157. return iter(this._options);
  158. }
  159. /**
  160. * Set the avilable options in the completer menu.
  161. */
  162. setOptions(newValue: IterableOrArrayLike<string>) {
  163. let values = toArray(newValue || []);
  164. if (JSONExt.deepEqual(values, this._options)) {
  165. return;
  166. }
  167. if (values.length) {
  168. this._options = [];
  169. this._options.push(...values);
  170. this._subsetMatch = true;
  171. } else {
  172. this._options = [];
  173. }
  174. this._stateChanged.emit(void 0);
  175. }
  176. /**
  177. * Handle a text change.
  178. */
  179. handleTextChange(request: CompleterWidget.ITextState): void {
  180. // When the completer detects a common subset prefix for all options,
  181. // it updates the model and sets the model source to that value, but this
  182. // text change should be ignored.
  183. if (this.subsetMatch) {
  184. return;
  185. }
  186. let { text, column } = request;
  187. // If last character entered is not whitespace, update completion.
  188. if (text[column - 1] && text[column - 1].match(/\S/)) {
  189. // If there is currently an active completion, update the current state.
  190. if (this.original) {
  191. this.current = request;
  192. }
  193. } else {
  194. // If final character is whitespace, reset completion.
  195. this.reset();
  196. }
  197. }
  198. /**
  199. * Create a resolved patch between the original state and a patch string.
  200. *
  201. * @param patch - The patch string to apply to the original value.
  202. *
  203. * @returns A patched text change or null if original value did not exist.
  204. */
  205. createPatch(patch: string): CompleterWidget.IPatch {
  206. let original = this._original;
  207. let cursor = this._cursor;
  208. if (!original || !cursor) {
  209. return null;
  210. }
  211. let prefix = original.text.substring(0, cursor.start);
  212. let suffix = original.text.substring(cursor.end);
  213. return { offset: (prefix + patch).length, text: prefix + patch + suffix };
  214. }
  215. /**
  216. * Reset the state of the model and emit a state change signal.
  217. */
  218. reset() {
  219. this._reset();
  220. this._stateChanged.emit(void 0);
  221. }
  222. /**
  223. * Apply the query to the complete options list to return the matching subset.
  224. */
  225. private _filter(): IIterator<CompleterWidget.IItem> {
  226. let options = this._options || [];
  227. let query = this._query;
  228. if (!query) {
  229. return map(options, option => ({ raw: option, text: option }));
  230. }
  231. let results: Private.IMatch[] = [];
  232. for (let option of options) {
  233. let match = StringExt.matchSumOfSquares(option, query);
  234. if (match) {
  235. let marked = StringExt.highlight(option, match.indices, Private.mark);
  236. results.push({
  237. raw: option,
  238. score: match.score,
  239. text: marked.join('')
  240. });
  241. }
  242. }
  243. return map(results.sort(Private.scoreCmp), result =>
  244. ({ text: result.text, raw: result.raw })
  245. );
  246. }
  247. /**
  248. * Reset the state of the model.
  249. */
  250. private _reset(): void {
  251. this._current = null;
  252. this._cursor = null;
  253. this._options = [];
  254. this._original = null;
  255. this._query = '';
  256. this._subsetMatch = false;
  257. }
  258. private _current: CompleterWidget.ITextState = null;
  259. private _cursor: CompleterWidget.ICursorSpan = null;
  260. private _isDisposed = false;
  261. private _options: string[] = [];
  262. private _original: CompleterWidget.ITextState = null;
  263. private _query = '';
  264. private _subsetMatch = false;
  265. private _stateChanged = new Signal<this, void>(this);
  266. }
  267. /**
  268. * A namespace for completer model private data.
  269. */
  270. namespace Private {
  271. /**
  272. * A filtered completion menu matching result.
  273. */
  274. export
  275. interface IMatch {
  276. /**
  277. * The raw text of a completion match.
  278. */
  279. raw: string;
  280. /**
  281. * A score which indicates the strength of the match.
  282. *
  283. * A lower score is better. Zero is the best possible score.
  284. */
  285. score: number;
  286. /**
  287. * The highlighted text of a completion match.
  288. */
  289. text: string;
  290. }
  291. /**
  292. * Mark a highlighted chunk of text.
  293. */
  294. export
  295. function mark(value: string): string {
  296. return `<mark>${value}</mark>`;
  297. }
  298. /**
  299. * A sort comparison function for item match scores.
  300. *
  301. * #### Notes
  302. * This orders the items first based on score (lower is better), then
  303. * by locale order of the item text.
  304. */
  305. export
  306. function scoreCmp(a: IMatch, b: IMatch): number {
  307. let delta = a.score - b.score;
  308. if (delta !== 0) {
  309. return delta;
  310. }
  311. return a.raw.localeCompare(b.raw);
  312. }
  313. }