├── .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 | ![Screenshot of tome in action](https://github.com/boutell/tome/blob/main/tome-screenshot-2.png?raw=true) 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 --------------------------------------------------------------------------------