├── .gitignore
├── CHANGELOG.md
├── COPYING
├── LICENSE.md
├── README-test.md
├── README.md
├── app.js
├── bin
└── tome.js
├── camelize.js
├── clipboard.js
├── debug-keycodes.js
├── editor.js
├── find.js
├── handlers
├── back.js
├── backspace.js
├── block.js
├── close.js
├── closed-block.js
├── copy.js
├── cut.js
├── debug.js
├── down.js
├── end-of-line.js
├── escape.js
├── find-again.js
├── find.js
├── forward.js
├── language.js
├── page-down.js
├── page-up.js
├── paste.js
├── redo.js
├── return.js
├── save.js
├── shift-selection-left.js
├── shift-selection-right.js
├── start-of-line.js
├── tab.js
├── toggle-comment.js
├── type.js
├── undo.js
└── up.js
├── languages
├── default.js
└── javascript.js
├── load-handler-factories.js
├── load-languages.js
├── package-lock.json
├── package.json
├── readline-test.js
├── screen.js
├── select.js
├── selectors-by-name.js
├── starts-with.js
├── state-styles.js
└── tome-screenshot-2.png
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | *.swp
3 | /test
4 | /test1
5 | /test2
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.13.1
4 |
5 | * Fix bug where regular expression literals can be misparsed.
6 |
7 | ## 0.13.0
8 |
9 | * Use terminal escape sequences to scroll up and down, reducing overhead.
10 | Unfortunately there's just no such thing for scrolling left or right.
11 |
12 | ## 0.11.0
13 |
14 | * Control-Q only offers to save if changes have been made.
15 |
16 | ## 0.10.0
17 |
18 | * Control-L toggles between languages. Useful when editing a standalone
19 | utility written in Node.js, since the filename will not have a `.js` extension.
20 |
21 | ## 0.9.0
22 |
23 | * Control-B now bounces between opening and closing marks like
24 | `{}`, `[]` and `()`. Pressing this key currently only makes sense
25 | when the cursor is on such a mark.
26 |
27 | ## 0.8.1
28 |
29 | * `.` is not a sensible character before a regexp. Accepting it as
30 | such causes confusion when it appears inside a regexp just before
31 | the closing `/`.
32 | * Never treat the closing `/` of a regexp as the potential start
33 | of a new one in any analysis. This implementation of regexp highlighting
34 | is obviously clunky and full of edge cases, eventually full JS parsing
35 | will do a better job.
36 |
37 | ## 0.8.0
38 |
39 | * Fixed several bugs with "undo" for plain ol' typing.
40 | * `({` no longer results in double indentation.
41 | * `[` and `]` can be used to shift the currently selected text one tabstop at a time.
42 | * Page-Up and Page-Down can contribute to text selection.
43 | * Contextual help for selection mode.
44 |
45 | ## 0.7.3
46 |
47 | * Regular expressions beginning with an \ escape sequence are now highlighted properly.
48 | * Character ranges [...] in regular expressions may now contain un-escaped / charcters.
49 |
50 | ## 0.7.2
51 |
52 | * Just packaging stuff, no code changes.
53 |
54 | ## 0.7.1
55 |
56 | * Fixed bug in horizontal scrolling.
57 |
58 | ## 0.7.0
59 |
60 | * Syntax highlighting for regular expressions. Gee, that one thing was actually quite difficult without
61 | a real JavaScript parser. To take this to the next step I'll have to implement a real-ish JavaScript
62 | parser. Without it, I'd probably never be able to do proper keyword highlighting, recognize when a
63 | keyword is merely a property name in an object literal, etc.
64 | * Better colors. Hey, it's the little things.
65 |
66 | ## 0.6.0
67 |
68 | * Language engines. The JavaScript engine is factored out to `language/javascript.js` and is
69 | triggered by file extension, so it no longer interferes with editing plaintext. For other
70 | extensions, the default language engine does nothing, as a safe fallback and as further documentation
71 | of the interface for language engines.
72 |
73 | ## 0.5.0, 0.5.1 (2023-10-14)
74 |
75 | * Syntax-aware, with basic syntax highlighting. Indentation now takes into account `{` `[` and `(` and
76 | understands when these are part of a quoted string and should not affect code indentation. All three
77 | types of quoted strings are supported. Invalid syntax is called out in no uncertain terms. Maybe this
78 | is too much if you're still typing something and haven't had a chance to close a bracket yet...
79 | will think about that.
80 |
81 | This version is much more specific to JavaScript because the syntax highlighting is strict and I haven't
82 | yet distinguished JavaScript from editing plaintext. That will happen next.
83 |
84 | ## 0.4.1 (2023-09-30)
85 |
86 | * ESC key followed instantly by arrow key now works as expected.
87 |
88 | ## 0.4.0 (2023-09-30)
89 |
90 | * Search and Replace.
91 |
92 | ## 0.3.2 (2023-09-29)
93 |
94 | * Always update cursor position in `draw`.
95 |
96 | ## 0.3.1 (2023-09-29)
97 |
98 | * Happy birthday to me!
99 | * Fixed a few errata from last night's virtual screen release.
100 |
101 | ## 0.3.0 (2023-09-28)
102 |
103 | * Redraw only where changes occur using virtual screen, also cleans up the main editor draw logic a lot.
104 | A lot more can be done here, like detecting when it's smart to scroll the screen with a single escape code.
105 |
106 | ## 0.2.6 (2023-09-25)
107 |
108 | * One more stab at a possible race condition causing dropped keystrokes,
109 | sorry about all these dumb version bumps to test over ssh
110 |
111 | ## 0.2.5 (2023-09-25)
112 |
113 | * Don't drop keystrokes between calls to `getKey`
114 |
115 | ## 0.2.3 (2023-09-25)
116 |
117 | * A proper fix for the Mystery Spaces race condition this time, without breaking
118 | nested experiences. `getKey()` is for everybody!
119 |
120 | ## 0.2.2 (2023-09-25)
121 |
122 | * Oops, broke `control-q` and `control-f` when I started awaiting the outcome of each keystroke because they nest editing experiences that require keystrokes.
123 |
124 | ## 0.2.1 (2023-09-25)
125 |
126 | * Fixed a subtle race condition that led to unexpected spaces onscreen when typing very fast.
127 |
128 | ## 0.2.0 (2023-09-24)
129 |
130 | * Use the built-in `readline` module to handle keyboard escape sequences correctly even when
131 | individual keys arrive in pieces.
132 | * Use `ansi-escape` and `ansi-styles` rather than my amusing but wholly incomplete termcap
133 | implementation, which was scribbled on a plane ride without Internet, based only on manpages.
134 | * ES modules, to be compatible with the above and generally in touch with 2023.
135 |
136 | ## 0.1.5 (2023-09-23)
137 |
138 | * Behaves correctly when you type fast enough to deliver several keystrokes a single
139 | `data` event.
140 |
--------------------------------------------------------------------------------
/COPYING:
--------------------------------------------------------------------------------
1 | GNU General Public License
2 | ==========================
3 |
4 | _Version 3, 29 June 2007_
5 | _Copyright © 2007 Free Software Foundation, Inc. <>_
6 |
7 | Everyone is permitted to copy and distribute verbatim copies of this license
8 | document, but changing it is not allowed.
9 |
10 | ## Preamble
11 |
12 | The GNU General Public License is a free, copyleft license for software and other
13 | kinds of works.
14 |
15 | The licenses for most software and other practical works are designed to take away
16 | your freedom to share and change the works. By contrast, the GNU General Public
17 | License is intended to guarantee your freedom to share and change all versions of a
18 | program--to make sure it remains free software for all its users. We, the Free
19 | Software Foundation, use the GNU General Public License for most of our software; it
20 | applies also to any other work released this way by its authors. You can apply it to
21 | your programs, too.
22 |
23 | When we speak of free software, we are referring to freedom, not price. Our General
24 | Public Licenses are designed to make sure that you have the freedom to distribute
25 | copies of free software (and charge for them if you wish), that you receive source
26 | code or can get it if you want it, that you can change the software or use pieces of
27 | it in new free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you these rights or
30 | asking you to surrender the rights. Therefore, you have certain responsibilities if
31 | you distribute copies of the software, or if you modify it: responsibilities to
32 | respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether gratis or for a fee,
35 | you must pass on to the recipients the same freedoms that you received. You must make
36 | sure that they, too, receive or can get the source code. And you must show them these
37 | terms so they know their rights.
38 |
39 | Developers that use the GNU GPL protect your rights with two steps: **(1)** assert
40 | copyright on the software, and **(2)** offer you this License giving you legal permission
41 | to copy, distribute and/or modify it.
42 |
43 | For the developers' and authors' protection, the GPL clearly explains that there is
44 | no warranty for this free software. For both users' and authors' sake, the GPL
45 | requires that modified versions be marked as changed, so that their problems will not
46 | be attributed erroneously to authors of previous versions.
47 |
48 | Some devices are designed to deny users access to install or run modified versions of
49 | the software inside them, although the manufacturer can do so. This is fundamentally
50 | incompatible with the aim of protecting users' freedom to change the software. The
51 | systematic pattern of such abuse occurs in the area of products for individuals to
52 | use, which is precisely where it is most unacceptable. Therefore, we have designed
53 | this version of the GPL to prohibit the practice for those products. If such problems
54 | arise substantially in other domains, we stand ready to extend this provision to
55 | those domains in future versions of the GPL, as needed to protect the freedom of
56 | users.
57 |
58 | Finally, every program is threatened constantly by software patents. States should
59 | not allow patents to restrict development and use of software on general-purpose
60 | computers, but in those that do, we wish to avoid the special danger that patents
61 | applied to a free program could make it effectively proprietary. To prevent this, the
62 | GPL assures that patents cannot be used to render the program non-free.
63 |
64 | The precise terms and conditions for copying, distribution and modification follow.
65 |
66 | ## TERMS AND CONDITIONS
67 |
68 | ### 0. Definitions
69 |
70 | “This License” refers to version 3 of the GNU General Public License.
71 |
72 | “Copyright” also means copyright-like laws that apply to other kinds of
73 | works, such as semiconductor masks.
74 |
75 | “The Program” refers to any copyrightable work licensed under this
76 | License. Each licensee is addressed as “you”. “Licensees” and
77 | “recipients” may be individuals or organizations.
78 |
79 | To “modify” a work means to copy from or adapt all or part of the work in
80 | a fashion requiring copyright permission, other than the making of an exact copy. The
81 | resulting work is called a “modified version” of the earlier work or a
82 | work “based on” the earlier work.
83 |
84 | A “covered work” means either the unmodified Program or a work based on
85 | the Program.
86 |
87 | To “propagate” a work means to do anything with it that, without
88 | permission, would make you directly or secondarily liable for infringement under
89 | applicable copyright law, except executing it on a computer or modifying a private
90 | copy. Propagation includes copying, distribution (with or without modification),
91 | making available to the public, and in some countries other activities as well.
92 |
93 | To “convey” a work means any kind of propagation that enables other
94 | parties to make or receive copies. Mere interaction with a user through a computer
95 | network, with no transfer of a copy, is not conveying.
96 |
97 | An interactive user interface displays “Appropriate Legal Notices” to the
98 | extent that it includes a convenient and prominently visible feature that **(1)**
99 | displays an appropriate copyright notice, and **(2)** tells the user that there is no
100 | warranty for the work (except to the extent that warranties are provided), that
101 | licensees may convey the work under this License, and how to view a copy of this
102 | License. If the interface presents a list of user commands or options, such as a
103 | menu, a prominent item in the list meets this criterion.
104 |
105 | ### 1. Source Code
106 |
107 | The “source code” for a work means the preferred form of the work for
108 | making modifications to it. “Object code” means any non-source form of a
109 | work.
110 |
111 | A “Standard Interface” means an interface that either is an official
112 | standard defined by a recognized standards body, or, in the case of interfaces
113 | specified for a particular programming language, one that is widely used among
114 | developers working in that language.
115 |
116 | The “System Libraries” of an executable work include anything, other than
117 | the work as a whole, that **(a)** is included in the normal form of packaging a Major
118 | Component, but which is not part of that Major Component, and **(b)** serves only to
119 | enable use of the work with that Major Component, or to implement a Standard
120 | Interface for which an implementation is available to the public in source code form.
121 | A “Major Component”, in this context, means a major essential component
122 | (kernel, window system, and so on) of the specific operating system (if any) on which
123 | the executable work runs, or a compiler used to produce the work, or an object code
124 | interpreter used to run it.
125 |
126 | The “Corresponding Source” for a work in object code form means all the
127 | source code needed to generate, install, and (for an executable work) run the object
128 | code and to modify the work, including scripts to control those activities. However,
129 | it does not include the work's System Libraries, or general-purpose tools or
130 | generally available free programs which are used unmodified in performing those
131 | activities but which are not part of the work. For example, Corresponding Source
132 | includes interface definition files associated with source files for the work, and
133 | the source code for shared libraries and dynamically linked subprograms that the work
134 | is specifically designed to require, such as by intimate data communication or
135 | control flow between those subprograms and other parts of the work.
136 |
137 | The Corresponding Source need not include anything that users can regenerate
138 | automatically from other parts of the Corresponding Source.
139 |
140 | The Corresponding Source for a work in source code form is that same work.
141 |
142 | ### 2. Basic Permissions
143 |
144 | All rights granted under this License are granted for the term of copyright on the
145 | Program, and are irrevocable provided the stated conditions are met. This License
146 | explicitly affirms your unlimited permission to run the unmodified Program. The
147 | output from running a covered work is covered by this License only if the output,
148 | given its content, constitutes a covered work. This License acknowledges your rights
149 | of fair use or other equivalent, as provided by copyright law.
150 |
151 | You may make, run and propagate covered works that you do not convey, without
152 | conditions so long as your license otherwise remains in force. You may convey covered
153 | works to others for the sole purpose of having them make modifications exclusively
154 | for you, or provide you with facilities for running those works, provided that you
155 | comply with the terms of this License in conveying all material for which you do not
156 | control copyright. Those thus making or running the covered works for you must do so
157 | exclusively on your behalf, under your direction and control, on terms that prohibit
158 | them from making any copies of your copyrighted material outside their relationship
159 | with you.
160 |
161 | Conveying under any other circumstances is permitted solely under the conditions
162 | stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
163 |
164 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
165 |
166 | No covered work shall be deemed part of an effective technological measure under any
167 | applicable law fulfilling obligations under article 11 of the WIPO copyright treaty
168 | adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention
169 | of such measures.
170 |
171 | When you convey a covered work, you waive any legal power to forbid circumvention of
172 | technological measures to the extent such circumvention is effected by exercising
173 | rights under this License with respect to the covered work, and you disclaim any
174 | intention to limit operation or modification of the work as a means of enforcing,
175 | against the work's users, your or third parties' legal rights to forbid circumvention
176 | of technological measures.
177 |
178 | ### 4. Conveying Verbatim Copies
179 |
180 | You may convey verbatim copies of the Program's source code as you receive it, in any
181 | medium, provided that you conspicuously and appropriately publish on each copy an
182 | appropriate copyright notice; keep intact all notices stating that this License and
183 | any non-permissive terms added in accord with section 7 apply to the code; keep
184 | intact all notices of the absence of any warranty; and give all recipients a copy of
185 | this License along with the Program.
186 |
187 | You may charge any price or no price for each copy that you convey, and you may offer
188 | support or warranty protection for a fee.
189 |
190 | ### 5. Conveying Modified Source Versions
191 |
192 | You may convey a work based on the Program, or the modifications to produce it from
193 | the Program, in the form of source code under the terms of section 4, provided that
194 | you also meet all of these conditions:
195 |
196 | * **a)** The work must carry prominent notices stating that you modified it, and giving a
197 | relevant date.
198 | * **b)** The work must carry prominent notices stating that it is released under this
199 | License and any conditions added under section 7. This requirement modifies the
200 | requirement in section 4 to “keep intact all notices”.
201 | * **c)** You must license the entire work, as a whole, under this License to anyone who
202 | comes into possession of a copy. This License will therefore apply, along with any
203 | applicable section 7 additional terms, to the whole of the work, and all its parts,
204 | regardless of how they are packaged. This License gives no permission to license the
205 | work in any other way, but it does not invalidate such permission if you have
206 | separately received it.
207 | * **d)** If the work has interactive user interfaces, each must display Appropriate Legal
208 | Notices; however, if the Program has interactive interfaces that do not display
209 | Appropriate Legal Notices, your work need not make them do so.
210 |
211 | A compilation of a covered work with other separate and independent works, which are
212 | not by their nature extensions of the covered work, and which are not combined with
213 | it such as to form a larger program, in or on a volume of a storage or distribution
214 | medium, is called an “aggregate” if the compilation and its resulting
215 | copyright are not used to limit the access or legal rights of the compilation's users
216 | beyond what the individual works permit. Inclusion of a covered work in an aggregate
217 | does not cause this License to apply to the other parts of the aggregate.
218 |
219 | ### 6. Conveying Non-Source Forms
220 |
221 | You may convey a covered work in object code form under the terms of sections 4 and
222 | 5, provided that you also convey the machine-readable Corresponding Source under the
223 | terms of this License, in one of these ways:
224 |
225 | * **a)** Convey the object code in, or embodied in, a physical product (including a
226 | physical distribution medium), accompanied by the Corresponding Source fixed on a
227 | durable physical medium customarily used for software interchange.
228 | * **b)** Convey the object code in, or embodied in, a physical product (including a
229 | physical distribution medium), accompanied by a written offer, valid for at least
230 | three years and valid for as long as you offer spare parts or customer support for
231 | that product model, to give anyone who possesses the object code either **(1)** a copy of
232 | the Corresponding Source for all the software in the product that is covered by this
233 | License, on a durable physical medium customarily used for software interchange, for
234 | a price no more than your reasonable cost of physically performing this conveying of
235 | source, or **(2)** access to copy the Corresponding Source from a network server at no
236 | charge.
237 | * **c)** Convey individual copies of the object code with a copy of the written offer to
238 | provide the Corresponding Source. This alternative is allowed only occasionally and
239 | noncommercially, and only if you received the object code with such an offer, in
240 | accord with subsection 6b.
241 | * **d)** Convey the object code by offering access from a designated place (gratis or for
242 | a charge), and offer equivalent access to the Corresponding Source in the same way
243 | through the same place at no further charge. You need not require recipients to copy
244 | the Corresponding Source along with the object code. If the place to copy the object
245 | code is a network server, the Corresponding Source may be on a different server
246 | (operated by you or a third party) that supports equivalent copying facilities,
247 | provided you maintain clear directions next to the object code saying where to find
248 | the Corresponding Source. Regardless of what server hosts the Corresponding Source,
249 | you remain obligated to ensure that it is available for as long as needed to satisfy
250 | these requirements.
251 | * **e)** Convey the object code using peer-to-peer transmission, provided you inform
252 | other peers where the object code and Corresponding Source of the work are being
253 | offered to the general public at no charge under subsection 6d.
254 |
255 | A separable portion of the object code, whose source code is excluded from the
256 | Corresponding Source as a System Library, need not be included in conveying the
257 | object code work.
258 |
259 | A “User Product” is either **(1)** a “consumer product”, which
260 | means any tangible personal property which is normally used for personal, family, or
261 | household purposes, or **(2)** anything designed or sold for incorporation into a
262 | dwelling. In determining whether a product is a consumer product, doubtful cases
263 | shall be resolved in favor of coverage. For a particular product received by a
264 | particular user, “normally used” refers to a typical or common use of
265 | that class of product, regardless of the status of the particular user or of the way
266 | in which the particular user actually uses, or expects or is expected to use, the
267 | product. A product is a consumer product regardless of whether the product has
268 | substantial commercial, industrial or non-consumer uses, unless such uses represent
269 | the only significant mode of use of the product.
270 |
271 | “Installation Information” for a User Product means any methods,
272 | procedures, authorization keys, or other information required to install and execute
273 | modified versions of a covered work in that User Product from a modified version of
274 | its Corresponding Source. The information must suffice to ensure that the continued
275 | functioning of the modified object code is in no case prevented or interfered with
276 | solely because modification has been made.
277 |
278 | If you convey an object code work under this section in, or with, or specifically for
279 | use in, a User Product, and the conveying occurs as part of a transaction in which
280 | the right of possession and use of the User Product is transferred to the recipient
281 | in perpetuity or for a fixed term (regardless of how the transaction is
282 | characterized), the Corresponding Source conveyed under this section must be
283 | accompanied by the Installation Information. But this requirement does not apply if
284 | neither you nor any third party retains the ability to install modified object code
285 | on the User Product (for example, the work has been installed in ROM).
286 |
287 | The requirement to provide Installation Information does not include a requirement to
288 | continue to provide support service, warranty, or updates for a work that has been
289 | modified or installed by the recipient, or for the User Product in which it has been
290 | modified or installed. Access to a network may be denied when the modification itself
291 | materially and adversely affects the operation of the network or violates the rules
292 | and protocols for communication across the network.
293 |
294 | Corresponding Source conveyed, and Installation Information provided, in accord with
295 | this section must be in a format that is publicly documented (and with an
296 | implementation available to the public in source code form), and must require no
297 | special password or key for unpacking, reading or copying.
298 |
299 | ### 7. Additional Terms
300 |
301 | “Additional permissions” are terms that supplement the terms of this
302 | License by making exceptions from one or more of its conditions. Additional
303 | permissions that are applicable to the entire Program shall be treated as though they
304 | were included in this License, to the extent that they are valid under applicable
305 | law. If additional permissions apply only to part of the Program, that part may be
306 | used separately under those permissions, but the entire Program remains governed by
307 | this License without regard to the additional permissions.
308 |
309 | When you convey a copy of a covered work, you may at your option remove any
310 | additional permissions from that copy, or from any part of it. (Additional
311 | permissions may be written to require their own removal in certain cases when you
312 | modify the work.) You may place additional permissions on material, added by you to a
313 | covered work, for which you have or can give appropriate copyright permission.
314 |
315 | Notwithstanding any other provision of this License, for material you add to a
316 | covered work, you may (if authorized by the copyright holders of that material)
317 | supplement the terms of this License with terms:
318 |
319 | * **a)** Disclaiming warranty or limiting liability differently from the terms of
320 | sections 15 and 16 of this License; or
321 | * **b)** Requiring preservation of specified reasonable legal notices or author
322 | attributions in that material or in the Appropriate Legal Notices displayed by works
323 | containing it; or
324 | * **c)** Prohibiting misrepresentation of the origin of that material, or requiring that
325 | modified versions of such material be marked in reasonable ways as different from the
326 | original version; or
327 | * **d)** Limiting the use for publicity purposes of names of licensors or authors of the
328 | material; or
329 | * **e)** Declining to grant rights under trademark law for use of some trade names,
330 | trademarks, or service marks; or
331 | * **f)** Requiring indemnification of licensors and authors of that material by anyone
332 | who conveys the material (or modified versions of it) with contractual assumptions of
333 | liability to the recipient, for any liability that these contractual assumptions
334 | directly impose on those licensors and authors.
335 |
336 | All other non-permissive additional terms are considered “further
337 | restrictions” within the meaning of section 10. If the Program as you received
338 | it, or any part of it, contains a notice stating that it is governed by this License
339 | along with a term that is a further restriction, you may remove that term. If a
340 | license document contains a further restriction but permits relicensing or conveying
341 | under this License, you may add to a covered work material governed by the terms of
342 | that license document, provided that the further restriction does not survive such
343 | relicensing or conveying.
344 |
345 | If you add terms to a covered work in accord with this section, you must place, in
346 | the relevant source files, a statement of the additional terms that apply to those
347 | files, or a notice indicating where to find the applicable terms.
348 |
349 | Additional terms, permissive or non-permissive, may be stated in the form of a
350 | separately written license, or stated as exceptions; the above requirements apply
351 | either way.
352 |
353 | ### 8. Termination
354 |
355 | You may not propagate or modify a covered work except as expressly provided under
356 | this License. Any attempt otherwise to propagate or modify it is void, and will
357 | automatically terminate your rights under this License (including any patent licenses
358 | granted under the third paragraph of section 11).
359 |
360 | However, if you cease all violation of this License, then your license from a
361 | particular copyright holder is reinstated **(a)** provisionally, unless and until the
362 | copyright holder explicitly and finally terminates your license, and **(b)** permanently,
363 | if the copyright holder fails to notify you of the violation by some reasonable means
364 | prior to 60 days after the cessation.
365 |
366 | Moreover, your license from a particular copyright holder is reinstated permanently
367 | if the copyright holder notifies you of the violation by some reasonable means, this
368 | is the first time you have received notice of violation of this License (for any
369 | work) from that copyright holder, and you cure the violation prior to 30 days after
370 | your receipt of the notice.
371 |
372 | Termination of your rights under this section does not terminate the licenses of
373 | parties who have received copies or rights from you under this License. If your
374 | rights have been terminated and not permanently reinstated, you do not qualify to
375 | receive new licenses for the same material under section 10.
376 |
377 | ### 9. Acceptance Not Required for Having Copies
378 |
379 | You are not required to accept this License in order to receive or run a copy of the
380 | Program. Ancillary propagation of a covered work occurring solely as a consequence of
381 | using peer-to-peer transmission to receive a copy likewise does not require
382 | acceptance. However, nothing other than this License grants you permission to
383 | propagate or modify any covered work. These actions infringe copyright if you do not
384 | accept this License. Therefore, by modifying or propagating a covered work, you
385 | indicate your acceptance of this License to do so.
386 |
387 | ### 10. Automatic Licensing of Downstream Recipients
388 |
389 | Each time you convey a covered work, the recipient automatically receives a license
390 | from the original licensors, to run, modify and propagate that work, subject to this
391 | License. You are not responsible for enforcing compliance by third parties with this
392 | License.
393 |
394 | An “entity transaction” is a transaction transferring control of an
395 | organization, or substantially all assets of one, or subdividing an organization, or
396 | merging organizations. If propagation of a covered work results from an entity
397 | transaction, each party to that transaction who receives a copy of the work also
398 | receives whatever licenses to the work the party's predecessor in interest had or
399 | could give under the previous paragraph, plus a right to possession of the
400 | Corresponding Source of the work from the predecessor in interest, if the predecessor
401 | has it or can get it with reasonable efforts.
402 |
403 | You may not impose any further restrictions on the exercise of the rights granted or
404 | affirmed under this License. For example, you may not impose a license fee, royalty,
405 | or other charge for exercise of rights granted under this License, and you may not
406 | initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging
407 | that any patent claim is infringed by making, using, selling, offering for sale, or
408 | importing the Program or any portion of it.
409 |
410 | ### 11. Patents
411 |
412 | A “contributor” is a copyright holder who authorizes use under this
413 | License of the Program or a work on which the Program is based. The work thus
414 | licensed is called the contributor's “contributor version”.
415 |
416 | A contributor's “essential patent claims” are all patent claims owned or
417 | controlled by the contributor, whether already acquired or hereafter acquired, that
418 | would be infringed by some manner, permitted by this License, of making, using, or
419 | selling its contributor version, but do not include claims that would be infringed
420 | only as a consequence of further modification of the contributor version. For
421 | purposes of this definition, “control” includes the right to grant patent
422 | sublicenses in a manner consistent with the requirements of this License.
423 |
424 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent license
425 | under the contributor's essential patent claims, to make, use, sell, offer for sale,
426 | import and otherwise run, modify and propagate the contents of its contributor
427 | version.
428 |
429 | In the following three paragraphs, a “patent license” is any express
430 | agreement or commitment, however denominated, not to enforce a patent (such as an
431 | express permission to practice a patent or covenant not to sue for patent
432 | infringement). To “grant” such a patent license to a party means to make
433 | such an agreement or commitment not to enforce a patent against the party.
434 |
435 | If you convey a covered work, knowingly relying on a patent license, and the
436 | Corresponding Source of the work is not available for anyone to copy, free of charge
437 | and under the terms of this License, through a publicly available network server or
438 | other readily accessible means, then you must either **(1)** cause the Corresponding
439 | Source to be so available, or **(2)** arrange to deprive yourself of the benefit of the
440 | patent license for this particular work, or **(3)** arrange, in a manner consistent with
441 | the requirements of this License, to extend the patent license to downstream
442 | recipients. “Knowingly relying” means you have actual knowledge that, but
443 | for the patent license, your conveying the covered work in a country, or your
444 | recipient's use of the covered work in a country, would infringe one or more
445 | identifiable patents in that country that you have reason to believe are valid.
446 |
447 | If, pursuant to or in connection with a single transaction or arrangement, you
448 | convey, or propagate by procuring conveyance of, a covered work, and grant a patent
449 | license to some of the parties receiving the covered work authorizing them to use,
450 | propagate, modify or convey a specific copy of the covered work, then the patent
451 | license you grant is automatically extended to all recipients of the covered work and
452 | works based on it.
453 |
454 | A patent license is “discriminatory” if it does not include within the
455 | scope of its coverage, prohibits the exercise of, or is conditioned on the
456 | non-exercise of one or more of the rights that are specifically granted under this
457 | License. You may not convey a covered work if you are a party to an arrangement with
458 | a third party that is in the business of distributing software, under which you make
459 | payment to the third party based on the extent of your activity of conveying the
460 | work, and under which the third party grants, to any of the parties who would receive
461 | the covered work from you, a discriminatory patent license **(a)** in connection with
462 | copies of the covered work conveyed by you (or copies made from those copies), or **(b)**
463 | primarily for and in connection with specific products or compilations that contain
464 | the covered work, unless you entered into that arrangement, or that patent license
465 | was granted, prior to 28 March 2007.
466 |
467 | Nothing in this License shall be construed as excluding or limiting any implied
468 | license or other defenses to infringement that may otherwise be available to you
469 | under applicable patent law.
470 |
471 | ### 12. No Surrender of Others' Freedom
472 |
473 | If conditions are imposed on you (whether by court order, agreement or otherwise)
474 | that contradict the conditions of this License, they do not excuse you from the
475 | conditions of this License. If you cannot convey a covered work so as to satisfy
476 | simultaneously your obligations under this License and any other pertinent
477 | obligations, then as a consequence you may not convey it at all. For example, if you
478 | agree to terms that obligate you to collect a royalty for further conveying from
479 | those to whom you convey the Program, the only way you could satisfy both those terms
480 | and this License would be to refrain entirely from conveying the Program.
481 |
482 | ### 13. Use with the GNU Affero General Public License
483 |
484 | Notwithstanding any other provision of this License, you have permission to link or
485 | combine any covered work with a work licensed under version 3 of the GNU Affero
486 | General Public License into a single combined work, and to convey the resulting work.
487 | The terms of this License will continue to apply to the part which is the covered
488 | work, but the special requirements of the GNU Affero General Public License, section
489 | 13, concerning interaction through a network will apply to the combination as such.
490 |
491 | ### 14. Revised Versions of this License
492 |
493 | The Free Software Foundation may publish revised and/or new versions of the GNU
494 | General Public License from time to time. Such new versions will be similar in spirit
495 | to the present version, but may differ in detail to address new problems or concerns.
496 |
497 | Each version is given a distinguishing version number. If the Program specifies that
498 | a certain numbered version of the GNU General Public License “or any later
499 | version” applies to it, you have the option of following the terms and
500 | conditions either of that numbered version or of any later version published by the
501 | Free Software Foundation. If the Program does not specify a version number of the GNU
502 | General Public License, you may choose any version ever published by the Free
503 | Software Foundation.
504 |
505 | If the Program specifies that a proxy can decide which future versions of the GNU
506 | General Public License can be used, that proxy's public statement of acceptance of a
507 | version permanently authorizes you to choose that version for the Program.
508 |
509 | Later license versions may give you additional or different permissions. However, no
510 | additional obligations are imposed on any author or copyright holder as a result of
511 | your choosing to follow a later version.
512 |
513 | ### 15. Disclaimer of Warranty
514 |
515 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
516 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
517 | PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER
518 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
519 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE
520 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
521 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
522 |
523 | ### 16. Limitation of Liability
524 |
525 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY
526 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS
527 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
528 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
529 | PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE
530 | OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE
531 | WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
532 | POSSIBILITY OF SUCH DAMAGES.
533 |
534 | ### 17. Interpretation of Sections 15 and 16
535 |
536 | If the disclaimer of warranty and limitation of liability provided above cannot be
537 | given local legal effect according to their terms, reviewing courts shall apply local
538 | law that most closely approximates an absolute waiver of all civil liability in
539 | connection with the Program, unless a warranty or assumption of liability accompanies
540 | a copy of the Program in return for a fee.
541 |
542 | _END OF TERMS AND CONDITIONS_
543 |
544 | ## How to Apply These Terms to Your New Programs
545 |
546 | If you develop a new program, and you want it to be of the greatest possible use to
547 | the public, the best way to achieve this is to make it free software which everyone
548 | can redistribute and change under these terms.
549 |
550 | To do so, attach the following notices to the program. It is safest to attach them
551 | to the start of each source file to most effectively state the exclusion of warranty;
552 | and each file should have at least the “copyright” line and a pointer to
553 | where the full notice is found.
554 |
555 |
556 | Copyright (C)
557 |
558 | This program is free software: you can redistribute it and/or modify
559 | it under the terms of the GNU General Public License as published by
560 | the Free Software Foundation, either version 3 of the License, or
561 | (at your option) any later version.
562 |
563 | This program is distributed in the hope that it will be useful,
564 | but WITHOUT ANY WARRANTY; without even the implied warranty of
565 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
566 | GNU General Public License for more details.
567 |
568 | You should have received a copy of the GNU General Public License
569 | along with this program. If not, see .
570 |
571 | Also add information on how to contact you by electronic and paper mail.
572 |
573 | If the program does terminal interaction, make it output a short notice like this
574 | when it starts in an interactive mode:
575 |
576 | Copyright (C)
577 | This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'.
578 | This is free software, and you are welcome to redistribute it
579 | under certain conditions; type 'show c' for details.
580 |
581 | The hypothetical commands `show w` and `show c` should show the appropriate parts of
582 | the General Public License. Of course, your program's commands might be different;
583 | for a GUI interface, you would use an “about box”.
584 |
585 | You should also get your employer (if you work as a programmer) or school, if any, to
586 | sign a “copyright disclaimer” for the program, if necessary. For more
587 | information on this, and how to apply and follow the GNU GPL, see
588 | <>.
589 |
590 | The GNU General Public License does not permit incorporating your program into
591 | proprietary programs. If your program is a subroutine library, you may consider it
592 | more useful to permit linking proprietary applications with the library. If this is
593 | what you want to do, use the GNU Lesser General Public License instead of this
594 | License. But first, please read
595 | <>.
596 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2023 Thomas Boutell. This program is distributed under the terms of
2 | the GNU General Public License version 3, as found in the COPYING file
3 | in this package.
4 |
--------------------------------------------------------------------------------
/README-test.md:
--------------------------------------------------------------------------------
1 | # tome
2 |
3 | Tom's Editor. Maybe should be THE (Tom's Hubristic Editor)
4 |
5 | This is:
6 |
7 | * A brand new command line editor
8 | * Written in Node.js
9 | * Intended to stay small and scrappy
10 | * A modeless editing experience
11 | * following ordinary UI conventions as much as that fits with the above
12 | * Focused on editing JS, HTML, Vue single-file components and markdown
13 |
14 | ## Status
15 |
16 | * Alpha quality. I'm using it to write it, so I'll probably know pretty quick if it's a hot mess, but you should definitely use
17 | `git` and watch out for surprises.
18 |
19 | ## Plan
20 |
21 | * Bootstrap this very quickly into something I can stand to use to edit JavaScript, just enough to work on it and see the problems. DONE
22 | * Fix the flicker LOL
23 | * Find
24 | * Find and Replace
25 | * Undo/redo
26 | * Why do emojis act weird?
27 | * File locking for the actual file
28 | * Make the js stuff file extension specific
29 | * Make the js stuff work in a script tag too
30 | * Add some HTML stuff
31 | * Respect .editorconfig
32 |
33 | ## Install
34 |
35 | ```bash
36 | npm install -g @boutell/tome
37 | ```
38 |
39 | ## Usage
40 |
41 | tome some-file-you-want-to-edit
42 |
43 | It's fine to create a new file.
44 |
45 | ## Commands
46 |
47 | * Keyboard select by holding down shift with up, down, left, right
48 | * Cut, copy, paste with control-X, control-C, control-V
49 | * Save and keep working with Control-S
50 | * Exit, with optional save, via Control-W
51 |
52 | ## Working with multiple files
53 |
54 | Start as many `tome` commands as you want in separate terminals. The clipboard is automatically shared. Just copy in one and paste in another.
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tome
2 |
3 | `tome`, aka Tom's Editor. Maybe should be THE (Tom's Hubristic Editor).
4 |
5 | 
6 |
7 | This is:
8 |
9 | * A brand new terminal-based text editor
10 | * Written in Node.js with practically no libraries (certainly no Electron)
11 | * Intended to stay small and scrappy
12 | * A mostly modeless editing experience, to the extent limited meta key support will allow
13 | * Following ordinary UI conventions as much as that fits with the above
14 | * Focused on editing JS, HTML, Vue single-file components and markdown (JS for now)
15 | * Released under GPLv3 so contributions come back (this **does not** mean the code you write with it
16 | is subject to the GPL)
17 |
18 | ## Status
19 |
20 | * Alpha quality. I'm using `tome` to write `tome`, so I'll probably know pretty quick if it's a hot mess, but you should definitely
21 | use `git` and watch out for surprises.
22 |
23 | ## Install
24 |
25 | You must have Node.js 18 or better. The laziest way to get modern Node.js is via [nvm](https://github.com/nvm-sh/nvm).
26 |
27 | ```bash
28 | npm install -g @boutell/tome
29 | ```
30 |
31 | I publish a new alpha release whenever it seems stable-ish and
32 | I can get more work done with it myself.
33 |
34 | Or, for the absolute bleeding edge, use `git`:
35 |
36 | ```bash
37 | git clone https://github.com/boutell/tome
38 | cd tome
39 | npm link
40 | ```
41 |
42 | ## Usage
43 |
44 | tome some-file-you-want-to-edit
45 |
46 | if the file does not exist it will be created at save time.
47 |
48 | ## Where will it run?
49 |
50 | Mac and Linux: definitely.
51 | Windows Subsystem for Linux: definitely.
52 | Windows: maybe.
53 |
54 | There are minor issues around file paths that probably interfere with using this under plain Windows. The
55 | terminal escapes used on output should be fine now in updated Windows 10 or Windows 11, but it's anybody's
56 | guess what the keycodes are. Contributions to fix plain-Windows issues are welcome.
57 |
58 | ## Commands
59 |
60 | * Type stuff
61 | * Move around with arrow keys
62 | * Tab key indents two spaces
63 | * Control-up and Control-down for Page Up and Page Down, or Control-O and Control-P on Mac
64 | * Keyboard select by pressing ESC, then moving around with arrow keys and the page up/down keys
65 | (can press ESC again if you change your mind)
66 | * Shift current selection left and right with the `[` and `]` keys while in selection mode
67 | * Cut, copy, paste with control-X, control-C, control-V
68 | * "Find" with control-F, variations and "Replace" are offered
69 | * "Find Again" / "Replace Again" with control-G
70 | * "Undo" with control-Z
71 | * "Redo" with control-Y
72 | * Save and keep working with Control-S
73 | * Exit, with optional save, via Control-Q
74 | * "Bounce" from an opening `{` to a closing `}` and vice versa with Control-B (also for `( )` and `[ ]`)
75 | * Toggle between available programming languages with Control-L (useful if initial guess is wrong)
76 |
77 | ## Working with multiple files
78 |
79 | Start as many `tome` commands as you want in separate terminals. The clipboard is automatically shared. Just copy in one and paste in another.
80 | I'm talking about the built-in `tome` cut, copy and paste here (ESC to select, then `control-x`, `control-c`, `control-v`). These are shared
81 | as long as you are working in the same account.
82 |
83 | ## Plan
84 |
85 | See also github issues, this list is in the progress of migrating there.
86 |
87 | * Bootstrap this very quickly into something I can stand to use to edit JavaScript, just enough to work on it and see the problems. DONE
88 | * Fix the flicker. DONE
89 | * Undo. DONE
90 | * Redo. DONE
91 | * Refactor such that this doesn't all have to be one file and multiple instances of the editor become technically possible. DONE
92 | * "Sub-editors" for editing fields like "Find" with access to all of the same conveniences. DONE
93 | * Bare-bones Find feature. DONE
94 | * Find Again DONE
95 | * What's going on with emoji? DONE
96 | * Prevent nonprintable characters tome doesn't understand from winding up as text in the document. DONE
97 | * Prompt for "Find: " DONE
98 | * "Find" loops around DONE
99 | * Mac-friendly keyboard selection DONE
100 | * "Find" supports regexps, case insensitive DONE
101 | * Situational instructions at bottom of screen DONE
102 | * Alternate page-up, page-down keys for Mac DONE
103 | * "Virtual DOM" haha. No, but seriously, we should update a virtual terminal and calculate what changes are really
104 | needed in `draw`. Make this finally feel good over ssh. DONE
105 | * Find and Replace DONE
106 | * Fix the delay before entering select mode with ESC works DONE
107 | * Continuous parsing so indent is performant DONE
108 | * Parens-aware indent DONE
109 | * String-aware indent DONE
110 | * Make the js stuff file extension specific DONE
111 | * Syntax highlighting, round 1 DONE
112 | * Reindentation with `[` and `]` during selection DONE
113 | * Control-Q should not offer to save if there are no changes DONE
114 | * Comment toggling DONE
115 | * Show two lines of help DONE
116 | * Help Screen (as a scrollable read-only editor)
117 | * File locking for the actual file
118 | * Add some HTML stuff
119 | * Add some markdown stuff, might be as basic as auto word wrap support, maybe color coding to catch runaway blocks
120 | * Make the js stuff work in a script tag too
121 | * Plugin support flexible enough that anyone can install a feature from any source without shipping it in core,
122 | e.g. not everyone wants AI copilot in their life but some people do
123 | * More efficient rendering in more situations, for slow links (take advantage of built in terminal scrolling)
124 | * Add `.editorconfig` support (including support for storing tabs, controlling # of spaces, etc)
125 | * Support for language servers
126 | * And/Or: true JS parsing, so keyword highlighting etc. can be implemented
127 | * Simple file browser
128 |
129 | ## Contributions
130 |
131 | Your contributions are welcome. The project is licensed under GPLv3 (see the `COPYING` file). This is a welcoming,
132 | all-volunteer project, abuse will not be tolerated. It's a good idea to discuss before embarking on a major feature
133 | just to make sure we have the same overall architecture in mind.
134 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import fs from 'fs';
4 | import readline from 'readline';
5 | import properLockFile from 'proper-lockfile';
6 | import ansi from 'ansi-escapes';
7 | import boring from 'boring';
8 | import { inspect } from 'util';
9 |
10 | import clipboardFactory from './clipboard.js';
11 | import Editor from './editor.js';
12 | import selectorsByName from './selectors-by-name.js';
13 | import loadHandlerFactories from './load-handler-factories.js';
14 | import loadLanguages from './load-languages.js';
15 |
16 | import Screen from './screen.js';
17 |
18 | const stdin = process.stdin;
19 | const stdout = process.stdout;
20 | stdin.setRawMode(true);
21 | readline.emitKeypressEvents(stdin);
22 | const screen = new Screen({
23 | stdout,
24 | log
25 | });
26 |
27 | const tabSpaces = 2;
28 |
29 | const argv = boring();
30 |
31 | const filename = argv._[0];
32 |
33 | if (!filename && !argv['debug-keycodes']) {
34 | usage();
35 | }
36 |
37 | // TODO consider Windows
38 | const localFolder = `${process.env.HOME}/.local`;
39 | const stateFolder = `${localFolder}/state/tome`;
40 | fs.mkdirSync(stateFolder, { recursive: true });
41 |
42 | const logFile = fs.createWriteStream(`${stateFolder}/log.txt`, 'utf8');
43 |
44 | const clipboard = clipboardFactory({
45 | stateFolder,
46 | lock
47 | });
48 |
49 | const hintStack = [
50 | [
51 | '^S: Save',
52 | '^Q: Quit',
53 | 'ESC: Select',
54 | '^X: Cut',
55 | '^C: Copy',
56 | '^P: Paste',
57 | '^Z: Undo',
58 | '^Y: Redo',
59 | '^F: Find',
60 | '^B: Block',
61 | '^L: Language'
62 | ]
63 | ];
64 |
65 | let deliverKey;
66 | let editor;
67 | let originalText;
68 | let keyQueue = [];
69 |
70 | const handlerFactories = await loadHandlerFactories();
71 | const languages = await loadLanguages();
72 | editor = new Editor({
73 | getKey,
74 | save: saveFile,
75 | close: closeEditor,
76 | status,
77 | selectorsByName,
78 | clipboard,
79 | tabSpaces,
80 | chars: loadFile() || newFile(),
81 | languages,
82 | language: guessLanguage(filename),
83 | hintStack,
84 | handlerFactories,
85 | screen,
86 | log
87 | });
88 |
89 | resize();
90 |
91 | stdin.on('keypress', (c, k) => {
92 | let key;
93 | if ((c == null) || (c.charCodeAt(0) < 32) || (c.charCodeAt(0) === 127)) {
94 | if (k.name == null) {
95 | // ignore occasional undefined keys on mac
96 | return;
97 | }
98 | if (k.shift) {
99 | k.name = `shift-${k.name}`;
100 | }
101 | if (k.ctrl) {
102 | k.name = `control-${k.name}`;
103 | }
104 | if ((k.sequence.charCodeAt(0) === 27) && (k.sequence.charCodeAt(1) === 27)) {
105 | // readline isn't quite smart enough on its own to do the right thing if
106 | // ESC is followed quickly by an arrow key, but gives us enough information
107 | // to figure it out ourselves
108 | output('escape');
109 | }
110 | key = k.name;
111 | } else {
112 | key = c;
113 | }
114 | if (key == null) {
115 | // Don't crash on weird input
116 | return;
117 | }
118 | output(key);
119 | function output(key) {
120 | if (deliverKey) {
121 | const fn = deliverKey;
122 | deliverKey = null;
123 | fn(key);
124 | } else {
125 | keyQueue.push(key);
126 | }
127 | }
128 | });
129 | process.on('SIGWINCH', () => {
130 | resize();
131 | });
132 | process.on('SIGCONT', () => {
133 | // In case the user remaps the keyboard to free up control-z for this purpose.
134 | //
135 | // Returning from control-Z we have to go back into raw mode in two steps
136 | // https://stackoverflow.com/questions/48483796/stdin-setrawmode-not-working-after-resuming-from-background
137 | stdin.setRawMode(false);
138 | stdin.setRawMode(true);
139 | stdin.resume();
140 | stdin.setEncoding('utf8');
141 | });
142 | screen.draw();
143 | while (true) {
144 | const key = await getKey();
145 | await editor.acceptKey(key);
146 | }
147 |
148 | function shortFilename(prompt) {
149 | return filename.split('/').pop().substring(0, stdout.columns - (prompt || '').length - 5);
150 | }
151 |
152 | function printKey(key) {
153 | console.log(name || key.split('').map(ch => ch.charCodeAt(0)).join(',') + `:${key}`);
154 | }
155 |
156 | function logCodes(s) {
157 | try {
158 | logFile.write(s.split('').map(ch => ch.charCodeAt(0)).join(' ') + '\n');
159 | } catch (e) {
160 | log('logCodes failed on non string argument:');
161 | log(s);
162 | }
163 | }
164 |
165 | function log(...args) {
166 | for (let arg of args) {
167 | if ((typeof arg) === 'object') {
168 | arg = inspect(arg, { depth: 10 });
169 | }
170 | logFile.write(arg + '\n');
171 | }
172 | }
173 |
174 | function lock(filename) {
175 | return properLockFile(filename, {
176 | retries: {
177 | retries: 30,
178 | factor: 1,
179 | minTimeout: 1000,
180 | maxTimeout: 1000
181 | },
182 | // Avoid chicken and egg problem when the file does not exist yet
183 | realpath: false
184 | });
185 | }
186 |
187 | function loadFile() {
188 | if (!fs.existsSync(filename)) {
189 | return false;
190 | }
191 | // Emoji-safe split by character (split('') is not safe)
192 | const content = fs.readFileSync(filename, 'utf8').split('\n').map(line => [...line]);
193 | if (!content.length) {
194 | content.push([]);
195 | }
196 | originalText = getText(content);
197 | return content;
198 | }
199 |
200 | function newFile() {
201 | return [ [] ];
202 | }
203 |
204 | function saveFile() {
205 | fs.writeFileSync(filename, getText(editor.chars));
206 | }
207 |
208 | function getText(chars) {
209 | return chars.map(line => line.join('')).join('\n');
210 | }
211 |
212 | async function closeEditor() {
213 | const text = getText(editor.chars);
214 | if (text !== originalText) {
215 | if (await confirm('Save before exiting? [Y/n]', true)) {
216 | saveFile();
217 | }
218 | }
219 | stdout.write(ansi.clearScreen);
220 | process.exit(0);
221 | }
222 |
223 | async function confirm(msg, def) {
224 | status(msg);
225 | screen.cursor(screen.width - 1, screen.height - 3);
226 | screen.draw();
227 | const response = await getKey();
228 | if (def === true) {
229 | return ((response !== 'n') && (response !== 'N'));
230 | } else {
231 | return ((response === 'y') || (response === 'Y'));
232 | }
233 | }
234 |
235 | // Returns a promise for the next key pressed
236 | function getKey() {
237 | if (keyQueue.length) {
238 | return keyQueue.shift();
239 | }
240 | return new Promise(resolve => {
241 | deliverKey = resolve;
242 | });
243 | }
244 |
245 | function usage() {
246 | process.stderr.write('Usage: tome filename\n');
247 | process.exit(1);
248 | }
249 |
250 | function resize() {
251 | screen.resize();
252 | editor.resize(process.stdout.columns, process.stdout.rows - 3);
253 | }
254 |
255 | function guessLanguage(filename) {
256 | const matches = filename.match(/\.([^\.]+)$/);
257 | if (matches) {
258 | const found = Object.values(languages).find(language => language.extensions.includes(matches[1]));
259 | return found || languages.default;
260 | }
261 | return languages.default;
262 | }
263 |
264 | function status(prompt) {
265 | const hints = hintStack[hintStack.length - 1];
266 | const width = Math.max(...hints.map(s => s.length)) + 2;
267 | let col = 0;
268 | let row = process.stdout.rows - 2;
269 | for (const hint of hints) {
270 | if (col + width >= process.stdout.columns) {
271 | fillRest();
272 | row++;
273 | if (row >= process.stdout.rows) {
274 | break;
275 | }
276 | }
277 | for (let sx = 0; (sx < width); sx++) {
278 | screen.set(col + sx, row, (sx < hint.length) ? hint.charAt(sx) : ' ');
279 | }
280 | col += width;
281 | }
282 | while (row < process.stdout.rows) {
283 | for (let sx = col; (sx < screen.width); sx++) {
284 | screen.set(sx, row, ' ');
285 | }
286 | col = 0;
287 | row++;
288 | }
289 | const left = `${editor.row + 1} ${editor.col + 1} ${shortFilename()}`;
290 | const right = (prompt !== false) ? prompt : '';
291 | const s = left + ' '.repeat(process.stdout.columns - 1 - right.length - left.length) + right;
292 | for (let i = 0; (i < s.length); i++) {
293 | screen.set(i, process.stdout.rows - 3, s.charAt(i));
294 | }
295 | function fillRest() {
296 | while (col < screen.width) {
297 | screen.set(col, row, ' ');
298 | col++;
299 | }
300 | col = 0;
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/bin/tome.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // Workaround for ES module as bin script
4 |
5 | import('../app.js').catch((err) => { console.error(err); process.exit(1) });
6 |
--------------------------------------------------------------------------------
/camelize.js:
--------------------------------------------------------------------------------
1 | export default function camelize(s) {
2 | const words = s.split('-');
3 | let result = '';
4 | for (let i = 0; (i < words.length); i++) {
5 | if (i > 0) {
6 | result += words[i].charAt(0).toUpperCase() + words[i].substring(1);
7 | } else {
8 | result += words[i];
9 | }
10 | }
11 | return result;
12 | }
13 |
--------------------------------------------------------------------------------
/clipboard.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import fs from 'fs';
4 |
5 | export default ({
6 | stateFolder,
7 | lock
8 | }) => {
9 | const clipboardFile = `${stateFolder}/clipboard.json`;
10 | const clipboardLockFile = `${clipboardFile}.lock`;
11 | return {
12 | async set(chars) {
13 | const release = await lock(clipboardLockFile);
14 | try {
15 | fs.writeFileSync(clipboardFile, JSON.stringify(chars));
16 | } finally {
17 | await release();
18 | }
19 | },
20 | async get(chars) {
21 | const release = await lock(clipboardLockFile);
22 | try {
23 | const clipboard = JSON.parse(fs.readFileSync(clipboardFile));
24 | return clipboard;
25 | } catch (e) {
26 | if (e.code === 'ENOENT') {
27 | // No clipboard exists right now
28 | return false;
29 | }
30 | throw e;
31 | } finally {
32 | await release();
33 | }
34 | }
35 | };
36 | };
37 |
--------------------------------------------------------------------------------
/debug-keycodes.js:
--------------------------------------------------------------------------------
1 | import readline from 'readline';
2 |
3 | const stdin = process.stdin;
4 | stdin.setRawMode(true);
5 | readline.emitKeypressEvents(stdin);
6 |
7 | console.log('press c to exit');
8 |
9 | stdin.on('keypress', (c, k) => {
10 | if (c === 'c') {
11 | process.exit(0);
12 | }
13 | console.log((c != null) && c.charCodeAt(0), k, [...k.sequence].map(s => s.charCodeAt(0)));
14 | });
15 |
--------------------------------------------------------------------------------
/editor.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import fs from 'fs';
4 | import ansi from 'ansi-escapes';
5 |
6 | const stdout = process.stdout;
7 |
8 | export default class Editor {
9 |
10 | constructor({
11 | getKey,
12 | prompt,
13 | customHandlers,
14 | handlerFactories,
15 | save,
16 | close,
17 | status,
18 | clipboard,
19 | selectorsByName,
20 | tabSpaces,
21 | chars,
22 | width,
23 | height,
24 | screenTop,
25 | screenLeft,
26 | log,
27 | hintStack,
28 | screen,
29 | languages,
30 | language
31 | }) {
32 | this.getKey = getKey;
33 | this.save = save;
34 | this.close = close;
35 | this.status = status;
36 | this.clipboard = clipboard;
37 | this.tabSpaces = tabSpaces;
38 | this.selectorsByName = selectorsByName;
39 | this.handlerFactories = handlerFactories;
40 | this.screenTop = screenTop || 0;
41 | this.screenLeft = screenLeft || 0;
42 | this.originalScreenLeft = this.screenLeft;
43 | this.originalWidth = width;
44 | this.setPrompt(prompt || '');
45 | this.height = height;
46 | this.handlers = {};
47 | this.handlersByKeyName = {};
48 | this.handlersWithTests = [];
49 | this.chars = chars || [ [] ];
50 | this.languages = languages;
51 | this.setLanguage(language);
52 | this.row = 0;
53 | this.col = 0;
54 | this.selRow = false;
55 | this.selCol = 0;
56 | this.selectMode = false;
57 | this.top = 0;
58 | this.left = 0;
59 | this.undos = [];
60 | this.redos = [];
61 | this.subEditors = [];
62 | this.log = log;
63 | this.hintStack = hintStack || [];
64 | this.screen = screen;
65 | for (const [name, factory] of Object.entries(this.handlerFactories)) {
66 | const handler = factory({
67 | editor: this,
68 | clipboard,
69 | selectorsByName,
70 | log
71 | });
72 | this.handlers[name] = handler;
73 | if (handler.keyName) {
74 | this.handlersByKeyName[handler.keyName] = handler;
75 | } else if (handler.keyNames) {
76 | for (const keyName of handler.keyNames) {
77 | this.handlersByKeyName[keyName] = handler;
78 | }
79 | } else {
80 | this.handlersWithTests.push(handler);
81 | }
82 | }
83 | // Local overrides for this particular editor instance,
84 | // as used in the "Find" experience
85 | for (const [ keyName, fn ] of Object.entries(customHandlers || {})) {
86 | this.handlersByKeyName[keyName] = {
87 | keyName,
88 | do: fn
89 | };
90 | }
91 | }
92 |
93 | resize(width, height, screenTop = 0, screenLeft = 0) {
94 | this.width = width;
95 | const reduction = this.subEditors.reduce((a, e) => a + e.height, 0);
96 |
97 | this.height = height - reduction;
98 | this.screenTop = screenTop;
99 | this.screenLeft = screenLeft;
100 | this.scroll();
101 | this.draw();
102 | let nextScreenTop = this.screenTop + height;
103 | for (const editor of this.subEditors) {
104 | editor.resize(width, editor.height, nextScreenTop, 0);
105 | nextScreenTop += editor.height;
106 | }
107 | }
108 |
109 | // Handle a key, then do shared things like building up the
110 | // undo stack, clearing the redo stack, clearing the selection, redrawing, etc.
111 | async acceptKey(key) {
112 | // Divert the next keystroke to a getKey method call
113 | if (this.getKeyPending) {
114 | const resolver = this.getKeyPending;
115 | this.getKeyPending = null;
116 | return resolver(key);
117 | }
118 | const wasSelecting = this.selectMode;
119 | const result = await this.handleKey(key);
120 | if (result === false) {
121 | // Bell would be good here
122 | return;
123 | }
124 | const {
125 | selecting,
126 | appending,
127 | undo
128 | } = result || {};
129 | if (undo) {
130 | // Actions that make a new change invalidate the redo stack
131 | this.redos.splice(0, this.redos.length);
132 | }
133 | if (undo) {
134 | this.undos.push(undo);
135 | }
136 | if (selecting && !wasSelecting) {
137 | this.hintStack.push([
138 | 'Arrows: Select',
139 | '^O: Page Up',
140 | '^P: Page Down',
141 | '^C: Copy',
142 | '[: Shift Left',
143 | ']: Shift Right',
144 | 'C: Toggle Comment',
145 | 'ESC: Done'
146 | ]);
147 | } else if (!selecting && wasSelecting) {
148 | this.selRow = false;
149 | this.selectMode = false;
150 | this.hintStack.pop();
151 | }
152 | this.draw(appending);
153 | }
154 |
155 | // You probably want acceptKey
156 | async handleKey(key) {
157 | let handler = this.handlersByKeyName[key];
158 | if (handler?.selectionRequired && !this.selectMode) {
159 | handler = null;
160 | }
161 | if (handler) {
162 | return handler.do(key);
163 | } else {
164 | for (const handler of this.handlersWithTests) {
165 | const result = await handler.do(key);
166 | if (result) {
167 | return result;
168 | }
169 | }
170 | }
171 | return false;
172 | }
173 |
174 | // Await this method to steal the next keystroke from this editor.
175 | // Usually invoked to feed sub-editors like the Find field
176 | getKey() {
177 | return new Promise(resolve => {
178 | this.getKeyPending = resolve;
179 | });
180 | }
181 |
182 | // Keep col from going off the right edge of a row
183 | clampCol() {
184 | this.col = Math.min(this.col, this.chars[this.row].length);
185 | }
186 |
187 | // Insert char at the current position and advance
188 | insertChar(char) {
189 | this.chars[this.row].splice(this.col, 0, char);
190 | this.forward();
191 | }
192 |
193 | // Used to insert characters without an undo or
194 | // redo operation. The chars array may include `\r`
195 |
196 | insert(chars, {
197 | indent = false
198 | } = {}) {
199 | for (const char of chars) {
200 | if (char === '\r') {
201 | this.break();
202 | if (indent) {
203 | this.indent();
204 | }
205 | } else {
206 | this.insertChar(char);
207 | }
208 | }
209 | }
210 |
211 | // Erase n characters at current position (not before it, use "back" first for backspace)
212 | erase(n = 1) {
213 | let changed = false;
214 | for (let i = 0; (i < n); i++) {
215 | const eol = this.col === this.chars[this.row].length;
216 | if (!eol) {
217 | this.chars[this.row].splice(this.col, 1);
218 | changed = true;
219 | } else if (this.row + 1 < this.chars.length) {
220 | const borrowed = this.chars[this.row + 1];
221 | this.chars[this.row].splice(this.chars[this.row].length, 0, ...borrowed);
222 | this.chars.splice(this.row + 1, 1);
223 | changed = true;
224 | }
225 | }
226 | return changed;
227 | }
228 |
229 | // Erase the current selection. Contributes to undo if provided
230 |
231 | eraseSelection(undo) {
232 | const chars = this.getSelectionChars();
233 | const {
234 | selected,
235 | selRow1,
236 | selCol1,
237 | selRow2,
238 | selCol2
239 | } = this.getSelection();
240 | if (!selected) {
241 | return false;
242 | }
243 | if (undo) {
244 | undo.chars = chars;
245 | }
246 | this.moveTo(selRow1, selCol1);
247 | // So we properly merge lines etc.
248 | for (let i = 0; (i < chars.length); i++) {
249 | this.erase();
250 | }
251 | return true;
252 | }
253 |
254 | // Move to location. If col does not exist on the row, stops appropriately
255 | // given the direction of movement
256 | moveTo(row, col) {
257 | if ((row < this.row) || ((row === this.row) && (col < this.col))) {
258 | this.row = row;
259 | this.col = 0;
260 | this.state = structuredClone(this.states[this.row]);
261 | while (this.col < Math.min(col, this.chars[this.row].length)) {
262 | this.forward();
263 | }
264 | } else {
265 | while ((row !== this.row) || ((this.col < col) && !this.eol())) {
266 | this.forward();
267 | }
268 | }
269 | }
270 |
271 | scroll() {
272 | let scrolled = false;
273 | while (this.row - this.top < 0) {
274 | this.top--;
275 | scrolled = 'down';
276 | }
277 | while (this.row - this.top >= this.height) {
278 | this.top++;
279 | scrolled = 'up';
280 | }
281 | while (this.col - this.left < 0) {
282 | this.left--;
283 | scrolled = 'right';
284 | }
285 | // Bias in favor of as much of the current line being visible as possible
286 | while ((this.left > 0) && (this.left > this.chars[this.row].length - this.width)) {
287 | this.left--;
288 | scrolled = 'right';
289 | }
290 | while (this.col - this.left >= this.width) {
291 | this.left++;
292 | scrolled = 'left';
293 | }
294 | return scrolled;
295 | }
296 |
297 | // TODO consider whether we can bring back the "appending" optimization or need to leave it out
298 | // because there are too many ways syntax highlighting can be impacted
299 | draw(appending) {
300 | const scrollDirection = this.scroll();
301 | const screen = this.screen;
302 | const { selected, selRow1, selCol1, selRow2, selCol2 } = this.getSelection();
303 | this.screen.cursor(this.col - this.left + this.screenLeft, this.row - this.top + this.screenTop);
304 | if (this.prompt.length) {
305 | for (let col = 0; (col < this.prompt.length); col++) {
306 | screen.set(
307 | this.screenLeft - this.prompt.length + col,
308 | this.screenTop,
309 | this.prompt.charAt(col)
310 | );
311 | }
312 | }
313 | const actualRow = this.row;
314 | const actualCol = this.col;
315 | for (let sy = 0; (sy < this.height); sy++) {
316 | const _row = sy + this.top;
317 | if (_row >= this.chars.length) {
318 | for (let sx = 0; (sx < this.width); sx++) {
319 | screen.set(this.screenLeft + sx, sy + this.screenTop, ' ');
320 | }
321 | continue;
322 | }
323 | for (let sx = 0; (sx < this.width); sx++) {
324 | const _col = sx + this.left;
325 | this.moveTo(_row, _col);
326 | let char;
327 | let style;
328 | if (this.eol()) {
329 | char = ' ';
330 | style = false;
331 | } else {
332 | char = this.peek();
333 | style = this.language.style(this.state);
334 | this.forward();
335 | // Sometimes it feels better to also style the character that caused a state change,
336 | // e.g. the character responsible for entering the error state
337 | style = this.language.styleBehind(this.state) || style;
338 | }
339 | if (selected) {
340 | if (
341 | (_row > selRow1 || ((_row === selRow1) && (_col >= selCol1))) &&
342 | (_row < selRow2 || ((_row === selRow2) && (_col < selCol2)))
343 | ) {
344 | style = 'selected';
345 | }
346 | }
347 | screen.set(this.screenLeft + sx, this.screenTop + sy, char, style);
348 | }
349 | }
350 | this.moveTo(actualRow, actualCol);
351 | this.screen.cursor(this.col - this.left + this.screenLeft, this.row - this.top + this.screenTop);
352 | this.drawStatus();
353 | screen.draw(scrollDirection);
354 | }
355 |
356 | drawStatus() {
357 | if (this.status) {
358 | this.status(this.selectMode ? 'select' : false);
359 | }
360 | }
361 |
362 | // Fetch the selection's start, end and "selected" flag in a normalized form.
363 | // If state is not passed the current selection state of the editor is used.
364 | getSelection(state) {
365 | state = state || this;
366 | let selRow1, selCol1;
367 | let selRow2, selCol2;
368 | let selected = false;
369 | if (state.selRow !== false) {
370 | selected = true;
371 | if ((state.selRow > state.row) || ((state.selRow === state.row) && state.selCol > state.col)) {
372 | selCol1 = state.col;
373 | selRow1 = state.row;
374 | selCol2 = state.selCol;
375 | selRow2 = state.selRow;
376 | } else {
377 | selCol1 = state.selCol;
378 | selRow1 = state.selRow;
379 | selCol2 = state.col;
380 | selRow2 = state.row;
381 | }
382 | }
383 | return {
384 | selected,
385 | selRow1,
386 | selCol1,
387 | selRow2,
388 | selCol2
389 | };
390 | }
391 |
392 | // Fetch the selection's chars as a flat array, suitable for reinsertion. Newlines are present as \r,
393 | // to map to the enter key on reinsertion
394 |
395 | getSelectionChars() {
396 | const {
397 | selRow1,
398 | selCol1,
399 | selRow2,
400 | selCol2,
401 | selected
402 | } = this.getSelection();
403 | const result = [];
404 | for (let row = selRow1; (row <= selRow2); row++) {
405 | let col1 = (row === selRow1) ? selCol1 : 0;
406 | let col2 = (row === selRow2) ? selCol2 : this.chars[row].length;
407 | for (let col = col1; (col < col2); col++) {
408 | result.push(this.chars[row][col]);
409 | }
410 | if (row < selRow2) {
411 | result.push('\r');
412 | }
413 | }
414 | return result;
415 | }
416 |
417 | // Insert appropriate number of spaces, typically called
418 | // on an empty newly inserted line
419 | indent(undo) {
420 | const depth = this.state.depth;
421 | const spaces = depth * this.tabSpaces;
422 | if (undo) {
423 | undo.indent = spaces;
424 | }
425 | for (let i = 0; (i < spaces); i++) {
426 | this.insertChar(' ');
427 | }
428 | if (this.state.state === '/*') {
429 | this.insert([ ' ', '*', ' ' ]);
430 | }
431 | }
432 |
433 | createSubEditor(params) {
434 | this.height -= params.height;
435 | this.draw();
436 | const editor = new Editor({
437 | handlerFactories: this.handlerFactories,
438 | clipboard: this.clipboard,
439 | selectorsByName: this.selectorsByName,
440 | tabSpaces: this.tabSpaces,
441 | log: this.log,
442 | screen: this.screen,
443 | languages: this.languages,
444 | language: this.languages.default,
445 | ...params
446 | });
447 | editor.draw();
448 | this.subEditors.push(editor);
449 | return editor;
450 | }
451 |
452 | removeSubEditor(editor) {
453 | this.subEditors = this.subEditors.filter(e => e !== editor);
454 | editor.removed = true;
455 | this.height += editor.height;
456 | this.draw();
457 | }
458 |
459 | setPrompt(prompt) {
460 | this.prompt = prompt;
461 | this.screenLeft = this.originalScreenLeft + this.prompt.length;
462 | this.width = this.originalWidth - this.prompt.length;
463 | }
464 |
465 | forward(n = 1) {
466 | let changed = false;
467 | for (let i = 0; (i < n); i++) {
468 | const canMoveForward = this.col < this.chars[this.row].length;
469 | const canMoveDown = this.row + 1 < this.chars.length;
470 | const canMove = canMoveForward || canMoveDown;
471 | if (canMove) {
472 | const peeked = this.peek();
473 | this.language.parse(this.state, peeked, {
474 | log: this.log,
475 | row: this.row,
476 | col: this.col
477 | });
478 | }
479 | if (canMoveForward) {
480 | this.col++;
481 | changed = true;
482 | } else if (canMoveDown) {
483 | this.row++;
484 | this.col = 0;
485 | changed = true;
486 | this.states[this.row] = structuredClone(this.state);
487 | }
488 | }
489 | return changed;
490 | }
491 |
492 | back(n = 1) {
493 | let changed = false;
494 | for (let i = 0; (i < n); i++) {
495 | if (this.col > 0) {
496 | this.col = Math.max(this.col - 1, 0);
497 | changed = true;
498 | } else if (this.row > 0) {
499 | this.row--;
500 | this.col = this.chars[this.row].length;
501 | changed = true;
502 | }
503 | if (changed) {
504 | // TODO very inefficient on every backwards arrow move,
505 | // think about how to avoid unnecessary clones
506 | this.state = structuredClone(this.states[this.row]);
507 | for (let col = 0; (col < this.col); col++) {
508 | this.language.parse(this.state, this.chars[this.row][col], {
509 | row: this.row,
510 | col,
511 | log: this.log
512 | });
513 | }
514 | }
515 | }
516 | return changed;
517 | }
518 |
519 | up() {
520 | if (this.row === 0) {
521 | return false;
522 | }
523 | const oldRow = this.row;
524 | const oldCol = this.col;
525 | while (this.row === oldRow) {
526 | this.back();
527 | }
528 | while (this.col > oldCol) {
529 | this.back();
530 | }
531 | return true;
532 | }
533 |
534 | down() {
535 | if ((this.row + 1) === this.chars.length) {
536 | return false;
537 | }
538 | const oldRow = this.row;
539 | const oldCol = this.col;
540 | while (this.row === oldRow) {
541 | this.forward();
542 | }
543 | while ((this.col < oldCol) && !this.eol()) {
544 | this.forward();
545 | }
546 | return true;
547 | }
548 |
549 | // Insert newline. Does not indent. Advances the cursor
550 | // to the start of the new line
551 | break() {
552 | const remainder = this.chars[this.row].slice(this.col);
553 | this.chars[this.row] = this.chars[this.row].slice(0, this.col);
554 | this.chars.splice(this.row + 1, 0, remainder);
555 | this.forward();
556 | }
557 |
558 | // Returns the character at the current position, or
559 | // at the position specified
560 | peek(row = null, col = null) {
561 | if (row == null) {
562 | row = this.row;
563 | }
564 | if (col == null) {
565 | col = this.col;
566 | }
567 | if (col < this.chars[row].length) {
568 | return this.chars[row][col];
569 | } else if (row + 1 < this.chars.length) {
570 | return '\r';
571 | } else {
572 | return null;
573 | }
574 | }
575 |
576 | sol() {
577 | return this.col === 0;
578 | }
579 |
580 | eol() {
581 | return this.col === this.chars[this.row].length;
582 | }
583 |
584 | // Shift text left or right one tab stop.
585 | //
586 | // Returns an undo object only if a shift was actually made.
587 | //
588 | // direction must be -1 or 1
589 | shiftSelection(direction) {
590 | let {
591 | selected,
592 | selRow1,
593 | selCol1,
594 | selRow2,
595 | selCol2
596 | } = this.getSelection();
597 | this.moveTo(selRow1, 0);
598 | const undo = {
599 | action: (direction === -1) ? 'shiftSelectionLeft' : 'shiftSelectionRight',
600 | row: this.row,
601 | col: this.col,
602 | chars: []
603 | };
604 | this.selRow = selRow2;
605 | this.selCol = this.chars[selRow2].length;
606 | if (selCol2 === 0) {
607 | selRow2--;
608 | this.selCol = 0;
609 | }
610 | for (let row = selRow1; (row <= selRow2); row++) {
611 | let chars = this.chars[row];
612 | if (direction === -1) {
613 | for (let space = 0; (space < 2); space++) {
614 | if (this.chars[row][0] === ' ') {
615 | chars = chars.slice(1);
616 | }
617 | }
618 | } else {
619 | chars = [ ' ', ' ', ...chars ];
620 | }
621 | if (chars !== this.chars[row]) {
622 | undo.chars[row] = [...this.chars[row]];
623 | this.chars[row] = chars;
624 | }
625 | }
626 | return undo;
627 | }
628 |
629 | toggleComment() {
630 | }
631 |
632 | setLanguage(language) {
633 | this.language = language;
634 | this.state = this.language.newState() || {
635 | depth: 0
636 | };
637 | this.states = [
638 | structuredClone(this.state)
639 | ];
640 | }
641 |
642 | setSelection({ row, col, selRow, selCol }) {
643 | this.moveTo(row, col);
644 | this.selRow = selRow;
645 | this.selCol = selCol;
646 | }
647 | }
648 |
649 | function camelize(s) {
650 | const words = s.split('-');
651 | let result = '';
652 | for (let i = 0; (i < words.length); i++) {
653 | if (i > 0) {
654 | result += words[i].charAt(0).toUpperCase() + words[i].substring(1);
655 | } else {
656 | result += words[i];
657 | }
658 | }
659 | return result;
660 | }
661 |
--------------------------------------------------------------------------------
/find.js:
--------------------------------------------------------------------------------
1 | function find(editor, {
2 | target,
3 | replacement = false,
4 | fromRow = 0,
5 | fromCol = 0,
6 | caseSensitive = false,
7 | regExp = false,
8 | direction = 1
9 | }, repeat = true) {
10 | const normalizeChar = caseSensitive ? ch => { return ch; } : ch => { return ((typeof ch) === 'string') ? ch.toLowerCase() : ch; };
11 | if ((fromRow === 0) && (fromCol === 0)) {
12 | repeat = false;
13 | }
14 | if (direction === 1) {
15 | const expression = regExp && new RegExp(target.join(''), caseSensitive ? '' : 'i');
16 | for (let row = fromRow; (row < editor.chars.length); row++) {
17 | const editorChars = editor.chars[row];
18 | if (regExp) {
19 | const s = editorChars.slice(fromCol).join('');
20 | const indexOf = s.search(expression);
21 | if (indexOf >= 0) {
22 | return replaceAndOrMove(editorChars, row, indexOf);
23 | }
24 | } else {
25 | // TODO pick up with replacement logic here
26 | for (let col = fromCol; (col < editorChars.length); col++) {
27 | let j;
28 | for (j = 0; (j < target.length); j++) {
29 | if (normalizeChar(editorChars[col + j]) !== normalizeChar(target[j])) {
30 | break;
31 | }
32 | }
33 | if (j === target.length) {
34 | return replaceAndOrMove(editorChars, row, col);
35 | }
36 | }
37 | }
38 | fromCol = 0;
39 | }
40 | if (repeat) {
41 | return find(editor, {
42 | target,
43 | replacement,
44 | fromRow: 0,
45 | fromCol: 0,
46 | caseSensitive,
47 | regExp,
48 | direction
49 | }, false);
50 | }
51 | } else {
52 | const expression = regExp && new RegExp(target.join(''), 'g' + (caseSensitive ? '' : 'i'));
53 | for (let row = fromRow; (row >= 0); row--) {
54 | const editorChars = editor.chars[row];
55 | if (fromCol === false) {
56 | fromCol = editorChars.length - 1;
57 | }
58 | if (regExp) {
59 | const s = editorChars.slice(0, fromCol).join('');
60 | const matches = s.matchAll(expression);
61 | const [match] = matches;
62 | if (match) {
63 | const indexOf = match.index;
64 | return replaceAndOrMove(editorChars, row, indexOf);
65 | }
66 | } else {
67 | for (let col = fromCol; (col >= 0); col--) {
68 | let j;
69 | for (j = 0; (j < target.length); j++) {
70 | if (col - j < 0) {
71 | break;
72 | }
73 | if (normalizeChar(editorChars[col - j]) !== normalizeChar(target[target.length - j - 1])) {
74 | break;
75 | }
76 | }
77 | if (j === target.length) {
78 | return replaceAndOrMove(editorChars, row, col - target.length + 1);
79 | }
80 | }
81 | }
82 | fromCol = false;
83 | }
84 | if (repeat) {
85 | return find(editor, {
86 | target,
87 | replacement,
88 | fromRow: editor.chars.length - 1,
89 | fromCol: editor.chars[editor.chars.length - 1].length - 1,
90 | caseSensitive,
91 | regExp,
92 | direction
93 | }, false);
94 | }
95 | }
96 | return false;
97 | function replaceAndOrMove(chars, row, col) {
98 | if (replacement) {
99 | editor.undos.push({
100 | action: 'find',
101 | row,
102 | col,
103 | target,
104 | replacement,
105 | direction
106 | });
107 | }
108 | if (replacement !== false) {
109 | chars.splice(col, target.length, ...replacement);
110 | }
111 | let newCol = (direction === 1) ? ((replacement === false) ? col : col + replacement.length)
112 | : col;
113 | editor.moveTo(row, newCol);
114 | return true;
115 | }
116 | }
117 |
118 | export default find;
119 |
--------------------------------------------------------------------------------
/handlers/back.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyName: 'left',
7 | do() {
8 | return select({
9 | editor,
10 | move() {
11 | return editor.back();
12 | }
13 | });
14 | }
15 | });
16 |
--------------------------------------------------------------------------------
/handlers/backspace.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'backspace',
5 | do() {
6 | if (!editor.back()) {
7 | return false;
8 | }
9 | const undo = {
10 | action: 'backspace',
11 | row: editor.row,
12 | col: editor.col,
13 | char: editor.peek()
14 | };
15 | const result = editor.erase();
16 | if (result) {
17 | return {
18 | undo
19 | };
20 | } else {
21 | return false;
22 | }
23 | },
24 | undo(undo) {
25 | editor.moveTo(undo.row, undo.col);
26 | editor.insert([ undo.char ]);
27 | },
28 | redo(undo) {
29 | editor.moveTo(undo.row, undo.col);
30 | editor.erase();
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/handlers/block.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyNames: [ 'control-b' ],
7 | do() {
8 | const char = editor.peek();
9 | if (editor.language.shouldOpenBlock(editor.state, char)) {
10 | return editor.language.forwardToCloser(editor.state, args(editor));
11 | } else if (editor.language.shouldCloseBlock(editor.state, char)) {
12 | return editor.language.backToOpener(editor.state, args(editor));
13 | }
14 | }
15 | });
16 |
17 | function args(editor) {
18 | return {
19 | forward() {
20 | const result = editor.forward();
21 | if (!result) {
22 | return false;
23 | }
24 | return editor.state;
25 | },
26 | back() {
27 | const result = editor.back();
28 | if (!result) {
29 | return false;
30 | }
31 | return editor.state;
32 | },
33 | log: editor.log
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/handlers/close.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-q',
5 | do() {
6 | if (editor.close) {
7 | return editor.close();
8 | }
9 | return false;
10 | }
11 | });
12 |
--------------------------------------------------------------------------------
/handlers/closed-block.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | do(char) {
5 | if (!editor.language) {
6 | return false;
7 | }
8 | if (!editor.language.shouldCloseBlock(editor.state, char)) {
9 | return false;
10 | }
11 | if (editor.chars[editor.row].some(char => char !== ' ')) {
12 | return false;
13 | }
14 | let depth = editor.state.depth;
15 | if (!depth) {
16 | return false;
17 | }
18 | depth--;
19 | const undo = {
20 | row: editor.row,
21 | action: 'closedBlock',
22 | oldCount: editor.chars[editor.row].length,
23 | char
24 | };
25 | while (!editor.sol()) {
26 | editor.back();
27 | }
28 | while (editor.peek() === ' ') {
29 | editor.erase();
30 | }
31 | for (let i = 0; (i < editor.tabSpaces * depth); i++) {
32 | editor.insert([ ' ' ]);
33 | }
34 | editor.insert([ char ]);
35 | return {
36 | undo
37 | };
38 | },
39 | undo(undo) {
40 | editor.moveTo(editor.row, 0);
41 | while (!editor.eol()) {
42 | editor.erase();
43 | }
44 | for (let i = 0; (i < undo.oldCount); i++) {
45 | editor.insert([ ' ' ]);
46 | }
47 | },
48 | redo(redo) {
49 | editor.moveTo(redo.row, redo.col);
50 | editor.handlers.closedBlock.do(redo.char);
51 | }
52 | });
53 |
--------------------------------------------------------------------------------
/handlers/copy.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor, clipboard }) => ({
4 | keyName: 'control-c',
5 | async do(key) {
6 | const {
7 | selected,
8 | selRow1,
9 | selCol1,
10 | selRow2,
11 | selCol2
12 | } = editor.getSelection();
13 | if (!selected) {
14 | return false;
15 | }
16 | const chars = editor.getSelectionChars();
17 | await clipboard.set(chars);
18 | editor.selectMode = false;
19 | return true;
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/handlers/cut.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-x',
5 | async do() {
6 | const undo = {
7 | action: 'cut',
8 | row: editor.row,
9 | col: editor.col,
10 | selRow: editor.selRow,
11 | selCol: editor.selCol
12 | };
13 | if (!await editor.handlers.copy.do()) {
14 | return false;
15 | }
16 | editor.eraseSelection(undo);
17 | return {
18 | undo
19 | };
20 | },
21 | async undo(undo) {
22 | const selection = editor.getSelection(undo);
23 | editor.moveTo(selection.selRow1, selection.selCol1);
24 | editor.insert(undo.chars);
25 | },
26 | async redo(redo) {
27 | editor.moveTo(redo.row, redo.col);
28 | editor.selRow = redo.selRow;
29 | editor.selCol = redo.selCol;
30 | return editor.handlers.cut.do();
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/handlers/debug.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-d',
5 | async do() {
6 | editor.log('debug state:', editor.state);
7 | }
8 | });
9 |
--------------------------------------------------------------------------------
/handlers/down.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyName: 'down',
7 | do() {
8 | if (editor.row === (editor.chars.length - 1)) {
9 | return false;
10 | }
11 | return select({
12 | editor,
13 | move() {
14 | return editor.down();
15 | }
16 | });
17 | }
18 | });
19 |
--------------------------------------------------------------------------------
/handlers/end-of-line.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-right',
5 | do() {
6 | if (editor.col === editor.chars[editor.row].length) {
7 | return false;
8 | }
9 | editor.moveTo(editor.row, editor.chars[editor.row].length);
10 | return true;
11 | }
12 | });
13 |
--------------------------------------------------------------------------------
/handlers/escape.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'escape',
5 | do() {
6 | // Stuck with a modal experience because it's a
7 | // miracle Mac Terminal lets me access any keys at all
8 | editor.selectMode = !editor.selectMode;
9 | return {
10 | selecting: editor.selectMode
11 | };
12 | }
13 | });
14 |
--------------------------------------------------------------------------------
/handlers/find-again.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import find from '../find.js';
4 |
5 | export default ({ editor, clipboard, log }) => ({
6 | keyName: 'control-g',
7 | async do(key) {
8 | if (editor.lastFind == null) {
9 | return false;
10 | }
11 | const findArgs = {
12 | ...editor.lastFind
13 | };
14 | if (findArgs.direction === 1) {
15 | findArgs.fromCol = findArgs.fromCol + 1;
16 | if (findArgs.fromCol === editor.chars[findArgs.fromRow].length) {
17 | findArgs.fromCol = 0;
18 | findArgs.fromRow++;
19 | if (findArgs.fromRow === editor.chars.length) {
20 | findArgs.fromRow = 0;
21 | }
22 | }
23 | } else {
24 | findArgs.fromCol = findArgs.fromCol - 1;
25 | if (findArgs.fromCol < 0) {
26 | findArgs.fromRow--;
27 | if (findArgs.fromRow < 0) {
28 | findArgs.fromRow = editor.chars.length - 1;
29 | }
30 | findArgs.fromCol = editor.chars[findArgs.fromRow].length - 1;
31 | }
32 | }
33 | const result = find(editor, findArgs);
34 | if (result) {
35 | editor.lastFind.fromRow = editor.row;
36 | editor.lastFind.fromCol = editor.col;
37 | editor.draw();
38 | }
39 | return result;
40 | }
41 | });
42 |
--------------------------------------------------------------------------------
/handlers/find.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import find from '../find.js';
4 |
5 | export default ({ editor, clipboard, log }) => ({
6 | keyName: 'control-f',
7 | async do(key) {
8 | editor.hintStack.push([
9 | 'ENTER: Find',
10 | '^E: rEgExp',
11 | '^A: cAse sensitive',
12 | '^U: Find previoUs',
13 | '^R: Replace',
14 | '^F: Cancel'
15 | ]);
16 | let regExp = false;
17 | let caseSensitive = false;
18 | const findField = editor.createSubEditor({
19 | prompt: getPrompt(),
20 | customHandlers: {
21 | return() {
22 | return go(1);
23 | },
24 | 'control-e': () => {
25 | regExp = !regExp;
26 | setPrompt();
27 | },
28 | 'control-a': () => {
29 | caseSensitive = !caseSensitive;
30 | setPrompt();
31 | },
32 | 'control-r': () => {
33 | return replace({
34 | editor,
35 | findField,
36 | closeFindField: close,
37 | clipboard,
38 | log
39 | });
40 | },
41 | 'control-u': () => {
42 | return go(-1);
43 | },
44 | 'control-f': () => {
45 | close();
46 | }
47 | },
48 | width: editor.width,
49 | height: 1,
50 | screenTop: editor.screenTop + editor.height - 1
51 | });
52 | findField.draw();
53 | while (!findField.removed) {
54 | const key = await editor.getKey();
55 | await findField.acceptKey(key);
56 | }
57 | function go(direction) {
58 | try {
59 | const target = findField.chars[0];
60 | const findArgs = {
61 | target,
62 | fromRow: editor.row,
63 | fromCol: editor.col,
64 | caseSensitive,
65 | regExp,
66 | direction
67 | };
68 | const result = find(editor, findArgs);
69 | if (result) {
70 | editor.lastFind = {
71 | ...findArgs,
72 | fromRow: editor.row,
73 | fromCol: editor.col
74 | };
75 | }
76 | return result;
77 | } finally {
78 | close();
79 | }
80 | }
81 | function setPrompt() {
82 | findField.setPrompt(getPrompt());
83 | findField.draw();
84 | }
85 | function getPrompt() {
86 | return (regExp ? '[rE] ' : '') + (caseSensitive ? '[cA] ' : '') + 'Find: ';
87 | }
88 | function close() {
89 | editor.hintStack.pop();
90 | editor.removeSubEditor(findField);
91 | }
92 | },
93 | // Only comes into play if the action was actually a find-and-replace
94 | undo({
95 | row,
96 | col,
97 | target,
98 | replacement,
99 | direction
100 | }) {
101 | editor.moveTo(row, col);
102 | editor.erase(replacement.length);
103 | editor.insert(target);
104 | },
105 | redo({
106 | row,
107 | col,
108 | target,
109 | replacement,
110 | direction
111 | }) {
112 | editor.moveTo(row, col);
113 | editor.erase(target.length);
114 | editor.insert(replacement);
115 | }
116 | });
117 |
118 | async function replace({
119 | editor,
120 | findField,
121 | closeFindField,
122 | caseSensitive,
123 | regExp,
124 | clipboard,
125 | log
126 | }) {
127 | editor.hintStack.push([
128 | 'ENTER: Replace',
129 | '^U: Replace previoUs',
130 | '^F: Cancel'
131 | ]);
132 | const replaceField = editor.createSubEditor({
133 | prompt: getPrompt(),
134 | customHandlers: {
135 | return() {
136 | return go(1);
137 | },
138 | 'control-u': () => {
139 | return go(-1);
140 | },
141 | 'control-f': () => {
142 | close();
143 | }
144 | },
145 | width: editor.width,
146 | height: 1,
147 | screenTop: editor.screenTop + editor.height - 1
148 | });
149 | replaceField.draw();
150 | while (!replaceField.removed) {
151 | const key = await editor.getKey();
152 | await replaceField.acceptKey(key);
153 | }
154 | function go(direction) {
155 | try {
156 | const target = findField.chars[0];
157 | const replacement = replaceField.chars[0];
158 | const findArgs = {
159 | target,
160 | replacement,
161 | fromRow: editor.row,
162 | fromCol: editor.col,
163 | caseSensitive,
164 | regExp,
165 | direction
166 | };
167 | const result = find(editor, findArgs);
168 | if (result) {
169 | editor.lastFind = {
170 | ...findArgs,
171 | fromRow: editor.row,
172 | fromCol: editor.col
173 | };
174 | }
175 | return result;
176 | } finally {
177 | close();
178 | }
179 | }
180 | function getPrompt() {
181 | return 'Replacement: ';
182 | }
183 | function close() {
184 | editor.hintStack.pop();
185 | editor.removeSubEditor(replaceField);
186 | closeFindField();
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/handlers/forward.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyName: 'right',
7 | do() {
8 | return select({
9 | editor,
10 | move() {
11 | return editor.forward();
12 | }
13 | });
14 | }
15 | });
16 |
--------------------------------------------------------------------------------
/handlers/language.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyName: 'control-l',
7 | do() {
8 | if (!editor.languages) {
9 | return false;
10 | }
11 | const languages = Object.values(editor.languages);
12 | let currentIndex = languages.indexOf(editor.language);
13 | currentIndex++;
14 | currentIndex %= languages.length;
15 | editor.setLanguage(languages[currentIndex]);
16 | return true;
17 | }
18 | });
19 |
--------------------------------------------------------------------------------
/handlers/page-down.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyNames: [ 'control-down', 'control-p' ],
7 | do() {
8 | if (editor.row === (editor.chars.length - 1)) {
9 | return false;
10 | }
11 | return select({
12 | editor,
13 | move() {
14 | editor.moveTo(Math.min(editor.row + editor.height, editor.chars.length - 1), editor.col);
15 | return true;
16 | }
17 | });
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/handlers/page-up.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyNames: [ 'control-up', 'control-o' ],
7 | do() {
8 | if (editor.row === 0) {
9 | return false;
10 | }
11 | return select({
12 | editor,
13 | move() {
14 | editor.moveTo(Math.max(editor.row - editor.height, 0), editor.col);
15 | return true;
16 | }
17 | });
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/handlers/paste.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor, clipboard }) => ({
4 | keyName: 'control-v',
5 | async do(key) {
6 | const eraseSelectionUndo = {};
7 | editor.eraseSelection(eraseSelectionUndo);
8 | const chars = await clipboard.get();
9 | if (chars === false) {
10 | return false;
11 | }
12 | const undo = {
13 | action: 'paste',
14 | row: editor.row,
15 | col: editor.col,
16 | chars,
17 | erasedChars: eraseSelectionUndo.chars
18 | };
19 | editor.insert(chars);
20 | undo.rowAfter = editor.row;
21 | undo.colAfter = editor.col;
22 | return {
23 | undo
24 | };
25 | },
26 | async undo(undo) {
27 | editor.moveTo(undo.rowAfter, undo.colAfter);
28 | for (let i = 0; (i < undo.chars.length); i++) {
29 | editor.back();
30 | editor.erase();
31 | }
32 | if (undo.erasedChars) {
33 | editor.insert(undo.erasedChars);
34 | }
35 | },
36 | async redo(redo) {
37 | editor.moveTo(editor.row, editor.col);
38 | return editor.handlers.paste.do();
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/handlers/redo.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-y',
5 | async do() {
6 | if (!editor.redos.length) {
7 | return false;
8 | }
9 | const task = editor.redos.pop();
10 | await editor.handlers[task.action].redo(task);
11 | editor.undos.push(task);
12 | return true;
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/handlers/return.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'return',
5 | do({ reversible = true, indent = true } = {}) {
6 | if (editor.return) {
7 | // Custom return handler instead, like for the editor used in the "find" field
8 | return editor.return();
9 | }
10 | const undo = reversible && {
11 | action: 'return',
12 | row: editor.row,
13 | col: editor.col
14 | };
15 | editor.insert([ '\r' ], { indent });
16 | if (reversible) {
17 | editor.undos.push(undo);
18 | }
19 | return true;
20 | },
21 | undo(undo) {
22 | editor.moveTo(undo.row, undo.col);
23 | editor.erase();
24 | },
25 | redo(redo) {
26 | editor.moveTo(redo.row, redo.col);
27 | editor.handlers.return.do();
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/handlers/save.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-s',
5 | async do() {
6 | if (editor.save) {
7 | return editor.save();
8 | }
9 | return false;
10 | }
11 | });
12 |
--------------------------------------------------------------------------------
/handlers/shift-selection-left.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: '[',
5 | selectionRequired: true,
6 | async do() {
7 | return {
8 | selecting: true,
9 | undo: editor.shiftSelection(-1)
10 | };
11 | },
12 | async undo(undo) {
13 | editor.setSelection(undo);
14 | for (const [ row, chars ] of Object.entries(undo.chars)) {
15 | editor.chars[row] = chars;
16 | }
17 | },
18 | async redo(redo) {
19 | editor.setSelection(redo);
20 | return editor.handlers.shiftSelectionLeft.do();
21 | }
22 | });
23 |
--------------------------------------------------------------------------------
/handlers/shift-selection-right.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: ']',
5 | selectionRequired: true,
6 | async do() {
7 | return {
8 | selecting: true,
9 | undo: editor.shiftSelection(1)
10 | };
11 | },
12 | async undo(undo) {
13 | editor.setSelection(undo);
14 | for (const [ row, chars ] of Object.entries(undo.chars)) {
15 | editor.chars[row] = chars;
16 | }
17 | },
18 | async redo(redo) {
19 | editor.setSelection(redo);
20 | return editor.handlers.shiftSelectionLeft.do();
21 | }
22 | });
23 |
--------------------------------------------------------------------------------
/handlers/start-of-line.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-left',
5 | do() {
6 | if (editor.col === 0) {
7 | return false;
8 | }
9 | while (!editor.sol()) {
10 | editor.back();
11 | }
12 | return true;
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/handlers/tab.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'tab',
5 | do() {
6 | const nextStop = editor.tabSpaces - (editor.col % editor.tabSpaces);
7 | const undo = {
8 | action: 'tab',
9 | row: editor.row,
10 | col: editor.col,
11 | spaces: nextStop
12 | };
13 | for (let n = 0; (n < nextStop); n++) {
14 | editor.insertChar(' ');
15 | }
16 | undo.afterRow = editor.row;
17 | undo.afterCol = editor.col;
18 | return {
19 | undo
20 | };
21 | },
22 | undo(task) {
23 | editor.moveTo(task.afterRow, task.afterCol);
24 | for (let n = 0; (n < task.spaces); n++) {
25 | editor.back();
26 | editor.erase();
27 | }
28 | },
29 | redo(task) {
30 | editor.moveTo(task.row, task.col);
31 | return editor.handlers.tab.do();
32 | }
33 | });
34 |
--------------------------------------------------------------------------------
/handlers/toggle-comment.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import startsWith from '../starts-with.js';
4 | import { inspect } from 'util';
5 |
6 | export default ({ editor }) => ({
7 | keyName: 'c',
8 | selectionRequired: true,
9 | async do() {
10 | let {
11 | selected,
12 | selRow1,
13 | selCol1,
14 | selRow2,
15 | selCol2
16 | } = editor.getSelection();
17 | editor.moveTo(selRow1, 0);
18 | editor.selRow = selRow2;
19 | editor.selCol = editor.chars[selRow2].length;
20 | if (selCol2 === 0) {
21 | selRow2--;
22 | editor.selCol = 0;
23 | }
24 | const undo = {
25 | action: 'toggleComment',
26 | row: editor.row,
27 | col: editor.col,
28 | selRow: editor.selRow,
29 | selCol: editor.selCol,
30 | chars: []
31 | };
32 | const isComment = editor.language.isComment(editor.chars[selRow1]);
33 | const commentLine = [ ...editor.language.commentLine, ' ' ];
34 | for (let row = selRow1; (row <= selRow2); row++) {
35 | const chars = editor.chars[row];
36 | if (isComment) {
37 | for (let col = 0; (col < chars.length); col++) {
38 | if (startsWith(chars, col, commentLine)) {
39 | undo.chars[row] = [...chars];
40 | editor.chars[row] = [ ...chars.slice(0, col), ...chars.slice(col + commentLine.length) ];
41 | }
42 | }
43 | } else {
44 | for (let col = 0; (col < chars.length); col++) {
45 | if (chars[col] !== ' ') {
46 | undo.chars[row] = [...chars];
47 | editor.chars[row] = [ ...chars.slice(0, col), ...commentLine, ...chars.slice(col) ];
48 | break;
49 | }
50 | }
51 | }
52 | }
53 | return {
54 | selecting: true,
55 | undo
56 | };
57 | },
58 | async undo(undo) {
59 | editor.setSelection(undo);
60 | for (const [ row, chars ] of Object.entries(undo.chars)) {
61 | editor.chars[row] = [...chars];
62 | }
63 | },
64 | async redo(redo) {
65 | editor.setSelection(redo);
66 | editor.handlers.toggleComment.do();
67 | }
68 | });
69 |
--------------------------------------------------------------------------------
/handlers/type.js:
--------------------------------------------------------------------------------
1 | export default ({ editor }) => ({
2 | do(key) {
3 | // Block function keys, control keys, etc. from winding up in content
4 | // without blocking emoji, which count as a single iterable in a string.
5 | // TODO: we need a representation of keys that doesn't require this
6 | // inefficiency
7 | let count = 0;
8 | for (const b of key) {
9 | count++;
10 | }
11 | if (count > 1) {
12 | return;
13 | }
14 | let appending = false;
15 | if (editor.eol()) {
16 | appending = true;
17 | }
18 | let undo;
19 | // Append to previous undo object if it is also typed text, it does not end with a word break,
20 | // and we haven't moved since
21 | let lastUndo = editor.undos[editor.undos.length - 1];
22 | if (lastUndo) {
23 | if ((lastUndo.lastRow === editor.row) && (lastUndo.lastCol === editor.col)) {
24 | if (lastUndo.action === 'type') {
25 | const lastChar = lastUndo.chars.length > 0 && lastUndo.chars[lastUndo.chars.length - 1];
26 | if (lastChar !== ' ') {
27 | undo = lastUndo;
28 | } else {
29 | lastUndo = false;
30 | }
31 | } else {
32 | lastUndo = false;
33 | }
34 | } else {
35 | lastUndo = false;
36 | }
37 | }
38 | if (!lastUndo) {
39 | undo = {
40 | action: 'type',
41 | row: editor.row,
42 | col: editor.col,
43 | chars: []
44 | };
45 | }
46 | undo.chars.push(key);
47 | editor.insertChar(key);
48 | undo.lastRow = editor.row;
49 | undo.lastCol = editor.col;
50 | return {
51 | appending,
52 | ...(lastUndo ? {} : {
53 | undo
54 | })
55 | };
56 | },
57 | undo(undo) {
58 | editor.moveTo(undo.row, undo.col);
59 | for (let i = 0; (i < undo.chars.length); i++) {
60 | editor.erase();
61 | }
62 | },
63 | redo(redo) {
64 | editor.moveTo(redo.row, redo.col);
65 | for (const char of redo.chars) {
66 | editor.handlers.type.do(char);
67 | }
68 | }
69 | });
70 |
--------------------------------------------------------------------------------
/handlers/undo.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor }) => ({
4 | keyName: 'control-z',
5 | async do(key) {
6 | if (!editor.undos.length) {
7 | return false;
8 | }
9 | const task = editor.undos.pop();
10 | await editor.handlers[task.action].undo(task);
11 | editor.redos.push(task);
12 | return true;
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/handlers/up.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import select from '../select.js';
4 |
5 | export default ({ editor }) => ({
6 | keyName: 'up',
7 | do() {
8 | if (editor.row === 0) {
9 | return false;
10 | }
11 | return select({
12 | editor,
13 | move() {
14 | return editor.up();
15 | }
16 | });
17 | }
18 | });
19 |
--------------------------------------------------------------------------------
/languages/default.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | // Fallback language engine. Used when nothing better matches the file extension.
4 | // Also serves as an interface definition for writing new langugae engines
5 |
6 | export { extensions, parse, newState, shouldCloseBlock, style, styleBehind, isComment, commentLine };
7 |
8 | const extensions = [];
9 |
10 | // Return initial state, which will be passed to `parse` with each character crossed.
11 | // The state must be compatible with structuredClone() and generally shouldn't need
12 | // to be large, e.g. a stack of open containers, a parsing state machine state, and
13 | // hints to resolve multicharacter operators etc.
14 | //
15 | // depth must be provided for indentation purposes and typically defaults to 0.
16 | // No other properties of the state are currently inspected outside of the language engine
17 |
18 | function newState() {
19 | return {
20 | // Currently the only property of the state that code outside the language
21 | // engine is allowed to inspect
22 | depth: 0
23 | };
24 | }
25 |
26 | // Parse a character, updating the parse state for purposes of indentation,
27 | // syntax highlighting, etc. Modifies state.
28 |
29 | function parse(state, char) {
30 | // modifies state
31 | }
32 |
33 | // Should return true if typing char should close a block (drop to next line,
34 | // decrease depth, indent, insert char) given current state
35 |
36 | function shouldCloseBlock(state, char) {
37 | return false;
38 | }
39 |
40 | // Should return a style name (see state-styles.js) or false based on state
41 |
42 | function style(state) {
43 | return false;
44 | }
45 |
46 | // Should return a style name only if the style of the character we just
47 | // advanced over should be changed to reflect the new state. This makes
48 | // sense e.g. for a character that caused the parser to enter an "error"
49 | // state. If you're not sure just return false and see what it's like.
50 | // If false is returned, the return value of the style() call made
51 | // immediately before advancing over the character is honored instead
52 |
53 | function styleBehind(state) {
54 | return false;
55 | }
56 |
57 | // Return true if the characters in "chars' represent a line that
58 | // is entirely commented out with a single-line comment like JavaScript //
59 |
60 | function isComment(chars) {
61 | return false;
62 | }
63 |
64 | // array of characters to be used to comment out a line, like
65 | // JavaScript [ '/', '/' ]
66 | const commentLine = [];
67 |
--------------------------------------------------------------------------------
/languages/javascript.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import startsWith from '../starts-with.js';
4 |
5 | export {
6 | extensions,
7 | parse,
8 | newState,
9 | shouldOpenBlock,
10 | shouldCloseBlock,
11 | forwardToCloser,
12 | backToOpener,
13 | style,
14 | styleBehind,
15 | isComment,
16 | commentLine
17 | };
18 |
19 | const extensions = [ 'js', 'mjs', 'ts' ];
20 |
21 | const validBeforeRegexp = [
22 | null,
23 | '{',
24 | '!',
25 | '&',
26 | '|',
27 | '(',
28 | ',',
29 | '=',
30 | ';'
31 | ];
32 |
33 | const openers = {
34 | '{': '}',
35 | '[': ']',
36 | '(': ')'
37 | };
38 |
39 | const closers = {
40 | '}': '{',
41 | ']': '[',
42 | ')': '('
43 | };
44 |
45 | const needsStyleBehind = [ 'code', 'error', 'regexp', 'regexpEscape', 'regexpRange' ];
46 |
47 | // Last on the stack of nested containers
48 | function last(state) {
49 | return state.stack[state.stack.length - 1];
50 | }
51 |
52 | // Look back one or more nonspace characters (currently capped at 10)
53 | function behind(state, n) {
54 | return (n <= state.marks.length) ? state.marks[state.marks.length - n] : null;
55 | }
56 |
57 | // Return initial state, which will be passed to parse with each character crossed
58 |
59 | function newState() {
60 | return {
61 | state: 'code',
62 | stack: [],
63 | // Indentation depth, in tabstops. Might not match stack.length because
64 | // we respect a developer's choice to put ({ on a single line etc.
65 | depth: 0,
66 | marks: []
67 | };
68 | }
69 |
70 | // Parse a character, updating the parse state for purposes of indentation,
71 | // syntax highlighting, etc. Modifies state.
72 |
73 | function parse(state, char, {
74 | log,
75 | row,
76 | col
77 | }) {
78 | let maybeComment = false;
79 | let maybeCloseComment = false;
80 | let maybeCode = false;
81 | let skipMark = false;
82 | if (state.state === 'code') {
83 | if (
84 | (behind(state, 1) === '/') &&
85 | validBeforeRegexp.includes(behind(state, 2)) &&
86 | (char !== '/') &&
87 | (char !== '*')
88 | ) {
89 | if (char === '\\') {
90 | state.state = 'regexpEscape';
91 | } else if (char === '[') {
92 | state.state = 'regexpRange';
93 | } else {
94 | state.state = 'regexp';
95 | }
96 | } else if ((char === '}') && (last(state)?.type === 'backtick')) {
97 | state.stack.pop();
98 | state.state = 'backtick';
99 | } else if (openers[char]) {
100 | // Don't indent twice if two openers appear on the same row, like the
101 | // common pattern ({
102 | const indents = !state.stack.find(block => block.row === row);
103 | if (indents) {
104 | state.depth++;
105 | }
106 | state.stack.push({
107 | type: char,
108 | row,
109 | indents
110 | });
111 | } else if (closers[char]) {
112 | const lastOpener = last(state)?.type;
113 | const opener = closers[char];
114 | if (lastOpener === opener) {
115 | if (last(state).indents) {
116 | state.depth--;
117 | }
118 | state.stack.pop();
119 | } else {
120 | state.state = 'error';
121 | }
122 | } else if (char === '\'') {
123 | state.state = 'single';
124 | } else if (char === '"') {
125 | state.state = 'double';
126 | } else if (char === '`') {
127 | state.state = 'backtick';
128 | } else if (char === '/') {
129 | if (state.maybeComment) {
130 | state.state = '//';
131 | } else {
132 | maybeComment = true;
133 | }
134 | } else if (char === '*') {
135 | if (state.maybeComment) {
136 | state.state = '/*';
137 | } else {
138 | maybeComment = true;
139 | }
140 | }
141 | } else if (state.state === 'regexp') {
142 | if (char === '\\') {
143 | state.state = 'regexpEscape';
144 | } else if (char === '/') {
145 | state.state = 'code';
146 | // So it can't be misread as the start of another
147 | skipMark = true;
148 | } else if (char === '[') {
149 | state.state = 'regexpRange';
150 | }
151 | } else if (state.state === 'regexpEscape') {
152 | state.state = 'regexp';
153 | } else if (state.state === 'regexpRange') {
154 | if (char === '\\') {
155 | state.state = 'regexpRangeEscape';
156 | } else if (char === ']') {
157 | state.state = 'regexp';
158 | }
159 | } else if (state.state === 'regexpRangeEscape') {
160 | state.state = 'regexpRange';
161 | } else if (state.state === 'single') {
162 | if (char === '\\') {
163 | state.state = 'singleEscape';
164 | } else if (char === '\'') {
165 | state.state = 'code';
166 | } else if (char === '\r') {
167 | state.state = 'error';
168 | }
169 | } else if (state.state === 'singleEscape') {
170 | state.state = 'single';
171 | } else if (state.state === 'singleEscape') {
172 | state.state = 'single';
173 | } else if (state.state === 'double') {
174 | if (char === '\\') {
175 | state.state = 'doubleEscape';
176 | } else if (char === '"') {
177 | state.state = 'code';
178 | } else if (char === '\r') {
179 | state.state = 'error';
180 | }
181 | } else if (state.state === 'doubleEscape') {
182 | state.state = 'double';
183 | } else if (state.state === 'backtick') {
184 | if (char === '\\') {
185 | state.state = 'backtickEscape';
186 | } else if (char === '`') {
187 | state.state = 'code';
188 | } else if (char === '$') {
189 | maybeCode = true;
190 | } else if ((state.maybeCode) && (char === '{')) {
191 | state.maybeCode = false;
192 | state.state = 'code';
193 | state.stack.push({
194 | type: 'backtick',
195 | indents: false
196 | });
197 | }
198 | } else if (state.state === 'backtickEscape') {
199 | state.state = 'backtick';
200 | } else if (state.state === 'error') {
201 | // Cool is a rule, but sometimes... bad is bad.
202 | // The developer very definitely has to fix something above
203 | // this point, so stick to our highlighting as "bad!"
204 | } else if (state.state === '//') {
205 | if (char === '\r') {
206 | state.state = 'code';
207 | state.marks.pop();
208 | }
209 | } else if (state.state === '/*') {
210 | if (char === '*') {
211 | maybeCloseComment = true;
212 | } else if (char === '/') {
213 | if (state.maybeCloseComment) {
214 | state.state = 'code';
215 | state.marks.pop();
216 | skipMark = true;
217 | }
218 | }
219 | }
220 | state.maybeComment = maybeComment;
221 | state.maybeCloseComment = maybeCloseComment;
222 | state.maybeCode = maybeCode;
223 | if ((!skipMark) && (state.state !== '//') && (state.state !== '/*') && (char !== ' ') && (char !== '\r')) {
224 | state.marks.push(char);
225 | if (state.marks.length === 11) {
226 | state.marks.shift();
227 | }
228 | }
229 | }
230 |
231 | function shouldCloseBlock(state, char) {
232 | return (state.state === 'code') && closers[char];
233 | }
234 |
235 | function shouldOpenBlock(state, char) {
236 | return (state.state === 'code') && openers[char];
237 | }
238 |
239 | function forwardToCloser(state, { forward, back, log }) {
240 | // We are sitting right before the opener
241 | if (!forward()) {
242 | return false;
243 | }
244 | const length = state.stack.length;
245 | do {
246 | state = forward();
247 | if (!state) {
248 | // A position change has occurred, even if the result is surprising
249 | return true;
250 | }
251 | } while (state.stack.length >= length);
252 | // We overshot by advancing over the closer
253 | return back();
254 | }
255 |
256 | function backToOpener(state, { forward, back, log }) {
257 | // We are already right before the closer so we don't have to back over it
258 | const length = state.stack.length;
259 | let once = false;
260 | do {
261 | state = back();
262 | if (!state) {
263 | return once;
264 | }
265 | once = true;
266 | } while (state.stack.length >= length);
267 | // The cursor is now sitting on (before) the opener
268 | return true;
269 | }
270 |
271 | function style(state) {
272 | return state.state;
273 | }
274 |
275 | function styleBehind(state) {
276 | return needsStyleBehind.includes(state.state) ? state.state : false;
277 | }
278 |
279 | function isComment(rowOfChars) {
280 | for (let i = 0; (i < rowOfChars.length); i++) {
281 | if (startsWith(rowOfChars, i, commentLine)) {
282 | return true;
283 | }
284 | if (rowOfChars[i] !== ' ') {
285 | return false;
286 | }
287 | }
288 | }
289 |
290 | const commentLine = [ '/', '/' ];
291 |
--------------------------------------------------------------------------------
/load-handler-factories.js:
--------------------------------------------------------------------------------
1 | import * as url from 'url';
2 | import fs from 'fs';
3 | import camelize from './camelize.js';
4 |
5 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
6 | export default async () => {
7 | const handlers = {};
8 | const names = fs.readdirSync(`${__dirname}/handlers`);
9 | for (let name of names) {
10 | const handler = await import(`${__dirname}/handlers/${name}`);
11 | name = camelize(name.replace('.js', ''));
12 | handlers[name] = handler.default;
13 | }
14 | return handlers;
15 | };
16 |
--------------------------------------------------------------------------------
/load-languages.js:
--------------------------------------------------------------------------------
1 | import * as url from 'url';
2 | import fs from 'fs';
3 | import camelize from './camelize.js';
4 |
5 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
6 | export default async () => {
7 | const languages = {};
8 | const names = fs.readdirSync(`${__dirname}/languages`);
9 | for (let name of names) {
10 | const language = await import(`${__dirname}/languages/${name}`);
11 | name = camelize(name.replace('.js', ''));
12 | languages[name] = language;
13 | }
14 | return languages;
15 | };
16 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@boutell/tome",
3 | "version": "0.4.1",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "@boutell/tome",
9 | "version": "0.4.1",
10 | "license": "GPLv3",
11 | "dependencies": {
12 | "ansi-escapes": "^6.2.0",
13 | "ansi-styles": "^6.2.1",
14 | "boring": "^1.1.1",
15 | "proper-lockfile": "^4.1.2"
16 | },
17 | "bin": {
18 | "tome": "bin/tome.js"
19 | }
20 | },
21 | "node_modules/ansi-escapes": {
22 | "version": "6.2.0",
23 | "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
24 | "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
25 | "dependencies": {
26 | "type-fest": "^3.0.0"
27 | },
28 | "engines": {
29 | "node": ">=14.16"
30 | },
31 | "funding": {
32 | "url": "https://github.com/sponsors/sindresorhus"
33 | }
34 | },
35 | "node_modules/ansi-styles": {
36 | "version": "6.2.1",
37 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
38 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
39 | "engines": {
40 | "node": ">=12"
41 | },
42 | "funding": {
43 | "url": "https://github.com/chalk/ansi-styles?sponsor=1"
44 | }
45 | },
46 | "node_modules/boring": {
47 | "version": "1.1.1",
48 | "resolved": "https://registry.npmjs.org/boring/-/boring-1.1.1.tgz",
49 | "integrity": "sha512-2JQukkqYeUPzUrwMOM8o6YcmNDtsir3h/ityMLJadvrXNP3uovZXptxYJGs6Lb7Eiwn5j6BZRyLSFn81uDOXPg=="
50 | },
51 | "node_modules/graceful-fs": {
52 | "version": "4.2.11",
53 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
54 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
55 | },
56 | "node_modules/proper-lockfile": {
57 | "version": "4.1.2",
58 | "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
59 | "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
60 | "dependencies": {
61 | "graceful-fs": "^4.2.4",
62 | "retry": "^0.12.0",
63 | "signal-exit": "^3.0.2"
64 | }
65 | },
66 | "node_modules/retry": {
67 | "version": "0.12.0",
68 | "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
69 | "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
70 | "engines": {
71 | "node": ">= 4"
72 | }
73 | },
74 | "node_modules/signal-exit": {
75 | "version": "3.0.7",
76 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
77 | "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
78 | },
79 | "node_modules/type-fest": {
80 | "version": "3.13.1",
81 | "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
82 | "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
83 | "engines": {
84 | "node": ">=14.16"
85 | },
86 | "funding": {
87 | "url": "https://github.com/sponsors/sindresorhus"
88 | }
89 | }
90 | },
91 | "dependencies": {
92 | "ansi-escapes": {
93 | "version": "6.2.0",
94 | "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
95 | "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
96 | "requires": {
97 | "type-fest": "^3.0.0"
98 | }
99 | },
100 | "ansi-styles": {
101 | "version": "6.2.1",
102 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
103 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="
104 | },
105 | "boring": {
106 | "version": "1.1.1",
107 | "resolved": "https://registry.npmjs.org/boring/-/boring-1.1.1.tgz",
108 | "integrity": "sha512-2JQukkqYeUPzUrwMOM8o6YcmNDtsir3h/ityMLJadvrXNP3uovZXptxYJGs6Lb7Eiwn5j6BZRyLSFn81uDOXPg=="
109 | },
110 | "graceful-fs": {
111 | "version": "4.2.11",
112 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
113 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
114 | },
115 | "proper-lockfile": {
116 | "version": "4.1.2",
117 | "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
118 | "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
119 | "requires": {
120 | "graceful-fs": "^4.2.4",
121 | "retry": "^0.12.0",
122 | "signal-exit": "^3.0.2"
123 | }
124 | },
125 | "retry": {
126 | "version": "0.12.0",
127 | "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
128 | "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="
129 | },
130 | "signal-exit": {
131 | "version": "3.0.7",
132 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
133 | "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
134 | },
135 | "type-fest": {
136 | "version": "3.13.1",
137 | "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
138 | "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@boutell/tome",
3 | "version": "0.13.1",
4 | "description": "A text editor, focused on JavaScript",
5 | "main": "app.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/boutell/tome.git"
12 | },
13 | "keywords": [
14 | "text",
15 | "editor",
16 | "code",
17 | "editor"
18 | ],
19 | "author": "Thomas Boutell",
20 | "license": "GPLv3",
21 | "bugs": {
22 | "url": "https://github.com/boutell/tome/issues"
23 | },
24 | "bin": {
25 | "tome": "./bin/tome.js"
26 | },
27 | "homepage": "https://github.com/boutell/tome#readme",
28 | "dependencies": {
29 | "ansi-escapes": "^6.2.0",
30 | "ansi-styles": "^6.2.1",
31 | "boring": "^1.1.1",
32 | "proper-lockfile": "^4.1.2"
33 | },
34 | "type": "module"
35 | }
36 |
--------------------------------------------------------------------------------
/readline-test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const readline = require('readline');
4 |
5 | const rl = readline.createInterface(process.stdin);
6 | process.stdin.setRawMode(true);
7 | readline.emitKeypressEvents(process.stdin);
8 |
9 | process.stdin.on('keypress', (c, k) => {
10 | if (c === 'c') {
11 | process.exit(0);
12 | }
13 | console.log(JSON.stringify({ c, k }, null, ' '));
14 | if (c != null) {
15 | for (const ch of c) {
16 | console.log(ch.charCodeAt(0));
17 | }
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/screen.js:
--------------------------------------------------------------------------------
1 | import ansi from 'ansi-escapes';
2 | import styles from 'ansi-styles';
3 | import stateStyles from './state-styles.js';
4 |
5 | const ESC = '\u001B[';
6 | // http://www.sweger.com/ansiplus/EscSeqScroll.html
7 | const scrollLeftSequence = `${ESC} @`;
8 | const scrollRightSequence = `${ESC} A`;
9 |
10 | export default class Screen {
11 | constructor({
12 | stdout,
13 | log
14 | }) {
15 | this.stdout = stdout;
16 | this.log = log;
17 | this.stdout.write(ansi.clearScreen);
18 | this.row = 0;
19 | this.col = 0;
20 | this.resize();
21 | }
22 | set(col, row, char, style = false) {
23 | const nextCell = this.next[row][col];
24 | nextCell[0] = char;
25 | nextCell[1] = style;
26 | }
27 | cursor(col, row) {
28 | this.col = col;
29 | this.row = row;
30 | }
31 | resize() {
32 | this.stdout.write(ansi.clearScreen);
33 | this.current = this.allocate();
34 | this.next = this.allocate();
35 | }
36 | allocate() {
37 | const data = [];
38 | this.width = process.stdout.columns;
39 | this.height = process.stdout.rows;
40 | for (let row = 0; (row < this.height); row++) {
41 | const cells = [];
42 | for (let col = 0; (col < this.width); col++) {
43 | cells.push([ ' ', false ]);
44 | }
45 | data.push(cells);
46 | }
47 | return data;
48 | }
49 | draw(scrollDirection) {
50 | const stdout = this.stdout;
51 | stdout.write(ansi.cursorHide);
52 | if (scrollDirection === 'up') {
53 | this.scrollUp();
54 | } else if (scrollDirection === 'down') {
55 | this.scrollDown();
56 | }
57 | for (let row = 0; (row < this.height); row++) {
58 | for (let col = 0; (col < this.width); col++) {
59 | const currentCell = this.current[row][col];
60 | const nextCell = this.next[row][col];
61 | if ((currentCell[0] !== nextCell[0]) || (currentCell[1] !== nextCell[1])) {
62 | stdout.write(ansi.cursorTo(col, row));
63 | if (nextCell[1]) {
64 | stdout.write(styles[stateStyles[nextCell[1]]].open);
65 | }
66 | stdout.write(nextCell[0]);
67 | if (nextCell[1]) {
68 | stdout.write(styles[stateStyles[nextCell[1]]].close);
69 | }
70 | currentCell[0] = nextCell[0];
71 | currentCell[1] = nextCell[1];
72 | }
73 | }
74 | }
75 | stdout.write(ansi.cursorShow);
76 | stdout.write(ansi.cursorTo(this.col, this.row));
77 | }
78 | // Scroll the actual screen up a row and update our virtual screen to match
79 | scrollUp() {
80 | this.scroll(ansi.scrollUp);
81 | for (let row = 0; (row < this.height - 4); row++) {
82 | for (let col = 0; (col < this.width); col++) {
83 | this.current[row][col][0] = this.current[row + 1][col][0];
84 | this.current[row][col][1] = this.current[row + 1][col][1];
85 | }
86 | }
87 | for (let col = 0; (col < this.width); col++) {
88 | this.current[this.height - 4][col][0] = ' ';
89 | this.current[this.height - 4][col][1] = false;
90 | }
91 | }
92 | // Scroll the actual screen up a row and update our virtual screen to match
93 | scrollDown() {
94 | this.scroll(ansi.scrollDown);
95 | for (let row = this.height - 5; (row >= 1); row--) {
96 | for (let col = 0; (col < this.width); col++) {
97 | this.current[row][col][0] = this.current[row - 1][col][0];
98 | this.current[row][col][1] = this.current[row - 1][col][1];
99 | }
100 | }
101 | for (let col = 0; (col < this.width); col++) {
102 | this.current[0][col][0] = ' ';
103 | this.current[0][col][1] = false;
104 | }
105 | }
106 | scroll(sequence) {
107 | // Not present in ansi-escapes but generally supported: lock the scroll region to
108 | // exclude the status and help area
109 | this.stdout.write(`${ESC}${0};${this.height - 3}r`);
110 | this.stdout.write(sequence);
111 | this.stdout.write(`${ESC}${0};${this.height - 1}r`);
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/select.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export default ({ editor, move }) => {
4 | const _row = editor.row, _col = editor.col;
5 |
6 | if (!move()) {
7 | return false;
8 | }
9 | if (!editor.selectMode) {
10 | return true;
11 | }
12 | if (editor.selRow === false) {
13 | editor.selRow = _row;
14 | editor.selCol = _col;
15 | }
16 | if ((editor.selRow === editor.row) && (editor.selCol === editor.col)) {
17 | return false;
18 | }
19 | return {
20 | selecting: true
21 | };
22 | };
23 |
--------------------------------------------------------------------------------
/selectors-by-name.js:
--------------------------------------------------------------------------------
1 | export default {
2 | 'shift-up': 'up',
3 | 'shift-right': 'forward',
4 | 'shift-down': 'down',
5 | 'shift-left': 'back'
6 | };
7 |
--------------------------------------------------------------------------------
/starts-with.js:
--------------------------------------------------------------------------------
1 | // Return true if a contains the contents of b beginning
2 | // at the given offset, where a and b are arrays
3 |
4 | export default function(a, offset, b) {
5 | if (b.length > a.length) {
6 | return false;
7 | }
8 | for (let i = 0; (i < b.length); i++) {
9 | if (a[i + offset] !== b[i]) {
10 | return false;
11 | }
12 | }
13 | return true;
14 | }
--------------------------------------------------------------------------------
/state-styles.js:
--------------------------------------------------------------------------------
1 | export default {
2 | selected: 'inverse',
3 | code: 'white',
4 | error: 'red',
5 | single: 'greenBright',
6 | singleEscape: 'greenBright',
7 | double: 'blueBright',
8 | doubleEscape: 'blueBright',
9 | backtick: 'magentaBright',
10 | backtickEscape: 'magentaBright',
11 | '//': 'yellowBright',
12 | '/*': 'cyanBright',
13 | regexp: 'green',
14 | regexpEscape: 'green',
15 | regexpRange: 'green',
16 | regexpRangeEscape: 'green'
17 | };
18 |
--------------------------------------------------------------------------------
/tome-screenshot-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boutell/tome/f42e9e54e16227fa68bde0d922ecc4c5f651913a/tome-screenshot-2.png
--------------------------------------------------------------------------------