├── .gitignore ├── LICENSE ├── README.md ├── media ├── pic.jpeg └── vid.mov ├── piwrite ├── __init__.py ├── __main__.py ├── buffer.py ├── cmaps_helper.py ├── cursor.py ├── dispatcher.py ├── display.py ├── editor.py ├── help ├── line.py ├── markdownify.py ├── mode.py ├── server.py └── static │ ├── 30socketio.js │ ├── ImFell-SC.ttf │ ├── ImFell-i.ttf │ ├── ImFell.ttf │ ├── cmunbx.ttf │ ├── cmunrm.ttf │ ├── cmunti.ttf │ ├── cooper-book-i.otf │ ├── cooper-book.otf │ ├── cooper-heavy.otf │ ├── index.css │ ├── index.html │ ├── index.js │ ├── monoid-bold.ttf │ ├── monoid-regular.ttf │ ├── socket.io.min.js │ ├── texgyreheros-bold.otf │ ├── texgyreheros-italic.otf │ └── texgyreheros-regular.otf ├── poetry.lock ├── pyproject.toml └── test ├── __init__.py ├── test_cmaps_helper.py ├── test_editor.py └── test_markdownify.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.swp 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ruben Berenguel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PiWrite 2 | 3 | 4 | https://github.com/rberenguel/PiWrite/assets/2410938/7902e0e9-7fd3-4d74-926b-1ac7ad85b84e 5 | 6 | 7 | #### What? 8 | 9 | Have you ever wanted to use your Kindle Paperwhite to write, even more, with a vim-like editor? This is what PiWrite is for. 10 | 11 | #### How? 12 | 13 | The TL;DR is _a webserver running somewhere a keyboard can be plugged, and a page opened in the Kindle's web browser_. 14 | 15 | The not-so-short version requires more effort and details, but is the UX I wanted to get: 16 | 17 | - A **Raspberry Pi Zero W**… 18 | - Paired with a **Bluetooth keyboard**… 19 | - Set up in **access point mode**… 20 | - With **this package** installed… 21 | - And configured to **start automatically** on boot. 22 | 23 | #### Why? 24 | 25 | I was inspired by [SolarWriter by Michael Solomon](https://solarwriter.msol.io). I had always wanted to use my Kindle for writing. SolarWriter solves that by setting up a local web server on your phone (iOS or Android), then you type with a Bluetooth keyboard paired with it. But you need to set up hotspot, keep your screen on… I didn't like those parts. So I wrote this. 26 | 27 | #### Contributions? 28 | 29 | This is open source, and I'll be happy to see it extended and improved. But I'm unlikely to accept contributions: I want a reduced feature set, with only what _I_ need. This is why I didn't release this to PyPI, so anybody can have its own version with custom tweaks and installs it easily from their own repository. 30 | 31 | --- 32 | 33 | 34 | 35 | --- 36 | 37 | # Installing the package 38 | 39 | With a current enough version of `pip` you can install directly from the repository (or from your fork) with 40 | 41 | ```bash 42 | pip install piwrite@git+https://github.com/rberenguel/PiWrite 43 | ``` 44 | 45 | Or with pipx (**recommended**) with 46 | 47 | ```bash 48 | pipx install piwrite@git+https://github.com/rberenguel/PiWrite 49 | ``` 50 | You might need to add `/home/YOU/.local/bin` to your `PATH` (like adding `export PATH="/home/YOU/.local/bin:$PATH"` at the end of your `.bashrc`, `.zshenv` or similar). 51 | 52 | # Trying it before installing 53 | 54 | Once you have installed it you can try it locally (by default it will serve back at `127.0.0.1:80`), and optionally configure host and port, like: 55 | 56 | ```bash 57 | PIWRITE_HOST=pi 58 | PIWRITE_PORT=31415 59 | ``` 60 | 61 | Point your web browser to this address and try! The editor is vim inspired, and the instructions can be found in [help](piwrite/help) 62 | 63 | # Setting up your Raspberry Pi Zero 64 | 65 | If you need a Pi, I can't recommend [Pimoroni](https://shop.pimoroni.com) enough. I'm not affiliated, I just buy always from them. 66 | 67 | The instructions below may be missing some piece, I have written it based on my bash history and what I remember having to tweak. With the information of _what_ you need to do, there are plenty of tutorials on how to approach each step though. 68 | 69 | ## Basics 70 | 71 | Best is installing a lightweight Raspbian version, since the Zero is not a terribly fast machine. By "mistake" (I was trying something) I updated the lite version (on Buster, I think) to Bookworm. Don't do that, not needed. 72 | 73 | To configure everything you will need to set up the Raspberry for `ssh` access, and better with password. For using it as a "magical thing that lets the Kindle work as a text editor" is better if you disable requiring password for logging in via `tty`. You can enable this (known as autologin) by running `sudo raspi-config`, in the _System Options_ section. You specifically want _Console autologin_. 74 | 75 | You also better set up wifi connectivity too. You can set this up by adding a `wpa_supplicant.conf` file to the boot partition of the SD card with contents like the following: 76 | 77 | ``` 78 | network={ 79 | ssid="YOUR_NETWORK_NAME" 80 | psk="YOUR_PASSWORD" 81 | key_mgmt=WPA-PSK 82 | } 83 | ``` 84 | 85 | Steps needed after this: 86 | - Pair with a Bluetooth keyboard; 87 | - Set up a wireless access point on your Raspberry; 88 | - Install the package and set it up; 89 | - Nice-to-have: ssh via USB (there are many tutorials for this). 90 | 91 | ## Pairing with a Bluetooth keyboard 92 | 93 | Pick your poison. The standard way is using `bluetoothctl`. I found that installing [Bluetuith](https://darkhz.github.io/bluetuith/Installation.html) was more convenient to be sure the pairing had worked. On the con side, you need to install the whole Go runtime. 94 | 95 | Remember: the keyboard will be usable in the `tty` session, NOT in any ssh-initiated session. 96 | 97 | If you want any fancy keyboard configuration (I use Colemak, and like my caps to be control) you will have to edit `/etc/default/keyboard` and add something like the following: 98 | 99 | ``` 100 | XKBMODEL="pc105" 101 | XKBLAYOUT="us" 102 | XKBVARIANT="colemak" 103 | XKBOPTIONS="ctrl:nocaps" 104 | ``` 105 | 106 | ## Wireless access point 107 | 108 | I followed the instructions from here: [Turn a Raspberry Pi into a Web Server with Its Own Wifi Network (Tech Note)](https://www.stevemurch.com/setting-up-a-raspberry-pi-for-ad-hoc-networking-tech-note/2022/12). From these instructions, you can (optionally) skip the routing stuff for this. Although the post mentions _only_ working in Buster, I followed the exact same steps and worked just fine in Bookworm. 109 | 110 | The TL;DR version: 111 | 112 | 113 | Get the access point and DNS services 114 | ``` 115 | sudo apt install hostapd dnsmasq 116 | ``` 117 | 118 | Turn it on 119 | ``` 120 | sudo systemctl unmask hostapd 121 | sudo systemctl enable hostapd 122 | ``` 123 | 124 | Edit `/etc/dhcpcd.conf` (sudo) and add at the end 125 | 126 | ``` 127 | interface wlan0 128 | static ip_address=192.168.11.1/24 129 | nohook wpa_supplicant 130 | ``` 131 | 132 | > [!IMPORTANT] 133 | > When you want to re-connect your Zero to your wifi, you need to comment this out, otherwise you are out of AP access and out of SSH via Wifi (or even USB gadget) access. If you forget, you'll need to edit the raw disk from another Linux device. 134 | 135 | You now need to configure `/etc/dnsmasq.conf` with 136 | 137 | ``` 138 | interface=wlan0 # Listening interface 139 | dhcp-range=192.168.11.2,192.168.11.20,255.255.255.0,24h 140 | # Pool of IP addresses served via DHCP 141 | domain=write # Local wireless DNS domain 142 | address=/pi/192.168.11.1 143 | # Alias for this router 144 | ``` 145 | and now that you are at it, change `/etc/hostname` to be `pi`. 146 | 147 | Finally, configure `/etc/hostapd/hostapd.conf` with (use your country code, of course) 148 | 149 | ``` 150 | country_code=CH 151 | interface=wlan0 152 | ssid=EnchantedRose 153 | hw_mode=g 154 | channel=7 155 | macaddr_acl=0 156 | auth_algs=1 157 | ignore_broadcast_ssid=0 158 | wpa=2 159 | wpa_passphrase=CHOOSE SOMETHING 160 | wpa_key_mgmt=WPA-PSK 161 | wpa_pairwise=TKIP 162 | rsn_pairwise=CCMP 163 | ``` 164 | 165 | Now, reboot (`sudo shutdown -r now` or `sudo systemctl reboot`). 166 | 167 | If you ever want to disable AP and enable normal wifi, run `sudo systemctl disable hostapd dnsmasq` AND remove the static IP setting mentioned above. 168 | 169 | ## Install the package and set it up 170 | 171 | Install pipx with `sudo apt install pipx` and then install piwrite with 172 | 173 | ```bash 174 | pipx install piwrite@git+https://github.com/rberenguel/PiWrite 175 | ``` 176 | 177 | You'll want to add `pipx`'s binaries to the path, for example by adding `export PATH="/home/YOU/.local/bin:$PATH"`. 178 | 179 | Since the ideal experience is _not_ having to add a port in the Kindle browser, the default port piwrite uses is 80. But that needs allowlisting: 180 | 181 | `sudo setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/python3.11` 182 | 183 | Tweak the Python version depending on what you have. 184 | 185 | You can test if it works (i.e.. if it is the right version or not) or not by starting piwrite now (you may need to change the exported host). 186 | 187 | You'll also want to start piwrite on `tty` user start, you can do this by adding the following to the end of your `.profile` 188 | 189 | ``` 190 | export PIWRITE_HOST=pi.write # or just pi, if it's not under the access point 191 | piwrite 192 | ``` 193 | 194 | ## Reinstalling/updating the package 195 | 196 | To update, first disable access point mode (that is, remove the static IP settings on `dhcpcd.conf` and disable the `hostapd` and `dnsmasq` services), then run: 197 | 198 | ``` 199 | pipx uninstall piwrite 200 | pipx install piwrite@git+https://github.com/rberenguel/PiWrite 201 | ``` 202 | 203 | So far I haven't had luck _reinstalling_. Uninstall is fast though. If you are using your own fork, just use your own git location. 204 | 205 | --- 206 | 207 | ## Set up your Kindle 208 | 209 | It would look as if nothing is needed from the Kindle side, but actually connecting to an access point that provides _no internet_ is not something a Kindle enjoys. 210 | 211 | You have to: 212 | - Connect your Kindle via USB to a computer, 213 | - Create a file called `WIFI_NO_NET_PROBE` in the root folder of your Kindle, 214 | - Restart it. 215 | 216 | This skips connectivity check (it will also make connecting to any other WiFi way faster). 217 | 218 | ## Some oddities 219 | 220 | The Kindle browser is weird and does not support everything. No websockets, only longpolling (or so it seems). For some reason, only version 3.0 of the socketio JavaScript libraries worked correctly. I found no way to get the Kindle browser to rotate the whole page via CSS so I could have a landscape view. 221 | 222 | My first trial implementation tried using pynvim (the NeoVim API layer) as the underlying editor. That would have been awesome, real vim! But it didn't work for obscure reasons (I had to do some unholy things with asyncio that caused it to explode easily). 223 | 224 | ## Development 225 | 226 | I wrote half of this directly on the Zero from my iPad, using [Blink](https://blink.sh) to SSH into it. The second half, I wrote it on my iPad with [iVim](https://apps.apple.com/es/app/ivim/id1266544660?l=en-GB), [ish](https://ish.app) and [Inspect Browser](https://apps.pdyn.net/inspect/). The finishing touches (moving to Poetry and cleaning up), on my Mac. For local development, you can then use basically anything. Just choose a valid port for your system and make sure the host is valid. 127.0.0.1 is the default choice and the one that should work. 227 | 228 | --- 229 | 230 | 231 | 232 | --- 233 | -------------------------------------------------------------------------------- /media/pic.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/media/pic.jpeg -------------------------------------------------------------------------------- /media/vid.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/media/vid.mov -------------------------------------------------------------------------------- /piwrite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/__init__.py -------------------------------------------------------------------------------- /piwrite/__main__.py: -------------------------------------------------------------------------------- 1 | import piwrite.server as server 2 | 3 | server.start() 4 | -------------------------------------------------------------------------------- /piwrite/buffer.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from prompt_toolkit.keys import Keys 4 | 5 | from piwrite.cursor import Cursor 6 | from piwrite.line import Line 7 | 8 | 9 | class Buffer: 10 | # TODO: move insertion to Line 11 | def __init__(self, lines: Union[List[Line], None] = None): 12 | if lines: 13 | self.lines = lines 14 | else: 15 | self.lines = list() 16 | 17 | def copy(self): 18 | return Buffer([line.copy() for line in self.lines]) 19 | 20 | def __len__(self): 21 | return len(self.lines) 22 | 23 | def __getitem__(self, key: int): 24 | return self.lines[key] 25 | 26 | def __setitem__(self, key: int, value: Line): 27 | self.lines[key] = value 28 | 29 | def __repr__(self): 30 | joined = "|".join([str(l) for l in self.lines]) 31 | return f"Buffer({joined})" 32 | 33 | def counts(self): 34 | content = " ".join([str(lin) for lin in self.get()]) 35 | content = ( 36 | content.replace("*", " ") 37 | .replace("_", " ") 38 | .replace("#", " ") 39 | .replace(":", " ") 40 | ) 41 | words = len(content.split(" ")) 42 | pars = len([1 for lin in self.get() if len(str(lin).strip()) > 0]) 43 | return words, pars, content 44 | 45 | def insert(self, key, cursor: Cursor): 46 | col: int = cursor.column 47 | 48 | if key == Keys.ControlM: 49 | current_line: int = cursor.line 50 | head = self.lines[current_line][0 : cursor.column] 51 | tail = self.lines[current_line][cursor.column :] 52 | # Keep indentation 53 | prev = str(self.lines[current_line]) 54 | indent = len(prev) - len(prev.lstrip()) 55 | self.lines[current_line] = Line(head) 56 | if len(tail) == 0 and indent > 0: 57 | self.lines.insert(current_line + 1, Line(" " * indent)) 58 | cursor.to(current_line + 1, indent) 59 | else: 60 | self.lines.insert(current_line + 1, Line(tail)) 61 | cursor.to(current_line + 1, 0) 62 | return 63 | 64 | if cursor.line + 1 > len(self.lines): 65 | self.lines.append(Line()) 66 | self.lines[cursor.line].insert(col, key) 67 | cursor += 1 68 | 69 | def clip(self, cursor: Cursor): 70 | """Clip the cursor to the current line and buffer""" 71 | if cursor.line >= len(self): 72 | cursor.line = len(self) - 1 73 | if len(self) == 0: 74 | le = 0 75 | else: 76 | le = len(self[cursor.line]) 77 | if cursor.column < 0: 78 | cursor.column = 0 79 | if cursor.line < 0: 80 | cursor.line = 0 81 | if le < cursor.column: 82 | cursor.column = le 83 | 84 | def delete(self, cursor: Cursor): 85 | col = cursor.column 86 | row = cursor.line 87 | if col == 0: 88 | if row == 0: 89 | return 90 | new_column = len(self.lines[row - 1]) 91 | self.lines[row - 1] += self.lines[row] 92 | self.lines = self.lines[0:row] + self.lines[row + 1 :] 93 | cursor.line = row - 1 94 | cursor.column = new_column 95 | self.clip(cursor) 96 | return 97 | lin = self.lines[cursor.line] 98 | self.lines[cursor.line].delete(col) 99 | cursor -= 1 100 | 101 | def get(self): 102 | return self.lines 103 | -------------------------------------------------------------------------------- /piwrite/cmaps_helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def has_arrow(line): 4 | return "->" in line 5 | 6 | def has_url(line): 7 | return "URL=" in line 8 | 9 | # TODO: accept also url, or just having http or https 10 | 11 | subgraph_cluster = re.compile(r"^\s*subgraph cluster_(\w+).*{.*$") 12 | replacement = re.compile(r"^\s*(\$\S+)\s*=\s*(\S+)\s*$") 13 | comment = re.compile(r"\s*\/\/.*") 14 | open_brace = re.compile(r"\s*{\s*$") 15 | close_brace = re.compile(r"\s*}\s*$") 16 | attr = re.compile(r"^\s*\w+=.*\s*$") 17 | attrs_of_arrow = re.compile(r"^\s*\S+\s*->\s*\S+\s+(.*)$") 18 | attrs_of_node = re.compile(r"^\s*\S+\s+(.*)$") 19 | lone_cluster = re.compile(r"^\s*cluster\s+(\S+).*{.*$") 20 | rgba_hex = re.compile(r"^#[0-9a-f]{8}$/.") 21 | 22 | def has_subgraph(line): 23 | print(subgraph_cluster.match(line)) 24 | return subgraph_cluster.match(line) is not None 25 | 26 | def has_cluster(line): 27 | return lone_cluster.match(line) is not None 28 | 29 | def has_replacement(line): 30 | return replacement.match(line) is not None 31 | 32 | def get_replacement(line): 33 | matches = replacement.match(line) 34 | name = matches.group(1) 35 | value = matches.group(2) 36 | return name, value 37 | 38 | def is_comment(line): 39 | return comment.match(line) is not None 40 | 41 | def is_only_brace(line): 42 | return (open_brace.match(line) is not None) or (close_brace.match(line) is not None) 43 | 44 | def is_attr(line): 45 | return attr.match(line) is not None 46 | 47 | def is_rgba_hex(line): 48 | return rgba_hex.match(line) is not None 49 | 50 | def get_attrs_of_arrow(line): 51 | matches = attrs_of_arrow.match(line) 52 | attrs = matches.group(1) 53 | return attrs 54 | 55 | def get_attrs_of_node(line): 56 | matches = attrs_of_node.match(line) 57 | attrs = matches.group(1) 58 | return attrs 59 | 60 | def get_subgraph_cluster_name(line): 61 | matches = subgraph_cluster.match(line) 62 | name = matches.group(1) 63 | return name 64 | 65 | def get_cluster_name(line): 66 | matches = lone_cluster.match(line) 67 | name = matches.group(1) 68 | return name 69 | 70 | def label_breaker(label): 71 | left_align = "\\l" 72 | if len(label) > 30 and not ("\\n" in label): 73 | words = label.split(" ") 74 | lines = [] 75 | curr_line = [] 76 | for word in words: 77 | curr_line.append(word) 78 | joined = " ".join(curr_line) 79 | if len(joined) > 30: 80 | lines.append(joined) 81 | curr_line = [] 82 | lines.append(" ".join(curr_line)) 83 | return left_align.join(lines) 84 | else: 85 | return label 86 | 87 | def converter(lines): 88 | result = [] 89 | replacements = {} 90 | tab = " " 91 | ttab = tab + tab 92 | clusters = [] 93 | result.append(tab + f"""label="\\n{lines[0].replace("# ", "")}"\\n\\n""") 94 | for line of lines[1:]: 95 | if is_only_brace(line) or is_attr(line) or is_comment(line): 96 | result.append(tab + line) 97 | continue 98 | if has_replacement(line): 99 | key, value = get_replacement(line) 100 | replacements[key] = value 101 | continue 102 | for key, value in replacements.items(): 103 | line = line.replace(key, value) 104 | 105 | if has_subgraph(line) or has_cluster(line): 106 | name = None 107 | if has_subgraph(line): 108 | name = get_subgraph_cluster_name(line) 109 | else: 110 | name = get_cluster_name(line) 111 | clusters.append(name) 112 | fill = None 113 | for word in line.split(" "): 114 | if is_rgba_hex(word): 115 | fill = f"""{ttab}fillcolor="{word.strip()}" """ 116 | line = line.replace(word.trim(), "") 117 | result.append(f"""{tab}subgraph cluster_{name} {""") 118 | result.append(f"""{ttab}style="filled, rounded, dotted" """) 119 | if fill is not None: 120 | result.append(fill) 121 | # Append the invisible node for linking 122 | result.append(f"""{ttab} label="{name}" """) 123 | result.append(f"""{ttab} {name} [style=invis,width=0,label="",fixedsize=true]""") 124 | continue 125 | 126 | # let attrs, src, dst 127 | # if(hasArrow(line)){ 128 | # attrs = getAttrsArrow(line); 129 | # const match = /^\s*(\w+)\s*->\s*(\w+).*$/.exec(line) 130 | # src = match[1] 131 | # dst = match[2] 132 | # src = src.trim() 133 | # dst = dst.trim() 134 | # } else { 135 | # attrs = getAttrsNode(line) 136 | # } 137 | # let addendum = "" 138 | # if(src && clusters.includes(src)){ 139 | # addendum = ` ltail="cluster_${src}"` 140 | # } 141 | # if(dst && clusters.includes(dst)){ 142 | # addendum = ` lhead="cluster_${dst}"` 143 | # } 144 | # if(!attrs || attrs.length == 1){ 145 | # const compoundEdge = addendum != "" ? ` [${addendum}]` : "" 146 | # result.push(tab + line + compoundEdge) 147 | # continue 148 | # } 149 | # const linkUTF = hasURL(attrs[1]) ? " 🔗" : "" 150 | # let [label, props] = attrs[1].split(";") 151 | # if(hasArrow(line) && label.trim() == "!"){ 152 | # label = "" 153 | # props = props ? props : "" + "style=invis" 154 | # } 155 | # const labelPropper = (label, props) => `[label="${labelBreaker(label)}${linkUTF}"${props ? " " + props : ""}${addendum}]` 156 | # const operation = line.replace(" " + attrs[1], " ") 157 | # let converted = `${operation} ${labelPropper(label, props)}` 158 | # if(!hasArrow(line) && label.trim() == "="){ 159 | # converted = `${operation} ${labelPropper(operation.trim(), props)}` 160 | # } 161 | # result.push(tab + converted) 162 | # } 163 | # let joined = result.join("") 164 | # return joined 165 | # } 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /piwrite/cursor.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Cursor: 6 | line: int = 0 7 | column: int = 0 8 | 9 | def to(self, line=0, column=0): 10 | self.line = line 11 | self.column = column 12 | # The hidden column is used for clipping: when moving up 13 | # shorter/longer lines we should try to keep the longest 14 | self._column = column 15 | 16 | def __ixor__(self, inc: int): 17 | """Change column, because ^ looks like moving up/down""" 18 | self.line += inc 19 | return self 20 | 21 | def __iadd__(self, inc: int): 22 | """Change position, because += seems natural for columns""" 23 | self.column += inc 24 | return self 25 | 26 | def __isub__(self, inc: int): 27 | self.column -= inc 28 | return self 29 | -------------------------------------------------------------------------------- /piwrite/dispatcher.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import time 6 | from dataclasses import dataclass 7 | from pathlib import Path 8 | 9 | from prompt_toolkit.keys import Keys 10 | from proselint import config as proselint_config 11 | from proselint import tools as proselint_tools 12 | from readability import Readability 13 | 14 | from piwrite.buffer import Buffer 15 | from piwrite.cursor import Cursor 16 | from piwrite.line import Line 17 | from piwrite.markdownify import markdownify 18 | from piwrite.mode import Mode 19 | 20 | logger = logging.getLogger("piwrite") 21 | 22 | 23 | class Dispatcher: 24 | def __init__(self, editor=None): 25 | self.editor = editor 26 | self.editor._history = [Buffer()] 27 | self.editor.buffer = Buffer(lines=None) 28 | self.editor.cursor = Cursor(0, 0) 29 | 30 | def dispatch_command(self, command): 31 | # TODO: A Trie for this would work sooo much better than doing it by hand 32 | logger.info("Dispatching %s", str(command)) 33 | if command == ["i"]: 34 | self.editor.clear_command() 35 | self.editor._mode = Mode.INSERT 36 | self.editor.updating_fields["mode"] = True 37 | self.editor.cursor -= 1 # Seems to be giving problems!? 38 | self.editor.buffer.clip(self.editor.cursor) 39 | return 40 | if command[-1] == Keys.Escape: 41 | # TODO: this has no test 42 | self.editor.clear_command() 43 | return 44 | if command == ["v"]: 45 | lines = [str(lin) for lin in self.editor.buffer.get()] 46 | self.editor.visual = markdownify(lines, visible=False) 47 | self.editor.updating_fields["visual"] = True 48 | return 49 | if command[-1] == Keys.ControlH: 50 | self.editor._command = self.editor._command[0:-2] 51 | self.editor.completions = None 52 | self.editor.completions_markdownified = ( 53 | None # TODO: wrap these two in a function 54 | ) 55 | self.editor.updating_fields["command"] = True 56 | self.editor.updating_fields["completions"] = True 57 | return 58 | if command[-1] in self.editor.GENERIC_MOVEMENT: 59 | self.editor.status = ( 60 | "I didn't bother implementing arrows or C-a/C-e here, sorry" 61 | ) 62 | self.editor.updating_fields["status"] = True 63 | self.editor._command.pop() 64 | self.editor.updating_fields["command"] = True 65 | return 66 | if command == ["d"] or command == ["d", "a"] or command == ["d", "i"]: 67 | return 68 | if command == ["c"] or command == ["c", "a"] or command == ["c", "i"]: 69 | return 70 | # TODO: the commands below need tests 71 | if command == ["a"]: 72 | self.editor.clear_command() 73 | self.editor._mode = Mode.INSERT 74 | self.editor.updating_fields["mode"] = True 75 | return 76 | if command == ["I"]: 77 | self.editor.clear_command() 78 | self.editor._mode = Mode.INSERT 79 | self.editor.updating_fields["mode"] = True 80 | self.editor.cursor.to(column=0, line=self.editor.cursor.line) 81 | return 82 | if command == ["A"]: 83 | self.editor.clear_command() 84 | self.editor._mode = Mode.INSERT 85 | self.editor.updating_fields["mode"] = True 86 | lin = self.editor.cursor.line 87 | end = len(self.editor.buffer[lin]) 88 | self.editor.cursor.to(column=end, line=lin) 89 | return 90 | 91 | if command == ["o"]: 92 | self.editor.clear_command() 93 | self.editor._mode = Mode.INSERT 94 | self.editor.updating_fields["mode"] = True 95 | lin = self.editor.cursor.line 96 | self.editor.buffer.lines.insert(lin + 1, Line()) 97 | self.editor.cursor.to(column=0, line=lin + 1) 98 | return 99 | 100 | if command == ["u"]: 101 | # Undo-ish 102 | self.editor.clear_command() 103 | self.editor._history_pointer -= 1 104 | if self.editor._history_pointer < 0: 105 | logger.info("No further undo") 106 | self.editor.status = "No further undo information" 107 | self.editor.updating_fields["status"] = True 108 | self.editor._history_pointer = 0 109 | logger.debug("Undoing at %s", self.editor._history_pointer) 110 | self.editor.buffer = self.editor._history[ 111 | self.editor._history_pointer 112 | ].copy() 113 | logger.debug("Buffer now: %s", self.editor.buffer) 114 | self.editor.buffer.clip(self.editor.cursor) 115 | return 116 | if command == [Keys.ControlS]: 117 | self.editor.clear_command() 118 | words, pars, _ = self.editor.buffer.counts() 119 | self.editor.status = f"{words} words, {pars} paragraphs" 120 | self.editor.updating_fields["status"] = True 121 | return 122 | 123 | if command == [Keys.ControlR]: 124 | # Redo-ish 125 | self.editor.clear_command() 126 | self.editor._history_pointer += ( 127 | 1 # We need to jump "pressed escape" and "current" 128 | ) 129 | if self.editor._history_pointer >= len(self.editor._history): 130 | self.editor.status = "No further redo information" 131 | self.editor._history_pointer = len(self.editor._history) - 1 132 | self.editor.buffer = self.editor._history[ 133 | self.editor._history_pointer 134 | ].copy() 135 | self.editor.buffer.clip(self.editor.cursor) 136 | return 137 | 138 | if command == ["g"]: 139 | return 140 | 141 | if command == ["g", "g"]: 142 | self.editor.clear_command() 143 | self.editor.cursor.to(0, 0) 144 | return 145 | 146 | if command == ["G"]: 147 | self.editor.clear_command() 148 | self.editor.cursor.to(len(self.editor.buffer.lines) - 1, 0) 149 | return 150 | 151 | if command == ["p"]: 152 | self.editor.clear_command() 153 | paste = ["a"] + self.editor.yank + [Keys.Escape] 154 | self.editor.send(paste) 155 | return 156 | 157 | if command == ["P"]: 158 | self.editor.clear_command() 159 | paste = ["i"] + self.editor.yank + [Keys.Escape] 160 | self.editor.send(paste) 161 | return 162 | 163 | if command == ["c", "a", "w"]: 164 | self.editor.clear_command() 165 | self.editor.send(["dawa"]) 166 | if command == ["c", "i", "w"]: 167 | self.editor.clear_command() 168 | self.editor.send(["diwa"]) 169 | if command == ["d", "d"]: 170 | self.editor.clear_command() 171 | lin = self.editor.cursor.line 172 | self.editor.yank = [self.editor.buffer.lines[lin], Keys.ControlM] 173 | del self.editor.buffer.lines[lin] 174 | if len(self.editor.buffer) == 0: 175 | self.editor.buffer = Buffer() 176 | self.editor.buffer.clip(self.editor.cursor) 177 | return 178 | # TODO: clear repetition between daw and diw 179 | if command == ["d", "a", "w"]: 180 | self.editor.clear_command() 181 | row = self.editor.cursor.line 182 | line = self.editor.buffer[row] 183 | line.cursor = self.editor.cursor # This is somewhat ugly 184 | new_line, word, col = line._aw() 185 | self.editor.yank = [word] 186 | self.editor.buffer[self.editor.cursor.line] = new_line 187 | self.editor.cursor.column = col 188 | self.editor.buffer.clip(self.editor.cursor) 189 | return 190 | 191 | if command == ["d", "i", "w"]: 192 | self.editor.clear_command() 193 | row = self.editor.cursor.line 194 | line = self.editor.buffer[row] 195 | line.cursor = self.editor.cursor # This is somewhat ugly 196 | new_line, word, col = line._iw() 197 | self.editor.yank = [word] 198 | self.editor.buffer[self.editor.cursor.line] = new_line 199 | self.editor.cursor.column = col 200 | self.editor.buffer.clip(self.editor.cursor) 201 | return 202 | if command == ["d", "$"]: 203 | self.editor.clear_command() 204 | row = self.editor.cursor.line 205 | col = self.editor.cursor.column 206 | line = self.editor.buffer[row] 207 | line.cursor = self.editor.cursor 208 | cut = line[col:] 209 | self.editor.buffer[row] = Line(line[:col]) 210 | self.editor.yank = [cut] 211 | self.editor.buffer.clip(self.editor.cursor) 212 | return 213 | if command == ["c", "$"]: 214 | self.editor.clear_command() 215 | self.editor.send("d$a") 216 | return 217 | if command[0] == "q" and self.editor.previous_file is not None: 218 | cmd = [":E ", self.editor.previous_file[0], Keys.ControlM] 219 | self.editor.clear_command() 220 | self.editor.send(cmd) 221 | self.editor.dot = "nope" 222 | self.editor.updating_fields["dot"] = True 223 | self.editor.filename = self.editor.previous_file[1] 224 | self.editor.updating_fields["filename"] = True 225 | Path(self.editor.previous_file[0]).unlink() 226 | self.editor.previous_file = None 227 | 228 | if command[0] == ":" and ( 229 | command[-1] != Keys.ControlM and command[-1] != Keys.ControlI 230 | ): 231 | logger.debug("Ignoring because no tab, no return") 232 | self.editor.completions = None 233 | self.editor.completions_markdownified = ( 234 | None # TODO: wrap these two in a function 235 | ) 236 | return 237 | if command == [":", "k", "e", "y", "s", Keys.ControlM]: 238 | self.editor.clear_command() 239 | self.editor.log_keys = True 240 | self.editor.status = "Logging keys to buffer" 241 | self.editor.updating_fields["status"] = True 242 | return 243 | if command == [":", "s", "t", "a", "t", "s", Keys.ControlM]: 244 | self.editor.clear_command() 245 | words, pars, content = self.editor.buffer.counts() 246 | r = Readability(content) 247 | fc_line = "" 248 | f_line = "" 249 | w_line = f"Stats and readability word count: {words} paragraphs: {pars}" 250 | try: 251 | fc = r.flesch_kincaid() 252 | f = r.flesch() 253 | fc_line = f"Flesch-Kincaid score: {fc.score:.2f} grade: {fc.grade_level} (1-18)" 254 | f_line = f"Flesch ease ease: {f.ease} ({f.score:.2f})" 255 | except Exception as e: 256 | f_line = f"Readability failure: {e}" 257 | finally: 258 | modal = "".join([w_line, fc_line, f_line]) 259 | self.editor.modal = modal 260 | self.editor.updating_fields["modal"] = True 261 | return 262 | 263 | if command == [":", "l", "i", "n", "t", Keys.ControlM]: 264 | self.editor.clear_command() 265 | _, tmpname = tempfile.mkstemp() 266 | resolved = Path(tmpname).resolve() 267 | previous_file = self.editor.filename 268 | previous_save_status = self.editor.saved 269 | cmd = [":W ", str(resolved), Keys.ControlM] 270 | self.editor.send(cmd) 271 | text = resolved.read_text() 272 | text = ( 273 | text.replace("*", " ") 274 | .replace("_", " ") 275 | .replace("#", " ") 276 | .replace(":", " ") 277 | ) 278 | p_suggestions = proselint_tools.lint(text, config=proselint_config.default) 279 | suggestions = [f"At {sug[2]}:{sug[3]}: {sug[1]}" for sug in p_suggestions] 280 | if len(suggestions) == 0: 281 | self.editor.modal = "No suggestions: As good as The Great Gatsby" 282 | else: 283 | self.editor.modal = "".join(suggestions) 284 | self.editor.filename = previous_file 285 | self.editor.saved = previous_save_status 286 | self.editor.updating_fields["modal"] = True 287 | self.editor.updating_fields["filename"] = True 288 | self.editor.updating_fields["saved"] = True 289 | return 290 | 291 | if command == [":", "q", Keys.ControlM]: 292 | self.editor.clear_command() 293 | if self.editor.saved: 294 | subprocess.call( 295 | [ 296 | self.editor._display, 297 | "/home/ruben/display.py", 298 | "-f", 299 | self.editor.font, 300 | "-s", 301 | "off", 302 | ] 303 | ) 304 | subprocess.call(["shutdown", "-h", "now"]) 305 | else: 306 | self.editor.status = "You have unsaved changes" 307 | if command == [":", "q", "!", Keys.ControlM]: 308 | self.editor.clear_command() 309 | self.editor.saved = True 310 | self.editor.send([":q", Keys.ControlM]) 311 | return 312 | if command == [":", "h", Keys.ControlM]: 313 | self.editor.clear_command() 314 | _, tmpname = tempfile.mkstemp() 315 | resolved = str(Path(tmpname).resolve()) 316 | self.editor.previous_file = ( 317 | resolved, 318 | self.editor.filename, 319 | ) # Keep track of the previous "real" file (if any) 320 | cmd = [":W ", resolved, Keys.ControlM] 321 | self.editor.send(cmd) 322 | this_path = Path(__file__).resolve() 323 | root_dir = this_path.parent 324 | help = root_dir / "help" 325 | cmd = [":E ", str(help), Keys.ControlM] 326 | self.editor.send(cmd) 327 | return 328 | 329 | if command == [":", "d", "o", "t", Keys.ControlM]: 330 | # Render graphviz 331 | self.editor.clear_command() 332 | _, tmpname = tempfile.mkstemp() 333 | resolved = Path(tmpname).resolve() 334 | self.editor.previous_file = ( 335 | str(resolved), 336 | self.editor.filename, 337 | ) # Keep track of the previous "real" file (if any) 338 | cmd = [":W ", str(resolved), Keys.ControlM] 339 | self.editor.send(cmd) 340 | img_resolved = ( 341 | str((self.editor.docs / Path("imgs") / Path("graph")).resolve()) 342 | + ".png" 343 | ) 344 | template = (self.editor.docs / Path("dot_template.dot")).read_text() 345 | content = resolved.read_text() 346 | adapted = resolved.with_suffix(".dot") 347 | with adapted.open("a") as f: 348 | f.write(template) 349 | f.write(content) 350 | f.write("}") 351 | subprocess.call(["dot", "-Tpng", str(adapted), "-o", img_resolved]) 352 | self.editor.status = img_resolved 353 | self.editor.updating_fields["status"] = True 354 | self.editor.dot = "/docs/imgs/graph.png" 355 | self.editor.updating_fields["dot"] = True 356 | return 357 | 358 | if command[0:2] == [":", "w"] and command[-1] == Keys.ControlM: 359 | # Write file 360 | filename = "".join(command[3:-1]) 361 | self.editor.clear_command() 362 | if filename.strip() == "": 363 | filename = self.editor.filename 364 | self.editor.updating_fields["filename"] = True 365 | try: 366 | (self.editor.docs / filename).write_text( 367 | "\n".join([str(lin) for lin in self.editor.buffer.get()]) 368 | ) 369 | self.editor.filename = filename 370 | self.editor.updating_fields["filename"] = True 371 | self.editor.saved = True 372 | self.editor.updating_fields["saved"] = True 373 | self.editor.status = f"Saved as {filename}" 374 | self.editor.updating_fields["status"] = True 375 | except Exception as e: 376 | self.editor.err = str(e) 377 | self.editor.updating_fields["err"] = True 378 | return 379 | if command[0:2] == [":", "W"] and command[-1] == Keys.ControlM: 380 | # This should be protected like E 381 | filename = "".join(command[3:-1]) 382 | self.editor.clear_command() 383 | try: 384 | Path(filename).write_text( 385 | "\n".join([str(lin) for lin in self.editor.buffer.get()]) 386 | ) 387 | self.editor.filename = filename 388 | self.editor.saved = True 389 | self.editor.status = f"Special saved as {filename}" 390 | self.editor.updating_fields["filename"] = True 391 | self.editor.updating_fields["saved"] = True 392 | self.editor.updating_fields["status"] = True 393 | except Exception as e: 394 | self.editor.err = str(e) 395 | self.editor.updating_fields["err"] = True 396 | return 397 | if "".join(command[0:4]) == ":rot" and command[-1] == Keys.ControlM: 398 | self.editor.clear_command() 399 | if self.editor.rot == "0": 400 | self.editor.rot = "90" 401 | else: 402 | self.editor.rot = "0" 403 | self.editor.updating_fields["rot"] = True 404 | return 405 | if "".join(command[0:5]) == ":mono" and command[-1] == Keys.ControlM: 406 | self.editor.clear_command() 407 | self.editor.font = "mono" 408 | self.editor.updating_fields["font"] = True 409 | return 410 | if "".join(command[0:5]) == ":gyre" and command[-1] == Keys.ControlM: 411 | self.editor.clear_command() 412 | self.editor.font = "gyre" 413 | self.editor.updating_fields["font"] = True 414 | return 415 | if "".join(command[0:5]) == ":sans" and command[-1] == Keys.ControlM: 416 | self.editor.clear_command() 417 | self.editor.font = "sans" 418 | self.editor.updating_fields["font"] = True 419 | return 420 | if "".join(command[0:6]) == ":serif" and command[-1] == Keys.ControlM: 421 | self.editor.clear_command() 422 | self.editor.font = "serif" 423 | self.editor.updating_fields["font"] = True 424 | return 425 | if "".join(command[0:6]) == ":latex" and command[-1] == Keys.ControlM: 426 | self.editor.clear_command() 427 | self.editor.font = "latex" 428 | self.editor.updating_fields["font"] = True 429 | return 430 | if "".join(command[0:9]) == ":fontsize" and command[-1] == Keys.ControlM: 431 | fs = command[10:-1] 432 | self.editor.clear_command() 433 | try: 434 | self.editor.fontsize = int("".join(fs).strip()) 435 | self.editor.updating_fields["fontsize"] = True 436 | self.editor.status = f"Set font size to {self.editor.fontsize}" 437 | self.editor.updating_fields["status"] = True 438 | except Exception as e: 439 | self.editor.err = str(e) 440 | self.editor.updating_fields["err"] = True 441 | return 442 | if "".join(command[0:3]) == ":fs" and command[-1] == Keys.ControlM: 443 | fs = command[4:-1] 444 | self.editor.clear_command() 445 | try: 446 | self.editor.fontsize = int("".join(fs).strip()) 447 | self.editor.updating_fields["fontsize"] = True 448 | self.editor.status = f"Set font size to {self.editor.fontsize}" 449 | self.editor.updating_fields["status"] = True 450 | except Exception as e: 451 | self.editor.err = str(e) 452 | self.editor.updating_fields["err"] = True 453 | return 454 | if (command[0:2] == [":", "e"] or command[0:3] == [":", "e", "!"]) and command[ 455 | -1 456 | ] == Keys.ControlI: 457 | command.pop() # Drop the tab 458 | if self.editor.completions is None: 459 | filename = "".join(command).replace(":e!", "").replace(":e", "").strip() 460 | logger.debug("Globbing on %s", filename) 461 | files = [str(f.name) for f in self.editor.docs.glob(filename + "*")] 462 | if len(files) == 0: 463 | self.editor.completions = None 464 | else: 465 | self.editor.completions = {"files": files, "idx": -1} 466 | self.editor.updating_fields["completions"] = True 467 | else: 468 | self.editor.completions["idx"] = ( 469 | self.editor.completions["idx"] + 1 470 | ) % len(self.editor.completions["files"]) 471 | # TODO: In addition to this, deleting or writing will need to clear completions, reset index… 472 | md = [] 473 | for i, completion in enumerate(self.editor.completions["files"]): 474 | if i == self.editor.completions["idx"]: 475 | md.append(f"::{completion}::") 476 | else: 477 | md.append(completion) 478 | self.editor.completions_markdownified = markdownify( 479 | [" ".join(md)], visible=False 480 | ) 481 | self.editor.updating_fields["completions"] = True 482 | return 483 | if command[0:4] == [":", "v", "i", "z"] and command[-1] == Keys.ControlM: 484 | try: 485 | viz_val = "".join(command[4:-1]).strip() 486 | self.editor.clear_command() 487 | if len(viz_val) == 0: 488 | self.editor.status = "Clearing custom viz" 489 | self.editor.viz = None 490 | else: 491 | viz, shift = viz_val.split(":") 492 | viz = int(viz) 493 | shift = int(shift) 494 | self.editor.viz = (viz, shift) 495 | except Exception as e: 496 | self.editor.clear_command() 497 | self.editor.status = f"viz has to be of the form int:int or empty ({e})" 498 | self.editor.updating_fields["status"] = True 499 | self.editor.status = f"Setting shift to {self.editor.viz}" 500 | self.editor.updating_fields["status"] = True 501 | return 502 | if command[0:3] == [":", "e", "!"] and command[-1] == Keys.ControlM: 503 | if self.editor.completions is None: 504 | filename = "".join(command[4:-1]) 505 | else: 506 | filename = self.editor.completions["files"][ 507 | self.editor.completions["idx"] 508 | ] 509 | self.editor.clear_command() 510 | self.editor.status = "" 511 | self.editor.saved = True 512 | self.editor.updating_fields["saved"] = True 513 | self.editor.updating_fields["status"] = True 514 | self.editor.send([":e ", filename, Keys.ControlM]) 515 | return 516 | if command[0:2] == [":", "e"] and command[-1] == Keys.ControlM: 517 | if not self.editor.saved: 518 | self.editor.status = "You have unsaved changes" 519 | self.editor.updating_fields["saved"] = True 520 | return 521 | if self.editor.completions is None: 522 | filename = "".join(command[3:-1]) 523 | else: 524 | filename = self.editor.completions["files"][ 525 | self.editor.completions["idx"] 526 | ] 527 | self.editor.clear_command() 528 | try: 529 | text = (self.editor.docs / filename).read_text() 530 | lines = text.split("\n") 531 | new_buffer = Buffer(lines=[Line(line) for line in lines]) 532 | self.editor.buffer = new_buffer 533 | self.editor.filename = filename 534 | self.editor.saved = True 535 | self.editor.status = f"Loaded {self.editor.filename}" 536 | self.editor.updating_fields["status"] = True 537 | self.editor.updating_fields["saved"] = True 538 | self.editor.updating_fields["filename"] = True 539 | except Exception as e: 540 | self.editor.err = str(e) 541 | self.editor.updating_fields["err"] = True 542 | self.editor.cursor.to(0, 0) 543 | if self.editor.filename.endswith(".dot"): 544 | self.editor.send([":mono", Keys.ControlM]) 545 | return 546 | if command[0:2] == [":", "E"] and command[-1] == Keys.ControlM: 547 | if self.editor.completions is None: 548 | filename = "".join(command[3:-1]) 549 | else: 550 | filename = self.editor.completions["files"][ 551 | self.editor.completions["idx"] 552 | ] 553 | self.editor.clear_command() 554 | try: 555 | logger.debug("Opening %s", filename) 556 | text = Path(filename).read_text() 557 | lines = text.split("\n") 558 | self.editor.buffer = Buffer([Line(line) for line in lines]) 559 | self.editor.filename = Path(filename).name 560 | self.editor.saved = True 561 | self.editor.status = f"Loaded {self.editor.filename}" 562 | self.editor.updating_fields["status"] = True 563 | self.editor.updating_fields["saved"] = True 564 | self.editor.updating_fields["filename"] = True 565 | except Exception as e: 566 | self.editor.err = str(e) 567 | self.editor.updating_fields["err"] = True 568 | self.editor.cursor.to(0, 0) 569 | return 570 | self.editor.clear_command() 571 | -------------------------------------------------------------------------------- /piwrite/display.py: -------------------------------------------------------------------------------- 1 | #!/home/ruben/piwrite/bin/python3.11 2 | 3 | # For _reasons_ I need to use a separate virtual environment for this 4 | 5 | import argparse 6 | 7 | import inky 8 | from inky import InkyPHAT 9 | from PIL import Image, ImageDraw, ImageFont 10 | 11 | from pathlib import Path 12 | 13 | static = Path(__file__).parent / "static" 14 | 15 | inky_display = InkyPHAT("red") 16 | 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument( 19 | "--font", 20 | "-f", 21 | choices=["mono", "latex", "gyre", "serif", "sans"], 22 | required=True, 23 | help="Font", 24 | ) 25 | parser.add_argument( 26 | "--state", "-s", choices=["on", "off"], required=True, help="State (on, off)" 27 | ) 28 | args, _ = parser.parse_known_args() 29 | 30 | padding = 1 31 | 32 | inky_display.lut = "red_ht" 33 | 34 | img = Image.new("P", inky_display.resolution) 35 | draw = ImageDraw.Draw(img) 36 | 37 | # Load the fonts 38 | 39 | monoid = ImageFont.truetype(str(static / "monoid-bold.ttf"), 40) 40 | monoid_small = ImageFont.truetype(str(static / "monoid-bold.ttf"), 16) 41 | monoid_med = ImageFont.truetype(str(static / "monoid-bold.ttf"), 24) 42 | piwrite = "PiWrite" 43 | 44 | font = monoid 45 | if args.font == "gyre": 46 | font = ImageFont.truetype(str(static / "texgyreheros-bold.otf"), 50) 47 | if args.font == "latex": 48 | font = ImageFont.truetype(str(static / "cmunbx.ttf"), 50) 49 | if args.font == "serif": 50 | font = ImageFont.truetype(str(static / "ImFell.ttf"), 54) 51 | 52 | power_color = inky_display.BLACK if args.state == "off" else inky_display.RED 53 | 54 | # Draw border 55 | for x in range(0, img.width): 56 | for y in range(0, img.height): 57 | if x < 4 or y < 3 or x > img.width - 5 or y > img.height - 5: 58 | img.putpixel((x, y), power_color) 59 | 60 | pw_w, pw_h = font.getsize(piwrite) 61 | pw_x = int((inky_display.width - pw_w) / 2) 62 | pw_y = int((inky_display.height - pw_h) / 2) + padding 63 | draw.text((pw_x, pw_y), piwrite, inky_display.BLACK, font=font) 64 | 65 | 66 | state = args.state.upper() 67 | s_w, s_h = monoid_small.getsize(state) 68 | s_x = pw_x + 20 69 | s_y = 3 70 | draw.text((s_x, s_y), state, inky_display.BLACK, font=monoid_small) 71 | 72 | draw.text((pw_x, 0), "•", power_color, font=monoid_med) 73 | 74 | l_height = 4 75 | 76 | lower = pw_y + pw_h + 6 77 | 78 | for y in range(lower, lower + l_height): 79 | for x in range(pw_x, inky_display.width - pw_x): 80 | img.putpixel((x, y), power_color) 81 | 82 | upper = pw_y - 1 83 | 84 | if args.font == "gyre": 85 | upper += 13 86 | 87 | for y in range(upper - l_height, upper): 88 | for x in range(pw_x, inky_display.width - pw_x): 89 | img.putpixel((x, y), power_color) 90 | 91 | 92 | # I wasn't sure if it was working properly, so did this manually 93 | 94 | colors = [inky.WHITE, inky.BLACK, inky.RED] 95 | for x in range(img.width): 96 | for y in range(img.height): 97 | inky_display.set_pixel(x, y, colors[img.getpixel((x, y))]) 98 | 99 | inky_display.show() 100 | -------------------------------------------------------------------------------- /piwrite/editor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import time 6 | from collections import OrderedDict 7 | from dataclasses import dataclass 8 | from enum import Enum 9 | from pathlib import Path 10 | 11 | from prompt_toolkit.key_binding.key_processor import KeyPress 12 | from prompt_toolkit.keys import Keys 13 | from proselint import config as proselint_config 14 | from proselint import tools as proselint_tools 15 | 16 | from piwrite.dispatcher import Dispatcher 17 | from piwrite.line import Line 18 | from piwrite.markdownify import markdownify 19 | from piwrite.mode import Mode 20 | 21 | logger = logging.getLogger("piwrite") 22 | 23 | 24 | class Editor: 25 | UNDO_DEPTH = 10 26 | 27 | def __init__(self, skip_config=True): 28 | # TODO: Some could be properties 29 | # TODO: Some should be saved and restored on quit 30 | self.refresh = False 31 | self._history_pointer = 0 32 | self._mode = Mode.NORMAL 33 | self._command = [] 34 | self.yank = [""] 35 | self.updating_fields = OrderedDict() 36 | self.viz = None 37 | self._break_counter = 0 38 | self.setup_movement() 39 | self.rot = "0" 40 | self.dot = "nope" 41 | self.log_keys = False 42 | self.filename = "unnamed" 43 | self.previous_file = None 44 | self.saved = True # Should start in saved state 45 | self.modal = "" 46 | self.visual = "" 47 | self.status = None 48 | self.completions = None 49 | self.completions_markdownified = None 50 | self.font = "serif" 51 | self.fontsize = 12 52 | self.err = None 53 | home = Path.home() 54 | self.docs = home / "piwrite-docs/" 55 | self.docs.mkdir(exist_ok=True) 56 | (self.docs / Path("imgs")).mkdir(exist_ok=True) 57 | self.dispatcher = Dispatcher(editor=self) 58 | config = self.docs / "config" 59 | if config.exists() and not skip_config: 60 | lines = config.read_text() 61 | for line in lines.split("\n"): 62 | logger.debug("Sending config line %s", line) 63 | self.send([line, Keys.ControlM]) 64 | self.status = "Config loaded" 65 | self.updating_fields["status"] = True 66 | try: 67 | self._display = Path(__file__).parent / "display.py" 68 | subprocess.call([self._display, "-f", self.font, "-s", "on"]) 69 | except: 70 | pass 71 | 72 | def send(self, arr): 73 | """Send an array containing strings and keys to be parsed""" 74 | logger.info(f"Command being sent: {arr}") 75 | for thing in arr: 76 | if isinstance(thing, Enum): 77 | self.dispatch(KeyPress(thing)) 78 | else: 79 | for let in thing: 80 | self.dispatch(KeyPress(let)) 81 | 82 | def mode(self): 83 | return str(self._mode) 84 | 85 | def get(self): 86 | # TODO: needs a test 87 | lines = [str(lin) for lin in self.buffer.get()] # "cheap" copy 88 | if self.cursor.line + 1 > len(lines): 89 | lin = "" 90 | lines.append(lin) 91 | lin = lines[self.cursor.line] 92 | col = self.cursor.column 93 | # if col == 0: 94 | # col = 1 95 | if len(lin) > col: 96 | end = lin[col:] 97 | else: 98 | end = "" 99 | if col <= 0: 100 | letter = " " 101 | else: 102 | letter = lin[col - 1] 103 | if self._mode == Mode.INSERT: 104 | if False: # col == 1: 105 | lines[self.cursor.line] = ( 106 | """""" + letter + """""" + end 107 | ) 108 | else: 109 | if col - 1 < 0: 110 | start = "" 111 | else: 112 | start = lin[0 : col - 1] 113 | lines[self.cursor.line] = ( 114 | start 115 | + """""" 116 | + letter 117 | + """""" 118 | + end 119 | ) 120 | if self._mode == Mode.NORMAL: 121 | if col - 1 < 0: 122 | start = "" 123 | else: 124 | start = lin[0 : col - 1] 125 | lines[self.cursor.line] = ( 126 | start 127 | + """""" 128 | + letter 129 | + """""" 130 | + end 131 | ) 132 | if self.viz: 133 | viz = self.viz[0] 134 | shift = self.viz[1] 135 | else: 136 | viz = int(1100 / (2 * self.fontsize)) + 2 # _very_ rough approx 137 | shift = int(viz / 2) 138 | if self.rot == "90": # TODO: Convert these to an Enum 139 | viz = int(int(self.fontsize) / 9) 140 | shift = 2 141 | row = self.cursor.line 142 | if row < viz: 143 | return markdownify(lines, row) 144 | else: 145 | return markdownify(lines[row - shift :], shift) 146 | 147 | def setup_movement(self): 148 | def up(): 149 | self.cursor ^= -1 150 | self.buffer.clip(self.cursor) 151 | return 152 | 153 | def down(): 154 | self.cursor ^= +1 155 | self.buffer.clip(self.cursor) 156 | return 157 | 158 | def right(): 159 | self.cursor += 1 160 | self.buffer.clip(self.cursor) 161 | return 162 | 163 | def left(): 164 | self.cursor -= 1 165 | self.buffer.clip(self.cursor) 166 | return 167 | 168 | self.GENERIC_MOVEMENT = { 169 | Keys.ControlA: lambda: self.cursor.to(line=self.cursor.line, column=0), 170 | Keys.ControlE: lambda: self.cursor.to( 171 | line=self.cursor.line, column=len(self.buffer.get()[self.cursor.line]) 172 | ), 173 | Keys.Up: up, 174 | Keys.Down: down, 175 | Keys.Left: left, 176 | Keys.Right: right, 177 | } 178 | 179 | def dispatch(self, _key): 180 | if _key is None: 181 | return 182 | key = _key.key 183 | if key == Keys.ControlC: 184 | self._break_counter += 1 185 | if self._break_counter == 3: 186 | subprocess.call( 187 | [ 188 | self._display, 189 | "/home/ruben/display.py", 190 | "-f", 191 | self.font, 192 | "-s", 193 | "off", 194 | ] 195 | ) 196 | sys.exit(0) 197 | else: 198 | self._break_counter = 0 199 | if key == Keys.Escape and self.log_keys: 200 | self.log_keys = False 201 | return 202 | if self.log_keys: 203 | # TODO: move this appending to be a method on buffer 204 | self.buffer.lines.append(Line(str(key))) 205 | return 206 | 207 | if key == Keys.ControlP: 208 | p_suggestions = proselint_tools.lint( 209 | str(self.buffer.lines[self.cursor.line]), 210 | config=proselint_config.default, 211 | ) 212 | suggestions = [f"At {sug[3]}: {sug[1]}" for sug in p_suggestions] 213 | self.modal = "".join(suggestions) 214 | self.updating_fields["modal"] = True 215 | return 216 | 217 | if key in self.GENERIC_MOVEMENT: 218 | self.GENERIC_MOVEMENT[key]() 219 | return 220 | 221 | if key == Keys.ControlQ: 222 | self.refresh = True 223 | self.clear_command() 224 | return 225 | 226 | if key == Keys.Escape and self.modal != "": 227 | logger.info("Hiding modal") 228 | self.modal = "" 229 | self.updating_fields["modal"] = True 230 | self.clear_command() 231 | return 232 | 233 | if key == "q" and self.visual != "": 234 | logger.info("Hiding visual") 235 | self.visual = "" 236 | self.updating_fields["visual"] = True 237 | self.clear_command() 238 | return 239 | 240 | if self._mode == Mode.INSERT: 241 | if key == Keys.Escape: 242 | self._mode = Mode.NORMAL 243 | self.updating_fields["mode"] = True 244 | self.cursor -= 1 # This seems to be the vim behaviour 245 | self.buffer.clip(self.cursor) 246 | logger.debug( 247 | "History before: %s, %s", self._history, self._history_pointer 248 | ) 249 | # TODO: All this needs better testing, and it may get tricky 250 | if self._history_pointer + 1 >= len(self._history): 251 | logger.debug("Adding at the end") 252 | self._history.append(self.buffer.copy()) 253 | else: 254 | self._history[self._history_pointer + 1] = self.buffer.copy() 255 | self._history_pointer += 1 256 | self._history_pointer = min(self._history_pointer, self.UNDO_DEPTH - 1) 257 | logger.debug("Clipping pointer at %s", self._history_pointer) 258 | if len(self._history) > self.UNDO_DEPTH: 259 | self._history = self._history[-self.UNDO_DEPTH :] 260 | assert len(self._history) == self.UNDO_DEPTH 261 | logger.debug( 262 | "History after: %s, %s", self._history, self._history_pointer 263 | ) 264 | return 265 | if self.saved: 266 | self.updating_fields["saved"] = True 267 | self.saved = False 268 | if key == Keys.Delete or key == Keys.ControlH: 269 | self.buffer.delete(self.cursor) 270 | return 271 | self.buffer.insert(key, self.cursor) 272 | 273 | if self._mode == Mode.NORMAL: 274 | self._command.append(key) 275 | self.updating_fields["command"] = True 276 | self.dispatch_command(self._command) 277 | return 278 | 279 | def clear_command(self): 280 | self.completions = None 281 | self.completions_markdownified = None 282 | self.updating_fields["completions"] = True 283 | self.status = " " 284 | self.updating_fields["status"] = True 285 | self._command = [] 286 | self.updating_fields["command"] = True 287 | 288 | def command(self): 289 | filt = [str(l) for l in self._command if len(str(l)) == 1] 290 | return "".join(filt) 291 | 292 | def dispatch_command(self, command): 293 | self.dispatcher.dispatch_command(command) 294 | -------------------------------------------------------------------------------- /piwrite/help: -------------------------------------------------------------------------------- 1 | # PiWrite help 2 | (press `q` to go back to your previous file) 3 | --- 4 | ### Welcome to PiWrite! 5 | 6 | **PiWrite** is a modal text editor inspired by Vim. If you want to know more details, please check the repository at `github.com/rberenguel/piwrite` 7 | 8 | It partially supports a reduced subset of Markdown, so you can: 9 | 10 | - Bold by surrounding with ** 11 | - Italics by surrounding with _ 12 | - Code block by surrounding with ` 13 | - Highlight a block by surrounding with :: (this is not markdown but was convenient) 14 | - Use up to 4 # for headings 15 | - Start a line with - to get an unordered list 16 | - _Ordered lists are not supported_ 17 | 18 | --- 19 | 20 | ### Changing modes 21 | 22 | `i`: enter insert mode from normal mode just before the current cursor position 23 | `I`: enter insert mode from normal mode _at the beginning of the current line_ 24 | `a`: enter insert mode from normal mode just _after_ the current cursor position 25 | `A`: enter insert mode from normal mode _at the end of the current line_ 26 | `o`: add a line below the cursor and enter insert mode on it 27 | 28 | `ESC`: enter normal mode from insert mode. For some reason (probably related to `prompt-toolkit` you need to press it twice. I'm looking into it. 29 | 30 | ### Commands that work regardless of the mode 31 | 32 | `Ctrl-a`: move the cursor to the beginning of the paragraph/line 33 | `Ctrl-e`: move the cursor to the end of the paragraph/line 34 | `Ctrl-p`: run `proselint` in the current paragraph/line. It is a bit slow 35 | `Arrows`: move the cursor around 36 | 37 | ### Commands in Normal mode 38 | 39 | `gg`: Go to the beginning of the file 40 | `G`: Go to the end of the file 41 | 42 | `:h`: This help 43 | `:e filename`: Open file (in the `piwrite-docs` folder only). Won't work if you have unsaved changes 44 | `:e! filename`: Open file (in the `piwrite-docs` folder only). Will work regardless of your save state 45 | `:w filename`: Write file (in the `piwrite-docs` folder only) 46 | 47 | `u`: Undo. The default (and so far only) undo depth is 10 48 | `Ctrl-r`: Redo. Same depth as above 49 | 50 | `:rot`: Hacky, landscape mode. Cursor scrolling does not work that well in this case, sadly (due to browser issues I have to use the editor mode which is only half-functional) 51 | 52 | `:mono`: Switch to a monospace font (Monoid) 53 | `:sans`: Switch to a sans-serif font (Cooper Hewitt book) 54 | `:serif`: Switch to a serif font (default, IM Fell English) 55 | `:latex`: Switch to Computer Modern (the typical LaTeX font) 56 | `:gyre`: Switch to TeX Gyre Heros (a font similar to Helvetica) 57 | 58 | `:fontsize N`: Change font size to `N` points 59 | `:fs N`: Shorthand for the above 60 | 61 | `:stats`: Get word/paragraph counts, Flesch-Kincaid readability and Flesch ease 62 | `Ctrl-s`: Get word/paragraph counts in the status line 63 | `:lint`: Run `proselint` on the whole document. It is a bit slow to do so 64 | `:dot`: _Experimental_: use a Graphviz header template in `dot_template.dot` and render this file. Press `q` to go back to the file 65 | `v`: turn on "reading mode" for the current document 66 | `viz N:M`: More or less lines measure of lines in buffer (`M`) and shift (`N`) 67 | 68 | `Ctrl-q`: Send all internal fields to the frontend, to refresh the webpage (hopefully) 69 | `:q`: Quit, will try to shutdown the machine. Won't work if you have unsaved changes 70 | `:q!`: Quit, will try to shutdown th machine. Will work regardless of your save state 71 | 72 | `:keys`: Insert the pressed keys in the buffer. Useful to debug 73 | 74 | `dd`: delete the whole line 75 | `daw`: delete _around_ word (will slightly alter spaces) 76 | `diw`: delete _inside_ word (preserves spaces) 77 | `caw`: change _around_ word (like daw but switches to insert mode) 78 | `ciw`: change _around_ word (like diw but switches to insert mode) 79 | 80 | `p`: paste the last deleted/copied text at the cursor position 81 | `P`: paste the last deleted/copied text before the cursor position 82 | 83 | --- 84 | 85 | You can put commands in a file named `config`, it will be loaded on start if it exists. Useful to set the font and font size, like 86 | 87 | :latex 88 | :fs 14 89 | -------------------------------------------------------------------------------- /piwrite/line.py: -------------------------------------------------------------------------------- 1 | class Line: 2 | def __init__(self, contents=None): 3 | if contents: 4 | self.contents = contents 5 | else: 6 | self.contents = "" 7 | self.cursor = None 8 | 9 | def copy(self): 10 | return Line(self.contents) 11 | 12 | def __getitem__(self, key): 13 | return self.contents[key] 14 | 15 | def __len__(self): 16 | return len(self.contents) 17 | 18 | def __repr__(self): 19 | return self.contents 20 | 21 | def __eq__(self, other): 22 | if isinstance(other, Line): 23 | return self.contents == other.contents 24 | if isinstance(other, str): 25 | return self.contents == other 26 | 27 | def __iadd__(self, other): 28 | self.contents += other.contents 29 | return self 30 | 31 | def delete(self, column): 32 | s = self.contents 33 | self.contents = s[0 : column - 1] + s[column:] 34 | 35 | def insert(self, column, letter): 36 | s = self.contents 37 | if len(self.contents) == column: 38 | self.contents = s + letter 39 | else: 40 | # TODO: keep the breakage point and keep inserting until a change of cursor 41 | # Probably needs to keep track of cursor, which I haven't used yet. 42 | self.contents = ( 43 | s[0:column] + letter + s[column:] 44 | ) # Wasteful, but ok for a PoC 45 | 46 | def _iw(self): 47 | """Handle, kind of, the inside word text object. Returns the new cursor position""" 48 | # This is uglily hardcoded like this 49 | # Note that daw is diw + deleting a space 50 | col = self.cursor.column 51 | # For now will ignore any other separators (vim handles . and others) 52 | lin = self.contents 53 | end = lin.find(" ", col) 54 | start = lin.rfind(" ", 0, col) 55 | if end == -1 and start == -1: 56 | return Line(""), "", 0 57 | if end == -1: 58 | return Line(lin[0 : start + 1]), lin[start + 1 :], start + 1 59 | if start == -1: 60 | return Line(lin[end:]), lin[0:end], 0 61 | return Line(lin[0 : start + 1] + lin[end:]), lin[start + 1 : end], start + 1 62 | 63 | def _aw(self): 64 | """Handle, kind of, the around word text object. Returns the new cursor position""" 65 | # This is uglily hardcoded like this 66 | # Note that daw is diw + deleting a space 67 | col = self.cursor.column 68 | # For now will ignore any other separators (vim handles . and others) 69 | lin = self.contents 70 | end = lin.find(" ", col) 71 | start = lin.rfind(" ", 0, col) 72 | if end == -1 and start == -1: 73 | print("Deletion has been empty-ish") 74 | return Line(""), lin, 0 75 | if end == -1: 76 | return Line(lin[0:start]), lin[start:], start + 1 77 | if start == -1: 78 | return Line(lin[end + 1 :]), lin[0 : end + 1], 0 79 | return Line(lin[0:start] + lin[end:]), lin[start + 1 : end + 1], start + 1 80 | -------------------------------------------------------------------------------- /piwrite/markdownify.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def start_highlighter(ent, clip=0): 5 | def focuser(line, idx, current): 6 | if idx == current: 7 | return f"""<{ent} class="focus">{line[clip:]}{ent}>""" 8 | else: 9 | return f"""<{ent}>{line[clip:]}{ent}>""" 10 | 11 | return focuser 12 | 13 | 14 | STARTS = { 15 | "# ": start_highlighter("h1"), # lambda t: f"{t}", 16 | "## ": start_highlighter("h2"), # lambda t: f"{t}", 17 | "### ": start_highlighter("h3"), # lambda t: f"{t}", 18 | "#### ": start_highlighter("h4"), # lambda t: f"{t}", 19 | "- ": start_highlighter("li", clip=2), # lambda t: f"{t[2:]}", 20 | } 21 | 22 | 23 | def bolding(line, visible=True): 24 | beg_of_word = re.compile(r"(^|\s)\*\*(\S)") 25 | end_of_word = re.compile(r"(\S)\*\*($|\s|:|\.|\W)") 26 | if visible: 27 | mark = "**" 28 | else: 29 | mark = "" 30 | new_line = re.sub(beg_of_word, f"\\1{mark}\\2", line) 31 | new_line = re.sub(end_of_word, f"\\1{mark}\\2", new_line) 32 | return new_line 33 | 34 | 35 | def highlighting(line, visible=True): 36 | beg_of_word = re.compile(r"(^|\s)::(\S)") 37 | end_of_word = re.compile(r"(\S)::($|\s|\W)") 38 | if visible: 39 | mark = "::" 40 | else: 41 | mark = "" 42 | new_line = re.sub(beg_of_word, f"\\1{mark}\\2", line) 43 | new_line = re.sub(end_of_word, f"\\1{mark}\\2", new_line) 44 | return new_line 45 | 46 | 47 | def italicising(line, visible=True): 48 | beg_of_word = re.compile(r"(^|\s)_(\S)") 49 | end_of_word = re.compile(r"(\S)_($|\s|:|\.|\W)") 50 | if visible: 51 | mark = "_" 52 | else: 53 | mark = "" 54 | new_line = re.sub(beg_of_word, f"\\1{mark}\\2", line) 55 | new_line = re.sub(end_of_word, f"\\1{mark}\\2", new_line) 56 | return new_line 57 | 58 | 59 | def teletyping(line, visible=True): 60 | beg_of_word = re.compile(r"(^|\s)`(\S)") 61 | end_of_word = re.compile(r"(\S)`($|\s|:|\.|\W)") 62 | if visible: 63 | mark = "`" 64 | else: 65 | mark = "" 66 | new_line = re.sub(beg_of_word, f"\\1{mark}\\2", line) 67 | new_line = re.sub(end_of_word, f"\\1{mark}\\2", new_line) 68 | return new_line 69 | 70 | 71 | def focus(line, idx, current): 72 | if idx == current: 73 | return f"""{line}""" 74 | return line 75 | 76 | 77 | def markdownify(original_lines, current_line=-1, visible=True): 78 | """Convert simple Markdown to reasonable HTML (with some visible Markdown markers), with highlighting of the current line""" 79 | new_lines = [] 80 | skip = False 81 | for idx, line in enumerate(original_lines): 82 | newline = line 83 | if line == "---": 84 | new_lines.append(focus("", idx, current_line)) 85 | continue 86 | if newline == "": 87 | new_lines.append( 88 | focus(""" """, idx, current_line) 89 | ) 90 | continue 91 | 92 | newline = bolding(newline, visible) 93 | newline = italicising(newline, visible) 94 | newline = teletyping(newline, visible) 95 | newline = highlighting(newline, visible) 96 | for key, transform in STARTS.items(): 97 | if line.startswith(key): 98 | skip = True 99 | if key == "- ": 100 | newline = transform(newline, idx, current_line) 101 | else: 102 | newline = transform(newline, idx, current_line) 103 | else: 104 | if not skip: 105 | newline = focus(newline, idx, current_line) 106 | newline = newline + "" 107 | else: 108 | skip = False 109 | new_lines.append(newline) 110 | return new_lines 111 | -------------------------------------------------------------------------------- /piwrite/mode.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Mode(Enum): 5 | NORMAL = "N" 6 | INSERT = "I" 7 | -------------------------------------------------------------------------------- /piwrite/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib 3 | import itertools 4 | import logging 5 | import os 6 | 7 | try: 8 | import importlib.resources as pkg_resources 9 | except ImportError: 10 | # I shouldn't have to do this 11 | import importlib_resources as pkg_resources 12 | 13 | import aiohttp.web 14 | import socketio 15 | from colorlog import ColoredFormatter 16 | from prompt_toolkit.input import create_input 17 | from prompt_toolkit.keys import Keys 18 | 19 | logger = logging.getLogger("piwrite") 20 | 21 | import nltk 22 | 23 | from piwrite.editor import Editor 24 | 25 | HOST = os.getenv("PIWRITE_HOST", "127.0.0.1") 26 | DEBUG = os.getenv("PIWRITE_DEBUG", "False") == "True" 27 | INFO = os.getenv("PIWRITE_INFO", "False") == "True" 28 | PORT = int(os.getenv("PIWRITE_PORT", 80)) 29 | 30 | STATIC_FOLDER = pkg_resources.files("piwrite") / "static" 31 | 32 | 33 | def configure_logger(): 34 | """Fancy logging is nicer""" 35 | formatter = ColoredFormatter( 36 | "%(log_color)s%(levelname)s - %(message)s", 37 | datefmt=None, 38 | reset=True, 39 | log_colors={ 40 | "DEBUG": "yellow", 41 | "INFO": "cyan", 42 | "WARNING": "purple", 43 | "ERROR": "red", 44 | "CRITICAL": "red,bg_white", 45 | }, 46 | secondary_log_colors={}, 47 | style="%", 48 | ) 49 | handler = logging.StreamHandler() 50 | handler.setFormatter(formatter) 51 | logger.addHandler(handler) 52 | 53 | 54 | def staticHandle(file): 55 | async def handler(request): 56 | return aiohttp.web.FileResponse(file) 57 | 58 | return handler 59 | 60 | 61 | v = None 62 | sio = socketio.AsyncServer(logger=False, engineio_logger=False, async_mode="aiohttp") 63 | 64 | 65 | def init_map(): 66 | update_only_map = { 67 | "saved": {"sent": False, "old": None, "exec": lambda: v.saved}, 68 | "completions": { 69 | "sent": False, 70 | "old": None, 71 | "exec": lambda: v.completions_markdownified, 72 | }, 73 | "mode": {"sent": False, "old": None, "exec": lambda: v.mode()}, 74 | "err": {"sent": False, "old": None, "exec": lambda: v.err}, 75 | "filename": {"sent": False, "old": None, "exec": lambda: v.filename}, 76 | "command": {"sent": False, "old": None, "exec": lambda: v.command()}, 77 | "modal": {"sent": False, "old": None, "exec": lambda: v.modal}, 78 | "visual": {"sent": False, "old": None, "exec": lambda: v.visual}, 79 | "status": {"sent": False, "old": None, "exec": lambda: v.status}, 80 | "font": {"sent": False, "old": None, "exec": lambda: v.font}, 81 | "fontsize": {"sent": False, "old": None, "exec": lambda: v.fontsize}, 82 | "rot": {"sent": False, "old": None, "exec": lambda: v.rot}, 83 | "dot": {"sent": False, "old": None, "exec": lambda: v.dot}, 84 | } 85 | return update_only_map 86 | 87 | 88 | async def the_loop(): 89 | key = asyncio.Event() 90 | key_press = None 91 | done = asyncio.Event() 92 | inp = create_input() 93 | 94 | update_only_map = init_map() 95 | update_only_fields = list(update_only_map.keys()) 96 | 97 | def keys_ready(): 98 | nonlocal key_press 99 | for _key_press in itertools.chain(inp.read_keys(), inp.flush_keys()): 100 | key.set() 101 | key_press = _key_press 102 | 103 | with inp.raw_mode(): 104 | with inp.attach(keys_ready): 105 | while True: 106 | await key.wait() 107 | logger.debug(key_press) 108 | v.dispatch(key_press) 109 | await sio.emit("buffer", {"data": v.get()}) 110 | if v.refresh: 111 | logger.info("Sending a full refresh") 112 | for field, val in update_only_map.items(): 113 | await sio.emit(field, {"data": val["exec"]()}) 114 | v.refresh = False 115 | logger.info(f"Updating {len(v.updating_fields)} fields") 116 | for field in v.updating_fields: 117 | new_val = update_only_map[field]["exec"]() 118 | await sio.emit(field, {"data": new_val}) 119 | v.updating_fields.clear() 120 | key.clear() 121 | 122 | 123 | async def main(): 124 | app = aiohttp.web.Application() 125 | app.router.add_route("GET", "/", staticHandle(STATIC_FOLDER / "index.html")) 126 | app.add_routes([aiohttp.web.static("/static", STATIC_FOLDER, show_index=False)]) 127 | app.add_routes([aiohttp.web.static("/docs", v.docs, show_index=True)]) 128 | sio.attach(app) 129 | runner = aiohttp.web.AppRunner(app) 130 | await runner.setup() 131 | site = aiohttp.web.TCPSite(runner, host=HOST, port=PORT) 132 | await site.start() 133 | logger.warning(f"Server started at '{HOST}:{PORT}'") 134 | await the_loop() 135 | await asyncio.Event().wait() 136 | 137 | 138 | def start(): 139 | global v 140 | configure_logger() 141 | if DEBUG: 142 | logger.setLevel(logging.DEBUG) 143 | elif INFO: 144 | logger.setLevel(logging.INFO) 145 | else: 146 | logger.setLevel(logging.WARNING) 147 | try: 148 | nltk.data.find("tokenizers/punkt") 149 | except LookupError: 150 | logger.warning("Punkt not available") 151 | try: 152 | # This means you need to run it at least 153 | # once without access point mode 154 | nltk.download("punkt") 155 | except: 156 | pass 157 | v = Editor(skip_config=False) 158 | asyncio.run(main()) 159 | 160 | 161 | if __name__ == "__main__": 162 | start() 163 | -------------------------------------------------------------------------------- /piwrite/static/30socketio.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Socket.IO v3.0.0 3 | * (c) 2014-2020 Guillermo Rauch 4 | * Released under the MIT License. 5 | */ 6 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.io=e():t.io=e()}("undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:Function("return this")(),(function(){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var o=e[r]={i:r,l:!1,exports:{}};return t[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var o in t)n.d(r,o,function(e){return t[e]}.bind(null,o));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=17)}([function(t,e,n){function r(t){if(t)return function(t){for(var e in r.prototype)t[e]=r.prototype[e];return t}(t)}t.exports=r,r.prototype.on=r.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this},r.prototype.once=function(t,e){function n(){this.off(t,n),e.apply(this,arguments)}return n.fn=e,this.on(t,n),this},r.prototype.off=r.prototype.removeListener=r.prototype.removeAllListeners=r.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var n,r=this._callbacks["$"+t];if(!r)return this;if(1==arguments.length)return delete this._callbacks["$"+t],this;for(var o=0;o=this._reconnectionAttempts)this.backoff.reset(),i(u(b.prototype),"emit",this).call(this,"reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=setTimeout((function(){e.skipReconnect||(i(u(b.prototype),"emit",t).call(t,"reconnect_attempt",e.backoff.attempts),e.skipReconnect||e.open((function(n){n?(e._reconnecting=!1,e.reconnect(),i(u(b.prototype),"emit",t).call(t,"reconnect_error",n)):e.onreconnect()})))}),n);this.subs.push({destroy:function(){clearTimeout(r)}})}}},{key:"onreconnect",value:function(){var t=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),i(u(b.prototype),"emit",this).call(this,"reconnect",t)}}])&&o(e.prototype,n),a&&o(e,a),b}(l);e.Manager=b},function(t,e,n){var r=n(3),o=n(22),i=n(26),s=n(27);e.polling=function(t){var e=!1,n=!1,s=!1!==t.jsonp;if("undefined"!=typeof location){var c="https:"===location.protocol,a=location.port;a||(a=c?443:80),e=t.hostname!==location.hostname||a!==t.port,n=t.secure!==c}if(t.xdomain=e,t.xscheme=n,"open"in new r(t)&&!t.forceJSONP)return new o(t);if(!s)throw new Error("JSONP disabled");return new i(t)},e.websocket=s},function(t,e,n){function r(t){return(r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function o(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(t,e){for(var n=0;n0);return e}function u(){var t=a(+new Date);return t!==r?(s=0,r=t):t+"."+a(s++)}for(;c<64;c++)i[o[c]]=c;u.encode=a,u.decode=function(t){var e=0;for(c=0;c1?e-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return i(this,l),e=f.call(this),t&&"object"===o(t)&&(n=t,t=null),t?(t=y(t),n.hostname=t.host,n.secure="https"===t.protocol||"wss"===t.protocol,n.port=t.port,t.query&&(n.query=t.query)):n.host&&(n.hostname=y(n.host).host),e.secure=null!=n.secure?n.secure:"undefined"!=typeof location&&"https:"===location.protocol,n.hostname&&!n.port&&(n.port=e.secure?"443":"80"),e.hostname=n.hostname||("undefined"!=typeof location?location.hostname:"localhost"),e.port=n.port||("undefined"!=typeof location&&location.port?location.port:e.secure?443:80),e.transports=n.transports||["polling","websocket"],e.readyState="",e.writeBuffer=[],e.prevBufferLen=0,e.opts=r({path:"/engine.io",agent:!1,upgrade:!0,jsonp:!0,timestampParam:"t",policyPort:843,rememberUpgrade:!1,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{}},n),e.opts.path=e.opts.path.replace(/\/$/,"")+"/","string"==typeof e.opts.query&&(e.opts.query=d.decode(e.opts.query)),e.id=null,e.upgrades=null,e.pingInterval=null,e.pingTimeout=null,e.pingTimeoutTimer=null,e.open(),e}return e=l,(n=[{key:"createTransport",value:function(t){var e=function(t){var e={};for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}(this.opts.query);e.EIO=h.protocol,e.transport=t,this.id&&(e.sid=this.id);var n=r({},this.opts.transportOptions[t],this.opts,{query:e,socket:this,hostname:this.hostname,secure:this.secure,port:this.port});return new p[t](n)}},{key:"open",value:function(){var t;if(this.opts.rememberUpgrade&&l.priorWebsocketSuccess&&-1!==this.transports.indexOf("websocket"))t="websocket";else{if(0===this.transports.length){var e=this;return void setTimeout((function(){e.emit("error","No transports available")}),0)}t=this.transports[0]}this.readyState="opening";try{t=this.createTransport(t)}catch(t){return this.transports.shift(),void this.open()}t.open(),this.setTransport(t)}},{key:"setTransport",value:function(t){var e=this;this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",(function(){e.onDrain()})).on("packet",(function(t){e.onPacket(t)})).on("error",(function(t){e.onError(t)})).on("close",(function(){e.onClose("transport close")}))}},{key:"probe",value:function(t){var e=this.createTransport(t,{probe:1}),n=!1,r=this;function o(){if(r.onlyBinaryUpgrades){var t=!this.supportsBinary&&r.transport.supportsBinary;n=n||t}n||(e.send([{type:"ping",data:"probe"}]),e.once("packet",(function(t){if(!n)if("pong"===t.type&&"probe"===t.data){if(r.upgrading=!0,r.emit("upgrading",e),!e)return;l.priorWebsocketSuccess="websocket"===e.name,r.transport.pause((function(){n||"closed"!==r.readyState&&(f(),r.setTransport(e),e.send([{type:"upgrade"}]),r.emit("upgrade",e),e=null,r.upgrading=!1,r.flush())}))}else{var o=new Error("probe error");o.transport=e.name,r.emit("upgradeError",o)}})))}function i(){n||(n=!0,f(),e.close(),e=null)}function s(t){var n=new Error("probe error: "+t);n.transport=e.name,i(),r.emit("upgradeError",n)}function c(){s("transport closed")}function a(){s("socket closed")}function u(t){e&&t.name!==e.name&&i()}function f(){e.removeListener("open",o),e.removeListener("error",s),e.removeListener("close",c),r.removeListener("close",a),r.removeListener("upgrading",u)}l.priorWebsocketSuccess=!1,e.once("open",o),e.once("error",s),e.once("close",c),this.once("close",a),this.once("upgrading",u),e.open()}},{key:"onOpen",value:function(){if(this.readyState="open",l.priorWebsocketSuccess="websocket"===this.transport.name,this.emit("open"),this.flush(),"open"===this.readyState&&this.opts.upgrade&&this.transport.pause)for(var t=0,e=this.upgrades.length;t0&&void 0!==arguments[0]?arguments[0]:{};return o(t,{supportsBinary:this.supportsBinary,xd:this.xd,xs:this.xs},this.opts),new w(this.uri(),t)}},{key:"doWrite",value:function(t,e){var n="string"!=typeof t&&void 0!==t,r=this.request({method:"POST",data:t,isBinary:n}),o=this;r.on("success",e),r.on("error",(function(t){o.onError("xhr post error",t)}))}},{key:"doPoll",value:function(){var t=this.request(),e=this;t.on("data",(function(t){e.onData(t)})),t.on("error",(function(t){e.onError("xhr poll error",t)})),this.pollXhr=t}}]),n}(y),w=function(t){a(n,t);var e=f(n);function n(t,r){var o;return i(this,n),(o=e.call(this)).opts=r,o.method=r.method||"GET",o.uri=t,o.async=!1!==r.async,o.data=void 0!==r.data?r.data:null,o.isBinary=r.isBinary,o.supportsBinary=r.supportsBinary,o.create(),o}return c(n,[{key:"create",value:function(){var t=v(this.opts,"agent","enablesXDR","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized");t.xdomain=!!this.opts.xd,t.xscheme=!!this.opts.xs;var e=this.xhr=new h(t),r=this;try{e.open(this.method,this.uri,this.async);try{if(this.opts.extraHeaders)for(var o in e.setDisableHeaderCheck&&e.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&e.setRequestHeader(o,this.opts.extraHeaders[o])}catch(t){console.log(t)}if("POST"===this.method)try{this.isBinary?e.setRequestHeader("Content-type","application/octet-stream"):e.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{e.setRequestHeader("Accept","*/*")}catch(t){}"withCredentials"in e&&(e.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(e.timeout=this.opts.requestTimeout),this.hasXDR()?(e.onload=function(){r.onLoad()},e.onerror=function(){r.onError(e.responseText)}):e.onreadystatechange=function(){if(2===e.readyState)try{var t=e.getResponseHeader("Content-Type");(r.supportsBinary&&"application/octet-stream"===t||"application/octet-stream; charset=UTF-8"===t)&&(e.responseType="arraybuffer")}catch(t){}4===e.readyState&&(200===e.status||1223===e.status?r.onLoad():setTimeout((function(){r.onError("number"==typeof e.status?e.status:0)}),0))},e.send(this.data)}catch(t){return void setTimeout((function(){r.onError(t)}),0)}"undefined"!=typeof document&&(this.index=n.requestsCount++,n.requests[this.index]=this)}},{key:"onSuccess",value:function(){this.emit("success"),this.cleanup()}},{key:"onData",value:function(t){this.emit("data",t),this.onSuccess()}},{key:"onError",value:function(t){this.emit("error",t),this.cleanup(!0)}},{key:"cleanup",value:function(t){if(void 0!==this.xhr&&null!==this.xhr){if(this.hasXDR()?this.xhr.onload=this.xhr.onerror=m:this.xhr.onreadystatechange=m,t)try{this.xhr.abort()}catch(t){}"undefined"!=typeof document&&delete n.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var t;try{var e;try{e=this.xhr.getResponseHeader("Content-Type")}catch(t){}t=("application/octet-stream"===e||"application/octet-stream; charset=UTF-8"===e)&&this.xhr.response||this.xhr.responseText}catch(t){this.onError(t)}null!=t&&this.onData(t)}},{key:"hasXDR",value:function(){return"undefined"!=typeof XDomainRequest&&!this.xs&&this.enablesXDR}},{key:"abort",value:function(){this.cleanup()}}]),n}(d);if(w.requestsCount=0,w.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",_);else if("function"==typeof addEventListener){addEventListener("onpagehide"in b?"pagehide":"unload",_,!1)}function _(){for(var t in w.requests)w.requests.hasOwnProperty(t)&&w.requests[t].abort()}t.exports=k,t.exports.Request=w},function(t,e,n){var r=n(11).PACKET_TYPES,o="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),i="function"==typeof ArrayBuffer,s=function(t,e){var n=new FileReader;return n.onload=function(){var t=n.result.split(",")[1];e("b"+t)},n.readAsDataURL(t)};t.exports=function(t,e,n){var c,a=t.type,u=t.data;return o&&u instanceof Blob?e?n(u):s(u,n):i&&(u instanceof ArrayBuffer||(c=u,"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(c):c&&c.buffer instanceof ArrayBuffer))?e?n(u instanceof ArrayBuffer?u:u.buffer):s(new Blob([u]),n):n(r[a]+(u||""))}},function(t,e,n){var r,o=n(11),i=o.PACKET_TYPES_REVERSE,s=o.ERROR_PACKET;"function"==typeof ArrayBuffer&&(r=n(25));var c=function(t,e){if(r){var n=r.decode(t);return a(n,e)}return{base64:!0,data:t}},a=function(t,e){switch(e){case"blob":return t instanceof ArrayBuffer?new Blob([t]):t;case"arraybuffer":default:return t}};t.exports=function(t,e){if("string"!=typeof t)return{type:"message",data:a(t,e)};var n=t.charAt(0);return"b"===n?{type:"message",data:c(t.substring(1),e)}:i[n]?t.length>1?{type:i[n],data:t.substring(1)}:{type:i[n]}:s}},function(t,e){!function(){"use strict";for(var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",n=new Uint8Array(256),r=0;r>2],i+=t[(3&r[n])<<4|r[n+1]>>4],i+=t[(15&r[n+1])<<2|r[n+2]>>6],i+=t[63&r[n+2]];return o%3==2?i=i.substring(0,i.length-1)+"=":o%3==1&&(i=i.substring(0,i.length-2)+"=="),i},e.decode=function(t){var e,r,o,i,s,c=.75*t.length,a=t.length,u=0;"="===t[t.length-1]&&(c--,"="===t[t.length-2]&&c--);var f=new ArrayBuffer(c),p=new Uint8Array(f);for(e=0;e>4,p[u++]=(15&o)<<4|i>>2,p[u++]=(3&i)<<6|63&s;return f}}()},function(t,e,n){function r(t){return(r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function o(t,e){for(var n=0;n';n=document.createElement(t)}catch(t){(n=document.createElement("iframe")).name=r.iframeId,n.src="javascript:0"}n.id=r.iframeId,r.form.appendChild(n),r.iframe=n}this.form.action=this.uri(),a(),t=t.replace(d,"\\\n"),this.area.value=t.replace(y,"\\n");try{this.form.submit()}catch(t){}this.iframe.attachEvent?this.iframe.onreadystatechange=function(){"complete"===r.iframe.readyState&&c()}:this.iframe.onload=c}},{key:"supportsBinary",get:function(){return!1}}])&&o(e.prototype,n),r&&o(e,r),l}(l);t.exports=b},function(t,e,n){function r(t){return(r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function o(t,e){for(var n=0;n=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,c=!0,a=!1;return{s:function(){n=t[Symbol.iterator]()},n:function(){var t=n.next();return c=t.done,t},e:function(t){a=!0,s=t},f:function(){try{c||null==n.return||n.return()}finally{if(a)throw s}}}}function i(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=new Array(e);n1?e-1:0),r=1;r0&&t.jitter<=1?t.jitter:0,this.attempts=0}t.exports=n,n.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-n:t+n}return 0|Math.min(t,this.max)},n.prototype.reset=function(){this.attempts=0},n.prototype.setMin=function(t){this.ms=t},n.prototype.setMax=function(t){this.max=t},n.prototype.setJitter=function(t){this.jitter=t}}])})); 7 | //# sourceMappingURL=socket.io.min.js.map -------------------------------------------------------------------------------- /piwrite/static/ImFell-SC.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/ImFell-SC.ttf -------------------------------------------------------------------------------- /piwrite/static/ImFell-i.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/ImFell-i.ttf -------------------------------------------------------------------------------- /piwrite/static/ImFell.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/ImFell.ttf -------------------------------------------------------------------------------- /piwrite/static/cmunbx.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cmunbx.ttf -------------------------------------------------------------------------------- /piwrite/static/cmunrm.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cmunrm.ttf -------------------------------------------------------------------------------- /piwrite/static/cmunti.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cmunti.ttf -------------------------------------------------------------------------------- /piwrite/static/cooper-book-i.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cooper-book-i.otf -------------------------------------------------------------------------------- /piwrite/static/cooper-book.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cooper-book.otf -------------------------------------------------------------------------------- /piwrite/static/cooper-heavy.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/cooper-heavy.otf -------------------------------------------------------------------------------- /piwrite/static/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "IM Fell English"; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url("/static/ImFell.ttf") format("truetype"); 6 | } 7 | 8 | @font-face { 9 | font-family: "IM Fell English"; 10 | font-style: bold; 11 | font-weight: 700; 12 | src: url("/static/ImFell-SC.ttf") format("truetype"); 13 | } 14 | 15 | @font-face { 16 | font-family: "IM Fell English"; 17 | font-style: italic; 18 | font-weight: 400; 19 | src: url("/static/ImFell-i.ttf") format("truetype"); 20 | } 21 | 22 | 23 | @font-face { 24 | font-family: "monoid"; 25 | src: url("/static/monoid-regular.ttf") format("truetype"); 26 | font-weight: normal; 27 | font-style: normal; 28 | } 29 | 30 | @font-face { 31 | font-family: "monoid"; 32 | src: url("/static/monoid-bold.ttf") format("truetype"); 33 | font-weight: bold; 34 | font-style: bold; 35 | } 36 | 37 | @font-face { 38 | font-family: "Cooper Hewitt"; 39 | src: url("/static/cooper-book.otf") format("opentype"); 40 | font-weight: 400; 41 | font-style: normal; 42 | } 43 | 44 | @font-face { 45 | font-family: "Cooper Hewitt"; 46 | src: url("/static/cooper-book-i.otf") format("opentype"); 47 | font-weight: 400; 48 | font-style: italic; 49 | } 50 | 51 | @font-face { 52 | font-family: "Cooper Hewitt"; 53 | src: url("/static/cooper-heavy.otf") format("opentype"); 54 | font-weight: 900; 55 | font-style: bold; 56 | } 57 | 58 | @font-face { 59 | font-family: "Computer Modern"; 60 | src: url("/static/cmunrm.ttf") format("truetype"); 61 | font-weight: 400; 62 | font-style: normal; 63 | } 64 | 65 | @font-face { 66 | font-family: "Computer Modern"; 67 | src: url("/static/cmunbx.ttf") format("truetype"); 68 | font-weight: 900; 69 | font-style: bold; 70 | } 71 | 72 | @font-face { 73 | font-family: "Computer Modern"; 74 | src: url("/static/cmunti.ttf") format("truetype"); 75 | font-weight: 400; 76 | font-style: italic; 77 | } 78 | 79 | @font-face { 80 | font-family: "TeX Gyre Heros"; 81 | src: url("/static/texgyreheros-regular.otf") format("opentype"); 82 | font-weight: 400; 83 | font-style: normal; 84 | } 85 | 86 | @font-face { 87 | font-family: "TeX Gyre Heros"; 88 | src: url("/static/texgyreheros-bold.otf") format("opentype"); 89 | font-weight: 900; 90 | font-style: bold; 91 | } 92 | 93 | @font-face { 94 | font-family: "TeX Gyre Heros"; 95 | src: url("/static/texgyreheros-italic.otf") format("opentype"); 96 | font-weight: 400; 97 | font-style: italic; 98 | } 99 | 100 | 101 | body { 102 | font-family: "monoid"; 103 | margin-right: 0%; 104 | margin-left: 0%; 105 | margin-bottom: 0%; 106 | margin-top: 0%; 107 | } 108 | 109 | .rot90 { 110 | -webkit-transform: rotate(90deg); 111 | -moz-transform: rotate(90deg); 112 | -o-transform: rotate(90deg); 113 | -ms-transform: rotate(90deg); 114 | position: absolute; 115 | top: 200px !important; 116 | width: 1300px; 117 | height: 1000px; 118 | } 119 | 120 | .monospace { 121 | font-family: "monoid"; 122 | font-size: 83%; 123 | } 124 | 125 | .latex { 126 | font-family: "Computer Modern"; 127 | } 128 | 129 | .gyre { 130 | font-family: "TeX Gyre Heros"; 131 | } 132 | 133 | 134 | .serif { 135 | font-family: "IM Fell English", serif; 136 | } 137 | 138 | .sans { 139 | font-family: "Cooper Hewitt", sans-serif; 140 | } 141 | 142 | h1 { 143 | font-size: 105%; 144 | margin-top: 0.1em; 145 | margin-bottom: 0.1em; 146 | } 147 | h2 { 148 | font-size: 104%; 149 | margin-top: 0.12em; 150 | margin-bottom: 0.12em; 151 | } 152 | h3 { 153 | font-size: 103%; 154 | margin-top: 0.13em; 155 | margin-bottom: 0.13em; 156 | } 157 | h4 { 158 | font-size: 102%; 159 | margin-top: 0.14em; 160 | margin-bottom: 0.14em; 161 | } 162 | 163 | .focus { 164 | background-color: rgba(0, 0, 0, 0.1); 165 | top: 50% !important; 166 | } 167 | 168 | .highlight { 169 | background-color: rgba(0, 0, 0, 0.2); 170 | } 171 | 172 | .small { 173 | font-size: 85%; 174 | display: block; 175 | } 176 | 177 | #container { 178 | width: 100%; 179 | border-bottom: 1px solid black; 180 | margin-bottom: 0.3em; 181 | height: 1.9em; 182 | z-index: 10; 183 | background-color: rgba(251, 251, 251, 1); 184 | } 185 | 186 | #bottom { 187 | position: absolute; 188 | bottom: 1.5em; 189 | width: 100%; 190 | border-top: 1px solid black; 191 | margin-top: 0.3em; 192 | margin-bottom: 0.6em; 193 | height: 4em; 194 | min-height: 4em; 195 | max-height: 4em; 196 | z-index: 10; 197 | background-color: rgba(251, 251, 251, 1); 198 | } 199 | 200 | .rotbottom { 201 | bottom: -170px !important; /* So messy */ 202 | } 203 | 204 | #fieldcontainer { 205 | margin-left: 1%; 206 | margin-right: 1%; 207 | min-height: 1000px; 208 | max-height: 1000px; 209 | } 210 | 211 | #field { 212 | position: relative; 213 | z-index: -1; 214 | } 215 | 216 | #visual { 217 | display: none; 218 | margin: 3%; 219 | } 220 | 221 | #modal { 222 | font-family: "monoid"; 223 | font-size: 10pt; 224 | padding: 0.5em; 225 | display: none; 226 | position: absolute; 227 | width: 80%; 228 | left: 8%; 229 | max-height: 800px; 230 | top: 10%; 231 | border-radius: 9px; 232 | border: 3px solid black; 233 | z-index: 10; 234 | background: white; 235 | } 236 | 237 | #graph { 238 | display: none; 239 | } 240 | 241 | #filename { 242 | font-family: "monoid"; 243 | font-size: 8pt; 244 | float: left; 245 | margin-left: 1%; 246 | } 247 | 248 | #saved { 249 | font-family: "monoid"; 250 | font-size: 8pt; 251 | float: left; 252 | } 253 | 254 | #mode { 255 | width: 30%; 256 | text-align: right; 257 | font-family: "monoid"; 258 | font-size: 8pt; 259 | float: right; 260 | margin-right: 3%; 261 | } 262 | 263 | #completions { 264 | margin-left: 1%; 265 | min-height: 1.5em; 266 | max-height: 1.5em; 267 | font-size: 8pt; 268 | width: 100%; 269 | display: block; 270 | } 271 | #status { 272 | margin-left: 1%; 273 | min-height: 1.5em; 274 | max-height: 1.5em; 275 | font-size: 8pt; 276 | width: 100%; 277 | display: block; 278 | /* By setting a fixed size it stops flickering on Kindle */ 279 | } 280 | 281 | #caret {} 282 | 283 | #insertion { 284 | margin-left: -0.1em; 285 | margin-right: 0em; 286 | } 287 | 288 | .normal { 289 | background-color: rgba(0, 0, 0, 0.3); 290 | } 291 | 292 | .ins { 293 | border-right: 2px solid black; 294 | } 295 | #ins0 { 296 | border-left: 2px solid black; 297 | } 298 | 299 | pre { 300 | white-space: pre-wrap; 301 | white-space: -moz-pre-wrap; 302 | white-space: -pre-wrap; 303 | white-space: -o-pre-wrap; 304 | word-wrap: break-word; 305 | overflow: hidden; 306 | } 307 | 308 | /* Seriously, I had to do this to get IM Fell to have a nicer bold */ 309 | b { 310 | font-weight: bold; 311 | text-shadow: 1px 1px 1px black; 312 | } 313 | -------------------------------------------------------------------------------- /piwrite/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | PiWrite 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | (filename) 13 | 14 | ? 15 | 16 | 17 | 18 | Press any key to trigger an update (or Ctrl-q to force a server refresh), and press :h(return) to get help 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /piwrite/static/index.js: -------------------------------------------------------------------------------- 1 | window.onerror = function(e, source, lineno, colno, error){ 2 | document.getElementById('err').innerText = e.toString() + " " + lineno + " " + error; 3 | } 4 | 5 | function piwrite(){ 6 | const socket = io() 7 | 8 | socket.on('connect', function () { 9 | console.log('created connection') 10 | }); 11 | 12 | socket.on('err', function (e) { 13 | if(!e.data || e.data == ""){ 14 | return 15 | } 16 | document.getElementById("status").innerHTML = e.data 17 | }); 18 | 19 | socket.on('completions', function (e) { 20 | document.getElementById("completions").innerHTML = e.data 21 | }); 22 | socket.on('fontsize', function (e) { 23 | if(!e.data || e.data == ""){ 24 | return 25 | } 26 | document.getElementById("field").style.fontSize = e.data + "pt" 27 | document.getElementById("visual").style.fontSize = e.data + "pt" 28 | }) 29 | 30 | socket.on('dot', function (e) { 31 | if(!e.data || e.data == ""){ 32 | return 33 | } 34 | if(e.data == "nope"){ 35 | document.getElementById("graph").style.display = "none" 36 | document.getElementById("graph").src = "" 37 | document.getElementById("wrapper").style.height = "auto" 38 | document.getElementById("wrapper").style.overflow = "hidden" 39 | document.getElementById("bottom").style.display = "block" 40 | } else { 41 | document.getElementById("graph").src = e.data 42 | document.getElementById("graph").style.display = "block" 43 | document.getElementById("wrapper").style.height = "auto" 44 | document.getElementById("wrapper").style.overflow = "hidden" 45 | document.getElementById("bottom").style.display = "block" 46 | } 47 | }) 48 | 49 | function addStyle(elem, stylename){ 50 | styles = ["monospace", "serif", "sans", "latex", "gyre"] 51 | for(i=0;i350){ 176 | adjusted+=350 177 | } 178 | } 179 | document.getElementById("field").style.top = adjusted+"px" 180 | }); 181 | } 182 | -------------------------------------------------------------------------------- /piwrite/static/monoid-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/monoid-bold.ttf -------------------------------------------------------------------------------- /piwrite/static/monoid-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/monoid-regular.ttf -------------------------------------------------------------------------------- /piwrite/static/socket.io.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Socket.IO v4.6.0 3 | * (c) 2014-2023 Guillermo Rauch 4 | * Released under the MIT License. 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).io=e()}(this,(function(){"use strict";function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}function e(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function n(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){a=!0,o=t},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var m=Object.create(null);m.open="0",m.close="1",m.ping="2",m.pong="3",m.message="4",m.upgrade="5",m.noop="6";var k=Object.create(null);Object.keys(m).forEach((function(t){k[m[t]]=t}));for(var b={type:"error",data:"parser error"},w="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),_="function"==typeof ArrayBuffer,E=function(t,e,n){var r,i=t.type,o=t.data;return w&&o instanceof Blob?e?n(o):O(o,n):_&&(o instanceof ArrayBuffer||(r=o,"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(r):r&&r.buffer instanceof ArrayBuffer))?e?n(o):O(new Blob([o]),n):n(m[i]+(o||""))},O=function(t,e){var n=new FileReader;return n.onload=function(){var t=n.result.split(",")[1];e("b"+t)},n.readAsDataURL(t)},A="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),T=0;T1?{type:k[n],data:t.substring(1)}:{type:k[n]}:b},S=function(t,e){if(C){var n=function(t){var e,n,r,i,o,s=.75*t.length,a=t.length,c=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var u=new ArrayBuffer(s),h=new Uint8Array(u);for(e=0;e>4,h[c++]=(15&r)<<4|i>>2,h[c++]=(3&i)<<6|63&o;return u}(t);return N(n,e)}return{base64:!0,data:t}},N=function(t,e){return"blob"===e&&t instanceof ArrayBuffer?new Blob([t]):t},x=String.fromCharCode(30);function L(t){if(t)return function(t){for(var e in L.prototype)t[e]=L.prototype[e];return t}(t)}L.prototype.on=L.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this},L.prototype.once=function(t,e){function n(){this.off(t,n),e.apply(this,arguments)}return n.fn=e,this.on(t,n),this},L.prototype.off=L.prototype.removeListener=L.prototype.removeAllListeners=L.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var n,r=this._callbacks["$"+t];if(!r)return this;if(1==arguments.length)return delete this._callbacks["$"+t],this;for(var i=0;i1?e-1:0),r=1;r0);return e}function W(){var t=z(+new Date);return t!==F?(K=0,F=t):t+"."+z(K++)}for(;Y<64;Y++)H[V[Y]]=Y;function $(t){var e="";for(var n in t)t.hasOwnProperty(n)&&(e.length&&(e+="&"),e+=encodeURIComponent(n)+"="+encodeURIComponent(t[n]));return e}function J(t){for(var e={},n=t.split("&"),r=0,i=n.length;r0&&void 0!==arguments[0]?arguments[0]:{};return i(t,{xd:this.xd,xs:this.xs},this.opts),new nt(this.uri(),t)}},{key:"doWrite",value:function(t,e){var n=this,r=this.request({method:"POST",data:t});r.on("success",e),r.on("error",(function(t,e){n.onError("xhr post error",t,e)}))}},{key:"doPoll",value:function(){var t=this,e=this.request();e.on("data",this.onData.bind(this)),e.on("error",(function(e,n){t.onError("xhr poll error",e,n)})),this.pollXhr=e}}]),s}(U),nt=function(t){o(i,t);var n=p(i);function i(t,r){var o;return e(this,i),D(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=t,o.async=!1!==r.async,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var t=this,e=j(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");e.xdomain=!!this.opts.xd,e.xscheme=!!this.opts.xs;var n=this.xhr=new G(e);try{n.open(this.method,this.uri,this.async);try{if(this.opts.extraHeaders)for(var r in n.setDisableHeaderCheck&&n.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(r)&&n.setRequestHeader(r,this.opts.extraHeaders[r])}catch(t){}if("POST"===this.method)try{n.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{n.setRequestHeader("Accept","*/*")}catch(t){}"withCredentials"in n&&(n.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(n.timeout=this.opts.requestTimeout),n.onreadystatechange=function(){4===n.readyState&&(200===n.status||1223===n.status?t.onLoad():t.setTimeoutFn((function(){t.onError("number"==typeof n.status?n.status:0)}),0))},n.send(this.data)}catch(e){return void this.setTimeoutFn((function(){t.onError(e)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(t){this.emitReserved("error",t,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(t){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=Z,t)try{this.xhr.abort()}catch(t){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var t=this.xhr.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(L);if(nt.requestsCount=0,nt.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",rt);else if("function"==typeof addEventListener){addEventListener("onpagehide"in P?"pagehide":"unload",rt,!1)}function rt(){for(var t in nt.requests)nt.requests.hasOwnProperty(t)&&nt.requests[t].abort()}var it="function"==typeof Promise&&"function"==typeof Promise.resolve?function(t){return Promise.resolve().then(t)}:function(t,e){return e(t,0)},ot=P.WebSocket||P.MozWebSocket,st="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),at=function(t){o(i,t);var n=p(i);function i(t){var r;return e(this,i),(r=n.call(this,t)).supportsBinary=!t.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var t=this.uri(),e=this.opts.protocols,n=st?{}:j(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=st?new ot(t,e,n):e?new ot(t,e):new ot(t)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType||"arraybuffer",this.addEventListeners()}}},{key:"addEventListeners",value:function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws._socket.unref(),t.onOpen()},this.ws.onclose=function(e){return t.onClose({description:"websocket connection closed",context:e})},this.ws.onmessage=function(e){return t.onData(e.data)},this.ws.onerror=function(e){return t.onError("websocket error",e)}}},{key:"write",value:function(t){var e=this;this.writable=!1;for(var n=function(n){var r=t[n],i=n===t.length-1;E(r,e.supportsBinary,(function(t){try{e.ws.send(t)}catch(t){}i&&it((function(){e.writable=!0,e.emitReserved("drain")}),e.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return e(this,a),(r=s.call(this)).writeBuffer=[],n&&"object"===t(n)&&(o=n,n=null),n?(n=ft(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=ft(o.host).host),D(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!0},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=J(r.opts.query)),r.id=null,r.upgrades=null,r.pingInterval=null,r.pingTimeout=null,r.pingTimeoutTimer=null,"function"==typeof addEventListener&&(r.opts.closeOnBeforeunload&&(r.beforeunloadEventListener=function(){r.transport&&(r.transport.removeAllListeners(),r.transport.close())},addEventListener("beforeunload",r.beforeunloadEventListener,!1)),"localhost"!==r.hostname&&(r.offlineEventListener=function(){r.onClose("transport close",{description:"network connection lost"})},addEventListener("offline",r.offlineEventListener,!1))),r.open(),r}return r(a,[{key:"createTransport",value:function(t){var e=i({},this.opts.query);e.EIO=4,e.transport=t,this.id&&(e.sid=this.id);var n=i({},this.opts.transportOptions[t],this.opts,{query:e,socket:this,hostname:this.hostname,secure:this.secure,port:this.port});return new ct[t](n)}},{key:"open",value:function(){var t,e=this;if(this.opts.rememberUpgrade&&a.priorWebsocketSuccess&&-1!==this.transports.indexOf("websocket"))t="websocket";else{if(0===this.transports.length)return void this.setTimeoutFn((function(){e.emitReserved("error","No transports available")}),0);t=this.transports[0]}this.readyState="opening";try{t=this.createTransport(t)}catch(t){return this.transports.shift(),void this.open()}t.open(),this.setTransport(t)}},{key:"setTransport",value:function(t){var e=this;this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",this.onDrain.bind(this)).on("packet",this.onPacket.bind(this)).on("error",this.onError.bind(this)).on("close",(function(t){return e.onClose("transport close",t)}))}},{key:"probe",value:function(t){var e=this,n=this.createTransport(t),r=!1;a.priorWebsocketSuccess=!1;var i=function(){r||(n.send([{type:"ping",data:"probe"}]),n.once("packet",(function(t){if(!r)if("pong"===t.type&&"probe"===t.data){if(e.upgrading=!0,e.emitReserved("upgrading",n),!n)return;a.priorWebsocketSuccess="websocket"===n.name,e.transport.pause((function(){r||"closed"!==e.readyState&&(f(),e.setTransport(n),n.send([{type:"upgrade"}]),e.emitReserved("upgrade",n),n=null,e.upgrading=!1,e.flush())}))}else{var i=new Error("probe error");i.transport=n.name,e.emitReserved("upgradeError",i)}})))};function o(){r||(r=!0,f(),n.close(),n=null)}var s=function(t){var r=new Error("probe error: "+t);r.transport=n.name,o(),e.emitReserved("upgradeError",r)};function c(){s("transport closed")}function u(){s("socket closed")}function h(t){n&&t.name!==n.name&&o()}var f=function(){n.removeListener("open",i),n.removeListener("error",s),n.removeListener("close",c),e.off("close",u),e.off("upgrading",h)};n.once("open",i),n.once("error",s),n.once("close",c),this.once("close",u),this.once("upgrading",h),n.open()}},{key:"onOpen",value:function(){if(this.readyState="open",a.priorWebsocketSuccess="websocket"===this.transport.name,this.emitReserved("open"),this.flush(),"open"===this.readyState&&this.opts.upgrade)for(var t=0,e=this.upgrades.length;t1))return this.writeBuffer;for(var t,e=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(t):Math.ceil(1.33*(t.byteLength||t.size))),n>0&&e>this.maxPayload)return this.writeBuffer.slice(0,n);e+=2}return this.writeBuffer}},{key:"write",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"send",value:function(t,e,n){return this.sendPacket("message",t,e,n),this}},{key:"sendPacket",value:function(t,e,n,r){if("function"==typeof e&&(r=e,e=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:t,data:e,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var t=this,e=function(){t.onClose("forced close"),t.transport.close()},n=function n(){t.off("upgrade",n),t.off("upgradeError",n),e()},r=function(){t.once("upgrade",n),t.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():e()})):this.upgrading?r():e()),this}},{key:"onError",value:function(t){a.priorWebsocketSuccess=!1,this.emitReserved("error",t),this.onClose("transport error",t)}},{key:"onClose",value:function(t,e){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",t,e),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(t){for(var e=[],n=0,r=t.length;n=0&&e.num0;case Et.ACK:case Et.BINARY_ACK:return Array.isArray(n)}}}]),a}(L),Rt=function(){function t(n){e(this,t),this.packet=n,this.buffers=[],this.reconPack=n}return r(t,[{key:"takeBinaryData",value:function(t){if(this.buffers.push(t),this.buffers.length===this.reconPack.attachments){var e=wt(this.reconPack,this.buffers);return this.finishedReconstruction(),e}return null}},{key:"finishedReconstruction",value:function(){this.reconPack=null,this.buffers=[]}}]),t}(),Tt=Object.freeze({__proto__:null,protocol:5,get PacketType(){return Et},Encoder:Ot,Decoder:At});function Ct(t,e,n){return t.on(e,n),function(){t.off(e,n)}}var Bt=Object.freeze({connect:1,connect_error:1,disconnect:1,disconnecting:1,newListener:1,removeListener:1}),St=function(t){o(a,t);var n=p(a);function a(t,r,o){var s;return e(this,a),(s=n.call(this)).connected=!1,s.recovered=!1,s.receiveBuffer=[],s.sendBuffer=[],s._queue=[],s.ids=0,s.acks={},s.flags={},s.io=t,s.nsp=r,o&&o.auth&&(s.auth=o.auth),s._opts=i({},o),s.io._autoConnect&&s.open(),s}return r(a,[{key:"disconnected",get:function(){return!this.connected}},{key:"subEvents",value:function(){if(!this.subs){var t=this.io;this.subs=[Ct(t,"open",this.onopen.bind(this)),Ct(t,"packet",this.onpacket.bind(this)),Ct(t,"error",this.onerror.bind(this)),Ct(t,"close",this.onclose.bind(this))]}}},{key:"active",get:function(){return!!this.subs}},{key:"connect",value:function(){return this.connected||(this.subEvents(),this.io._reconnecting||this.io.open(),"open"===this.io._readyState&&this.onopen()),this}},{key:"open",value:function(){return this.connect()}},{key:"send",value:function(){for(var t=arguments.length,e=new Array(t),n=0;n1?e-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),e&&e(t));else if(n._queue.shift(),e){for(var o=arguments.length,s=new Array(o>1?o-1:0),a=1;a0&&t.jitter<=1?t.jitter:0,this.attempts=0}Nt.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-n:t+n}return 0|Math.min(t,this.max)},Nt.prototype.reset=function(){this.attempts=0},Nt.prototype.setMin=function(t){this.ms=t},Nt.prototype.setMax=function(t){this.max=t},Nt.prototype.setJitter=function(t){this.jitter=t};var xt=function(n){o(s,n);var i=p(s);function s(n,r){var o,a;e(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===t(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,D(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new Nt({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var c=r.parser||Tt;return o.encoder=new c.Encoder,o.decoder=new c.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(t){return arguments.length?(this._reconnection=!!t,this):this._reconnection}},{key:"reconnectionAttempts",value:function(t){return void 0===t?this._reconnectionAttempts:(this._reconnectionAttempts=t,this)}},{key:"reconnectionDelay",value:function(t){var e;return void 0===t?this._reconnectionDelay:(this._reconnectionDelay=t,null===(e=this.backoff)||void 0===e||e.setMin(t),this)}},{key:"randomizationFactor",value:function(t){var e;return void 0===t?this._randomizationFactor:(this._randomizationFactor=t,null===(e=this.backoff)||void 0===e||e.setJitter(t),this)}},{key:"reconnectionDelayMax",value:function(t){var e;return void 0===t?this._reconnectionDelayMax:(this._reconnectionDelayMax=t,null===(e=this.backoff)||void 0===e||e.setMax(t),this)}},{key:"timeout",value:function(t){return arguments.length?(this._timeout=t,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(t){var e=this;if(~this._readyState.indexOf("open"))return this;this.engine=new lt(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=Ct(n,"open",(function(){r.onopen(),t&&t()})),o=Ct(n,"error",(function(n){r.cleanup(),r._readyState="closed",e.emitReserved("error",n),t?t(n):r.maybeReconnectOnOpen()}));if(!1!==this._timeout){var s=this._timeout;0===s&&i();var a=this.setTimeoutFn((function(){i(),n.close(),n.emit("error",new Error("timeout"))}),s);this.opts.autoUnref&&a.unref(),this.subs.push((function(){clearTimeout(a)}))}return this.subs.push(i),this.subs.push(o),this}},{key:"connect",value:function(t){return this.open(t)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var t=this.engine;this.subs.push(Ct(t,"ping",this.onping.bind(this)),Ct(t,"data",this.ondata.bind(this)),Ct(t,"error",this.onerror.bind(this)),Ct(t,"close",this.onclose.bind(this)),Ct(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}}},{key:"ondecoded",value:function(t){var e=this;it((function(){e.emitReserved("packet",t)}),this.setTimeoutFn)}},{key:"onerror",value:function(t){this.emitReserved("error",t)}},{key:"socket",value:function(t,e){var n=this.nsps[t];return n||(n=new St(this,t,e),this.nsps[t]=n),this._autoConnect&&n.connect(),n}},{key:"_destroy",value:function(t){for(var e=0,n=Object.keys(this.nsps);e=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){e.skipReconnect||(t.emitReserved("reconnect_attempt",e.backoff.attempts),e.skipReconnect||e.open((function(n){n?(e._reconnecting=!1,e.reconnect(),t.emitReserved("reconnect_error",n)):e.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){clearTimeout(r)}))}}},{key:"onreconnect",value:function(){var t=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",t)}}]),s}(L),Lt={};function Pt(e,n){"object"===t(e)&&(n=e,e=void 0);var r,i=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=t;n=n||"undefined"!=typeof location&&location,null==t&&(t=n.protocol+"//"+n.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?n.protocol+t:n.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==n?n.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+e,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(e,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,c=Lt[s]&&a in Lt[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||c?r=new xt(o,n):(Lt[s]||(Lt[s]=new xt(o,n)),r=Lt[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Pt,{Manager:xt,Socket:St,io:Pt,connect:Pt}),Pt})); 7 | //# sourceMappingURL=socket.io.min.js.map 8 | -------------------------------------------------------------------------------- /piwrite/static/texgyreheros-bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/texgyreheros-bold.otf -------------------------------------------------------------------------------- /piwrite/static/texgyreheros-italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/texgyreheros-italic.otf -------------------------------------------------------------------------------- /piwrite/static/texgyreheros-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/piwrite/static/texgyreheros-regular.otf -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "piwrite" 3 | version = "0.1.0" 4 | description = "TBD" 5 | authors = ["Ruben Berenguel Montoro "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/rberenguel/PiWrite" 9 | include = ["piwrite/static", "piwrite/static/*"] 10 | 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | prompt-toolkit = "^3.0.39" 15 | aiohttp = "^3.8.5" 16 | python-socketio = "^5.9.0" 17 | colorlog = "^6.7.0" 18 | importlib-resources = "^6.1.0" 19 | py-readability-metrics = "^1.4.5" 20 | proselint = "^0.13.0" 21 | inky = {extras = ["rpi"], version = "^1.5.0"} 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | pytest = "^7.4.2" 25 | mypy = "^1.5.1" 26 | black = "^23.9.1" 27 | isort = "^5.12.0" 28 | 29 | [tool.poetry.scripts] 30 | piwrite = 'piwrite.server:start' 31 | 32 | [build-system] 33 | requires = ["poetry-core"] 34 | build-backend = "poetry.core.masonry.api" 35 | 36 | [tool.mypy] 37 | ignore_missing_imports = true -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rberenguel/PiWrite/5f1d21967142c9fd50ce2a7d49c66496f82a3280/test/__init__.py -------------------------------------------------------------------------------- /test/test_cmaps_helper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import piwrite.cmaps_helper as ch 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "line,expected", 8 | [ 9 | ("a -> b", True), 10 | ("arstartsarst", False), 11 | ("a-> b", True), 12 | ("a ->b", True), 13 | ("a->b", True), 14 | ], 15 | ) 16 | def test_has_arrow(line, expected): 17 | assert ch.has_arrow(line) == expected 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "line,expected", 22 | [ 23 | ("subgraph cluster_foo{", True), 24 | ("aarstarst", False), 25 | ], 26 | ) 27 | def test_has_subgraph(line, expected): 28 | assert ch.has_subgraph(line) == expected 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "line,expected", 33 | [ 34 | ("cluster foo{", True), 35 | ("aarstarst", False), 36 | ], 37 | ) 38 | def test_has_cluster(line, expected): 39 | assert ch.has_cluster(line) == expected 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "line,expected", 44 | [ 45 | ("$foo = bar", True), 46 | ("$foo=bar", True), 47 | ("$foo =bar", True), 48 | ("$foo= bar", True), 49 | ("foo=bar", False), 50 | ("$foo bar", False), 51 | ], 52 | ) 53 | def test_has_replacement(line, expected): 54 | assert ch.has_replacement(line) == expected 55 | 56 | 57 | @pytest.mark.parametrize( 58 | "line,expected", 59 | [ 60 | ("$foo = bar", ("$foo", "bar")), 61 | ("$foo=bar", ("$foo", "bar")), 62 | ("$foo =bar", ("$foo", "bar")), 63 | ("$foo= bar", ("$foo", "bar")), 64 | ], 65 | ) 66 | def test_get_replacement(line, expected): 67 | assert ch.get_replacement(line) == expected 68 | 69 | 70 | @pytest.mark.parametrize( 71 | "line,expected", 72 | [ 73 | ("// foo", True), 74 | ("/// foo", True), 75 | ("//foo", True), 76 | ("/ foo", False), 77 | ], 78 | ) 79 | def test_is_comment(line, expected): 80 | assert ch.is_comment(line) == expected 81 | 82 | 83 | @pytest.mark.parametrize( 84 | "line,expected", 85 | [ 86 | ("{", True), 87 | (" { ", True), 88 | ("}", True), 89 | (" } ", True), 90 | (" }}", False), 91 | (" {{ ", False), 92 | (" }a", False), 93 | (" b{ ", False), 94 | ], 95 | ) 96 | def test_is_only_brace(line, expected): 97 | assert ch.is_only_brace(line) == expected 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "line,expected", 102 | [ 103 | ("foo=bar", True), 104 | ("foo = bar", False), 105 | (" = bar", False), 106 | ], 107 | ) 108 | def test_is_attr(line, expected): 109 | assert ch.is_attr(line) == expected 110 | 111 | 112 | @pytest.mark.parametrize( 113 | "line,expected", 114 | [ 115 | ("zzz -> arstd label thingy;color=red", "label thingy;color=red"), 116 | ("zzz ->arstd label thingy; color=red", "label thingy; color=red"), 117 | ("zzz-> arstd label thingy; color=red ", "label thingy; color=red "), 118 | ("zzz -> arstd ;color=red", ";color=red"), 119 | ("zzz -> arstd ", ""), 120 | ], 121 | ) 122 | def test_get_attrs_of_arrow(line, expected): 123 | assert ch.get_attrs_of_arrow(line) == expected 124 | 125 | 126 | @pytest.mark.parametrize( 127 | "line,expected", 128 | [ 129 | ("zzzz The zs; foo", "The zs; foo"), 130 | ("zzzz The zs; ", "The zs; "), 131 | ("zzzz The zs", "The zs"), 132 | ], 133 | ) 134 | def test_get_attrs_of_node(line, expected): 135 | assert ch.get_attrs_of_node(line) == expected 136 | 137 | 138 | @pytest.mark.parametrize( 139 | "line,expected", 140 | [ 141 | ("subgraph cluster_foo {", "foo"), 142 | ("subgraph cluster_foo { arstarstarst", "foo"), 143 | ("subgraph cluster_foo{", "foo"), 144 | ], 145 | ) 146 | def test_get_subgraph_cluster_name(line, expected): 147 | assert ch.get_subgraph_cluster_name(line) == expected 148 | 149 | 150 | @pytest.mark.parametrize( 151 | "line,expected", 152 | [ 153 | ("cluster foo {", "foo"), 154 | (" cluster foo { arstarstarst", "foo"), 155 | ("cluster foo{", "foo"), 156 | ], 157 | ) 158 | def test_get_cluster_name(line, expected): 159 | assert ch.get_cluster_name(line) == expected 160 | 161 | 162 | @pytest.mark.parametrize( 163 | "line,expected", 164 | [ 165 | ( 166 | "a very very long long long label label label", 167 | "a very very long long long label\\llabel label", 168 | ), 169 | ("short one", "short one"), 170 | ], 171 | ) 172 | def test_label_breaker(line, expected): 173 | assert ch.label_breaker(line) == expected 174 | -------------------------------------------------------------------------------- /test/test_editor.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import pytest 4 | from prompt_toolkit.key_binding.key_processor import KeyPress 5 | from prompt_toolkit.keys import Keys 6 | 7 | import piwrite.editor as editor 8 | 9 | LETTER_I = KeyPress("i") 10 | ESC = Keys.Escape 11 | DEL = Keys.ControlH 12 | ENT = Keys.ControlM 13 | LEFT = Keys.Left 14 | 15 | 16 | def K(letter): 17 | return KeyPress(letter) 18 | 19 | 20 | def test_insertion(): 21 | v = editor.Editor() 22 | v.send(["if"]) 23 | assert v.buffer.get()[0] == "f" 24 | 25 | 26 | def test_save(): 27 | v = editor.Editor() 28 | filename = "foo" 29 | text = "text" 30 | command = ["i", text, ESC, ":w ", filename, ENT] 31 | v.send(command) 32 | assert v.mode() == str(editor.Mode.NORMAL) 33 | docs = v.docs 34 | fil = docs / filename 35 | written_text = fil.read_text() 36 | assert written_text == text 37 | assert v.filename == filename 38 | fil.unlink() 39 | 40 | 41 | def test_read(): 42 | v = editor.Editor() 43 | filename = "foo" 44 | text = "text" 45 | command = ["i", text, ESC, ":w ", filename, ENT] 46 | v.send(command) 47 | w = editor.Editor() 48 | command2 = ["it", ESC, ":e ", filename, ENT] 49 | w.send(command) 50 | assert w.buffer.get()[0] == "text" 51 | docs = w.docs 52 | fil = docs / "foo" 53 | fil.unlink() 54 | 55 | 56 | def test_left(): 57 | v = editor.Editor() 58 | cmd = ["if"] 59 | v.send(cmd) 60 | assert v.buffer.get()[0] == "f" 61 | cmd = [LEFT, "g"] 62 | v.send(cmd) 63 | assert v.buffer.get()[0] == "gf" 64 | v.send([ESC, LEFT, "ih"]) 65 | assert v.buffer.get()[0] == "hgf" 66 | 67 | 68 | def test_right(): 69 | v = editor.Editor() 70 | cmd = ["i2"] 71 | v.send(cmd) 72 | assert v.buffer.get()[0] == "2" 73 | cmd = [LEFT, "1", Keys.Right, "3"] 74 | v.send(cmd) 75 | assert v.buffer.get()[0] == "123" 76 | v.send([ESC, Keys.ControlA, Keys.Right, "i."]) 77 | assert v.buffer.get()[0] == ".123" 78 | 79 | 80 | def test_up(): 81 | v = editor.Editor() 82 | cmd = ["i1"] 83 | v.send(cmd) 84 | assert v.buffer.get()[0] == "1" 85 | cmd = [ENT, "2"] 86 | v.send(cmd) 87 | assert v.buffer.get()[1] == "2" 88 | cmd = [Keys.Up, "3"] 89 | v.send(cmd) 90 | assert v.buffer.get()[0] == "13" 91 | cmd = [ENT, "4", ESC, Keys.Up] 92 | v.send(cmd) 93 | assert v.mode() == str(editor.Mode.NORMAL) 94 | cmd = ["i5"] 95 | v.send(cmd) 96 | assert ( 97 | v.buffer.get()[0] == "513" 98 | ) # Remember ESC switches cursor one position back from insertion 99 | 100 | 101 | def test_new_lines(): 102 | v = editor.Editor() 103 | cmd = ["i1"] 104 | v.send(cmd) 105 | assert v.buffer.get()[0] == "1" 106 | cmd = [ENT, "2"] 107 | v.send(cmd) 108 | assert v.buffer.get() == ["1", "2"] 109 | 110 | 111 | def test_line_break(): 112 | v = editor.Editor() 113 | cmd = ["i12", LEFT, ENT] 114 | v.send(cmd) 115 | assert v.buffer.get() == ["1", "2"] 116 | 117 | 118 | def test_indent_ish(): 119 | v = editor.Editor() 120 | cmd = ["i b", ENT, "a"] 121 | v.send(cmd) 122 | s = v.buffer.get()[1] 123 | assert v.buffer.get()[0] == " b" 124 | assert s == " a" 125 | 126 | 127 | def test_deletion(): 128 | v = editor.Editor() 129 | cmd = ["i123", DEL] 130 | v.send(cmd) 131 | assert v.buffer.get()[0] == "12" 132 | 133 | 134 | def test_deletion_of_beginning(): 135 | v = editor.Editor() 136 | cmd = ["i123", ESC, "o456", Keys.ControlA, DEL] 137 | v.send(cmd) 138 | assert v.buffer.get()[0] == "123456" 139 | assert len(v.buffer) == 1 140 | 141 | 142 | def test_dd(): 143 | v = editor.Editor() 144 | cmd = ["i123", ESC, "ddi1"] 145 | v.send(cmd) 146 | assert v.buffer.get()[0] == "1" 147 | 148 | v = editor.Editor() 149 | cmd = ["i123", ENT, "456", Keys.Up, ESC, "dd"] 150 | v.send(cmd) 151 | assert v.buffer.get()[0] == "456" 152 | 153 | 154 | def test_ddollar(): 155 | v = editor.Editor() 156 | cmd = ["i123", LEFT, LEFT, ESC, "d$i1"] 157 | v.send(cmd) 158 | assert v.buffer.get()[0] == "1" 159 | 160 | v = editor.Editor() 161 | cmd = ["i123", ENT, "456", Keys.Up, ESC, "d$"] 162 | v.send(cmd) 163 | assert v.buffer.get()[0] == "12" 164 | assert v.buffer.get()[1] == "456" 165 | 166 | 167 | def test_cdollar(): 168 | v = editor.Editor() 169 | cmd = ["i123", LEFT, LEFT, ESC, "c$1"] 170 | v.send(cmd) 171 | assert v.buffer.get()[0] == "1" 172 | 173 | v = editor.Editor() 174 | cmd = ["i123", ENT, "456", Keys.Up, ESC, "c$3"] 175 | v.send(cmd) 176 | assert v.buffer.get()[0] == "123" 177 | assert v.buffer.get()[1] == "456" 178 | 179 | 180 | @pytest.mark.parametrize( 181 | "text,deleted,yanked", 182 | [ 183 | (["a word", ESC], "a", " word"), 184 | (["a word ", LEFT, LEFT, ESC], "a ", "word "), 185 | (["a word ", LEFT, LEFT, ESC], "a ", "word "), 186 | (["a wo ", LEFT, LEFT, LEFT, LEFT, ESC], "wo ", "a "), 187 | ], 188 | ) 189 | def test_delete_around_word(text, deleted, yanked): 190 | v = editor.Editor() 191 | cmd = ["i"] + text + ["daw"] 192 | v.send(cmd) 193 | assert str(v.buffer.get()[0]) == deleted 194 | assert v.yank == [yanked] 195 | 196 | 197 | @pytest.mark.parametrize( 198 | "text,deleted,yanked", 199 | [ 200 | (["a word", ESC], "a ", "word"), 201 | (["a word ", LEFT, LEFT, ESC], "a ", "word"), 202 | (["a word ", LEFT, LEFT, ESC], "a ", "word"), 203 | (["a wo ", LEFT, LEFT, LEFT, LEFT, ESC], " wo ", "a"), 204 | ], 205 | ) 206 | def test_delete_inside_word(text, deleted, yanked): 207 | v = editor.Editor() 208 | cmd = ["i"] + text + ["diw"] 209 | v.send(cmd) 210 | assert str(v.buffer.get()[0]) == deleted 211 | assert v.yank == [yanked] 212 | 213 | 214 | @pytest.mark.parametrize( 215 | "text,deleted", 216 | [ 217 | (["da word", ESC], "dafoo"), 218 | (["da word ", LEFT, LEFT, LEFT, ESC], "da foo"), 219 | (["da word ", LEFT, LEFT, LEFT, ESC], "da foo"), 220 | (["da wo ", LEFT, LEFT, LEFT, LEFT, ESC], "foowo "), 221 | ], 222 | ) 223 | def test_change_around_word(text, deleted): 224 | v = editor.Editor() 225 | cmd = ["i"] + text + ["cawfoo"] 226 | v.send(cmd) 227 | assert str(v.buffer.get()[0]) == deleted 228 | 229 | 230 | @pytest.mark.parametrize( 231 | "text,deleted", 232 | [ 233 | (["da word", ESC], "da foo"), 234 | (["da word ", LEFT, LEFT, LEFT, ESC], "da foo "), 235 | (["da word ", LEFT, LEFT, LEFT, ESC], "da foo "), 236 | (["da wo ", LEFT, LEFT, LEFT, LEFT, ESC], "foo wo "), 237 | ], 238 | ) 239 | def test_change_inside_word(text, deleted): 240 | v = editor.Editor() 241 | cmd = ["i"] + text + ["ciwfoo"] 242 | v.send(cmd) 243 | assert str(v.buffer.get()[0]) == deleted 244 | 245 | 246 | def test_paste_problem(): 247 | v = editor.Editor() 248 | cmd = ["ihello", ESC, "dawa", ENT, ESC, "p"] 249 | v.send(cmd) 250 | assert str(v.buffer.get()[1]) == "hello" 251 | 252 | 253 | def test_basic_undo(): 254 | v = editor.Editor() 255 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uA4"] 256 | v.send(cmd) 257 | assert str(v.buffer.get()[0]) == "1 2 4" 258 | 259 | 260 | def test_branch_undo(): 261 | v = editor.Editor() 262 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uuA4 ", ESC, "A5"] 263 | v.send(cmd) 264 | assert str(v.buffer.get()[0]) == "1 4 5" 265 | 266 | 267 | def test_redo(): 268 | v = editor.Editor() 269 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uu", Keys.ControlR] 270 | v.send(cmd) 271 | assert str(v.buffer.get()[0]) == "1 2 " 272 | 273 | 274 | def test_redo_and_undo(): 275 | v = editor.Editor() 276 | # 1 2 3 277 | # 1 278 | # 1 2 279 | # 1 2 4 280 | # 1 2 281 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uu", Keys.ControlR, "A4", ESC, "u"] 282 | v.send(cmd) 283 | assert str(v.buffer.get()[0]) == "1 2 " 284 | 285 | 286 | def test_undo_depth(): 287 | v = editor.Editor() 288 | v.UNDO_DEPTH = 2 289 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uA4"] 290 | v.send(cmd) 291 | assert str(v.buffer.get()[0]) == "1 2 4" 292 | 293 | 294 | def test_redo_and_undo_with_depth(): 295 | v = editor.Editor() 296 | v.UNDO_DEPTH = 2 297 | # 1 2 3 298 | # 1 2 (just one depth) 299 | # 1 2 3 (redo) 300 | # 1 2 3 4 301 | # 1 2 3 (u) 302 | cmd = ["i1 ", ESC, "A2 ", ESC, "A3 ", ESC, "uu", Keys.ControlR, "A4", ESC, "u"] 303 | v.send(cmd) 304 | assert str(v.buffer.get()[0]) == "1 2 3 " 305 | 306 | 307 | def test_redo_limit(): 308 | v = editor.Editor() 309 | v.send([Keys.ControlR, Keys.ControlR]) 310 | 311 | 312 | def test_undo_limit(): 313 | v = editor.Editor() 314 | v.send(["uuuu"]) 315 | 316 | 317 | def test_basic_paste(): 318 | v = editor.Editor() 319 | cmd = ["ia word", ESC, "diwa", ENT, ESC, "p"] 320 | v.send(cmd) 321 | assert str(v.buffer.get()[1]) == "word" 322 | -------------------------------------------------------------------------------- /test/test_markdownify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from piwrite.markdownify import markdownify 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "text,converted", 8 | [ 9 | ("# foo", "# foo"), 10 | ("## foo", "## foo"), 11 | ("### foo", "### foo"), 12 | ("#### foo", "#### foo"), 13 | ], 14 | ) 15 | def test_headers(text, converted): 16 | assert markdownify([text])[0] == converted 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "text,converted", 21 | [ 22 | ("**foo**", "**foo**"), 23 | ("**foo**.", "**foo**."), 24 | ("**foo**?", "**foo**?"), 25 | ('**foo**"', '**foo**"'), 26 | ("**foo**'", "**foo**'"), 27 | ("**foo**. aaa", "**foo**. aaa"), 28 | (" **foo**", " **foo**"), 29 | (" **foo** ", " **foo** "), 30 | ("**foo** ", "**foo** "), 31 | ("**foo**: ", "**foo**: "), 32 | (" **foo bar** ", " **foo bar** "), 33 | ], 34 | ) 35 | def test_bold(text, converted): 36 | assert markdownify([text])[0] == converted 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "text,converted", 41 | [ 42 | ("**foo**", "foo"), 43 | ("**foo**.", "foo."), 44 | ('**foo**"', 'foo"'), 45 | ("**foo**'", "foo'"), 46 | ("**foo**?", "foo?"), 47 | ("**foo**. aaa", "foo. aaa"), 48 | (" **foo**", " foo"), 49 | (" **foo** ", " foo "), 50 | ("**foo** ", "foo "), 51 | ("**foo**: ", "foo: "), 52 | (" **foo bar** ", " foo bar "), 53 | ], 54 | ) 55 | def test_bold_hidden(text, converted): 56 | assert markdownify([text], visible=False)[0] == converted 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "text,converted", 61 | [ 62 | ("_foo_", "_foo_"), 63 | ("_foo_.", "_foo_."), 64 | ("_foo_?", "_foo_?"), 65 | ('_foo_"', '_foo_"'), 66 | ("_foo_'", "_foo_'"), 67 | ("_foo_. aaa", "_foo_. aaa"), 68 | (" _foo_", " _foo_"), 69 | (" _foo_ ", " _foo_ "), 70 | ("_foo_ ", "_foo_ "), 71 | ("_foo_: ", "_foo_: "), 72 | (" _foo bar_ ", " _foo bar_ "), 73 | ], 74 | ) 75 | def test_italics(text, converted): 76 | assert markdownify([text])[0] == converted 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "text,converted", 81 | [ 82 | ("_foo_", "foo"), 83 | ("_foo_.", "foo."), 84 | ("_foo_. aaa", "foo. aaa"), 85 | (" _foo_", " foo"), 86 | (" _foo_ ", " foo "), 87 | ("_foo_ ", "foo "), 88 | ("_foo_: ", "foo: "), 89 | (" _foo bar_ ", " foo bar "), 90 | ], 91 | ) 92 | def test_italics_hidden(text, converted): 93 | assert markdownify([text], visible=False)[0] == converted 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "text,converted", 98 | [ 99 | ("`foo`", "`foo`"), 100 | ("`foo`.", "`foo`."), 101 | ("`foo`. aaa", "`foo`. aaa"), 102 | (" `foo`", " `foo`"), 103 | (" `foo` ", " `foo` "), 104 | ("`foo` ", "`foo` "), 105 | ("`foo`: ", "`foo`: "), 106 | (" `foo bar` ", " `foo bar` "), 107 | ], 108 | ) 109 | def test_tt(text, converted): 110 | assert markdownify([text])[0] == converted 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "text,converted", 115 | [ 116 | ("`foo`", "foo"), 117 | ("`foo`.", "foo."), 118 | ("`foo`. aaa", "foo. aaa"), 119 | (" `foo`", " foo"), 120 | (" `foo` ", " foo "), 121 | ("`foo` ", "foo "), 122 | ("`foo`: ", "foo: "), 123 | (" `foo bar` ", " foo bar "), 124 | ], 125 | ) 126 | def test_tt_hidden(text, converted): 127 | assert markdownify([text], visible=False)[0] == converted 128 | 129 | 130 | @pytest.mark.parametrize( 131 | "text,converted", 132 | [ 133 | ("_f_oo_", "_f_oo_"), 134 | (" _f_oo_", " _f_oo_"), 135 | (" _f_oo_ ", " _f_oo_ "), 136 | ("_f_oo_ ", "_f_oo_ "), 137 | (" _f_oo bar_ ", " _f_oo bar_ "), 138 | ], 139 | ) 140 | def test_escaped_underscore(text, converted): 141 | assert markdownify([text])[0] == converted 142 | --------------------------------------------------------------------------------
Press any key to trigger an update (or Ctrl-q to force a server refresh), and press :h(return) to get help