Merge pull request #2727 from FoamyGuy/add_text_editor

Adding Text editor project code
This commit is contained in:
Anne Barela 2024-02-08 17:33:38 -05:00 committed by GitHub
commit ae08e5704c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 632 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT

View file

@ -0,0 +1,124 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import select
import sys
# pylint: disable=no-self-use
try:
import termios
_orig_attr = None # pylint: disable=invalid-name
def _nonblocking():
global _orig_attr # pylint: disable=global-statement
_orig_attr = termios.tcgetattr(sys.stdin)
attr = termios.tcgetattr(sys.stdin)
attr[3] &= ~(termios.ECHO | termios.ICANON)
attr[6][termios.VMIN] = 1
attr[6][termios.VTIME] = 0
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, attr)
def _blocking():
if _orig_attr is not None:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, _orig_attr)
except ImportError:
def _nonblocking():
pass
def _blocking():
pass
LINES = 24
COLS = 80
special_keys = {
"\x1b": ..., # all prefixes of special keys must be entered as Ellipsis
"\x1b[": ...,
"\x1b[5": ...,
"\x1b[6": ...,
"\x1b[A": "KEY_UP",
"\x1b[B": "KEY_DOWN",
"\x1b[C": "KEY_RIGHT",
"\x1b[D": "KEY_LEFT",
"\x1b[H": "KEY_HOME",
"\x1b[F": "KEY_END",
"\x1b[5~": "KEY_PGUP",
"\x1b[6~": "KEY_PGDN",
"\x1b[3~": "KEY_DELETE",
}
class Screen:
def __init__(self):
self._poll = select.poll()
self._poll.register(sys.stdin, select.POLLIN)
self._pending = ""
def _sys_stdin_readable(self):
return hasattr(sys.stdin, "readable") and sys.stdin.readable()
def _sys_stdout_flush(self):
if hasattr(sys.stdout, "flush"):
sys.stdout.flush()
def _terminal_read_blocking(self):
return sys.stdin.read(1)
def _terminal_read_timeout(self, timeout):
if self._sys_stdin_readable() or self._poll.poll(timeout):
r = sys.stdin.read(1)
return r
return None
def move(self, y, x):
print(end=f"\033[{y+1};{x+1}H")
def erase(self):
print(end="\033H\033[2J")
def addstr(self, y, x, text):
self.move(y, x)
print(end=text)
def getkey(self):
self._sys_stdout_flush()
pending = self._pending
if pending and (code := special_keys.get(pending)) is None:
self._pending = pending[1:]
return pending[0]
while True:
if pending:
c = self._terminal_read_timeout(50)
if c is None:
self._pending = pending[1:]
return pending[0]
else:
c = self._terminal_read_blocking()
c = pending + c
code = special_keys.get(c)
if code is None:
self._pending = c[1:]
return c[0]
if code is not Ellipsis:
return code
pending = c
def wrapper(func, *args, **kwds):
stdscr = Screen()
try:
_nonblocking()
return func(stdscr, *args, **kwds)
finally:
_blocking()
stdscr.move(LINES - 1, 0)
print("\n")

View file

@ -0,0 +1,333 @@
# SPDX-FileCopyrightText: 2020 Wasim Lorgat
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries
# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import gc
import os
import usb_cdc
from . import dang as curses
from . import util
#pylint: disable=redefined-builtin
def print(message):
usb_cdc.data.write(f"{message}\r\n".encode("utf-8"))
class MaybeDisableReload:
def __enter__(self):
try:
from supervisor import runtime # pylint: disable=import-outside-toplevel
except ImportError:
return
self._old_autoreload = ( # pylint: disable=attribute-defined-outside-init
runtime.autoreload
)
runtime.autoreload = False
def __exit__(self, exc_type, exc_value, traceback):
try:
from supervisor import runtime # pylint: disable=import-outside-toplevel
except ImportError:
return
runtime.autoreload = self._old_autoreload
def os_exists(filename):
try:
os.stat(filename)
return True
except OSError:
return False
def gc_mem_free_hint():
if hasattr(gc, "mem_free"):
gc.collect()
return f" | free: {gc.mem_free()}"
return ""
class Buffer:
def __init__(self, lines):
self.lines = lines
def __len__(self):
return len(self.lines)
def __getitem__(self, index):
return self.lines[index]
@property
def bottom(self):
return len(self) - 1
def insert(self, cursor, string):
row, col = cursor.row, cursor.col
# print(f"len: {len(self.lines)}")
# print(f"row: {row}")
try:
current = self.lines.pop(row)
except IndexError:
current = ""
new = current[:col] + string + current[col:]
self.lines.insert(row, new)
def split(self, cursor):
row, col = cursor.row, cursor.col
current = self.lines.pop(row)
self.lines.insert(row, current[:col])
self.lines.insert(row + 1, current[col:])
def delete(self, cursor):
row, col = cursor.row, cursor.col
if (row, col) < (self.bottom, len(self[row])):
current = self.lines.pop(row)
if col < len(current):
new = current[:col] + current[col + 1:]
self.lines.insert(row, new)
else:
nextline = self.lines.pop(row)
new = current + nextline
self.lines.insert(row, new)
def clamp(x, lower, upper):
if x < lower:
return lower
if x > upper:
return upper
return x
class Cursor:
def __init__(self, row=0, col=0, col_hint=None):
self.row = row
self._col = col
self._col_hint = col if col_hint is None else col_hint
@property
def col(self):
return self._col
@col.setter
def col(self, col):
self._col = col
self._col_hint = col
def _clamp_col(self, buffer):
self._col = min(self._col_hint, len(buffer[self.row]))
def up(self, buffer): # pylint: disable=invalid-name
if self.row > 0:
self.row -= 1
self._clamp_col(buffer)
# print(f"cursor pos: {self.row}, {self.col}")
def down(self, buffer):
if self.row < len(buffer) - 1:
self.row += 1
self._clamp_col(buffer)
# print(f"cursor pos: {self.row}, {self.col}")
def left(self, buffer):
if self.col > 0:
self.col -= 1
# print(f"cursor pos: {self.row}, {self.col}")
elif self.row > 0:
self.row -= 1
self.col = len(buffer[self.row])
# print(f"cursor pos: {self.row}, {self.col}")
def right(self, buffer):
print(f"len: {len(buffer)}")
if len(buffer) > 0 and self.col < len(buffer[self.row]):
self.col += 1
# print(f"cursor pos: {self.row}, {self.col}")
elif self.row < len(buffer) - 1:
self.row += 1
self.col = 0
# print(f"cursor pos: {self.row}, {self.col}")
def end(self, buffer):
self.col = len(buffer[self.row])
# print(f"cursor pos: {self.row}, {self.col}")
class Window:
def __init__(self, n_rows, n_cols, row=0, col=0):
self.n_rows = n_rows
self.n_cols = n_cols
self.row = row
self.col = col
@property
def bottom(self):
return self.row + self.n_rows - 1
def up(self, cursor): # pylint: disable=invalid-name
if cursor.row == self.row - 1 and self.row > 0:
self.row -= 1
def down(self, buffer, cursor):
if cursor.row == self.bottom + 1 and self.bottom < len(buffer) - 1:
self.row += 1
def horizontal_scroll(self, cursor, left_margin=5, right_margin=2):
n_pages = cursor.col // (self.n_cols - right_margin)
self.col = max(n_pages * self.n_cols - right_margin - left_margin, 0)
def translate(self, cursor):
return cursor.row - self.row, cursor.col - self.col
def left(window, buffer, cursor):
cursor.left(buffer)
window.up(cursor)
window.horizontal_scroll(cursor)
def right(window, buffer, cursor):
cursor.right(buffer)
window.down(buffer, cursor)
window.horizontal_scroll(cursor)
def home(window, buffer, cursor): # pylint: disable=unused-argument
cursor.col = 0
window.horizontal_scroll(cursor)
def end(window, buffer, cursor):
cursor.end(buffer)
window.horizontal_scroll(cursor)
def editor(stdscr, filename, visible_cursor): # pylint: disable=too-many-branches,too-many-statements
if os_exists(filename):
with open(filename, "r", encoding="utf-8") as f:
buffer = Buffer(f.read().splitlines())
else:
buffer = Buffer([""])
window = Window(curses.LINES - 1, curses.COLS - 1)
cursor = Cursor()
# print("updating visible cursor")
visible_cursor.anchored_position = ((0 * 6) - 1, (0 * 12) + 20)
try:
visible_cursor.text = buffer.lines[0][0]
except IndexError:
visible_cursor.text = " "
stdscr.erase()
img = [None] * curses.LINES
def setline(row, line):
if img[row] == line:
return
img[row] = line
line += " " * (window.n_cols - len(line))
stdscr.addstr(row, 0, line)
while True:
lastrow = 0
for row, line in enumerate(buffer[window.row: window.row + window.n_rows]):
lastrow = row
if row == cursor.row - window.row and window.col > 0:
line = "«" + line[window.col + 1:]
if len(line) > window.n_cols:
line = line[: window.n_cols - 1] + "»"
setline(row, line)
for row in range(lastrow + 1, window.n_rows):
setline(row, "~~ EOF ~~")
row = curses.LINES - 1
if util.readonly():
line = f"{filename:12} (readonly) | ^C: quit{gc_mem_free_hint()}"
else:
line = f"{filename:12} | ^X: write & exit | ^C: quit w/o save{gc_mem_free_hint()}"
setline(row, line)
stdscr.move(*window.translate(cursor))
k = stdscr.getkey()
if len(k) == 1 and " " <= k <= "~":
buffer.insert(cursor, k)
for _ in k:
right(window, buffer, cursor)
elif k == "\x18": # ctrl-x
if not util.readonly():
with open(filename, "w", encoding="utf-8") as f:
for row in buffer:
f.write(f"{row}\n")
return
else:
print("Unable to Save due to readonly mode! File Contents:")
print("---- begin file contents ----")
for row in buffer:
print(row)
print("---- end file contents ----")
elif k == "\x11": # Ctrl-Q
print("ctrl-Q")
for row in buffer:
print(row)
elif k == "KEY_HOME":
home(window, buffer, cursor)
elif k == "KEY_END":
end(window, buffer, cursor)
elif k == "KEY_LEFT":
left(window, buffer, cursor)
elif k == "KEY_DOWN":
cursor.down(buffer)
window.down(buffer, cursor)
window.horizontal_scroll(cursor)
elif k == "KEY_PGDN":
for _ in range(window.n_rows):
cursor.down(buffer)
window.down(buffer, cursor)
window.horizontal_scroll(cursor)
elif k == "KEY_UP":
cursor.up(buffer)
window.up(cursor)
window.horizontal_scroll(cursor)
elif k == "KEY_PGUP":
for _ in range(window.n_rows):
cursor.up(buffer)
window.up(cursor)
window.horizontal_scroll(cursor)
elif k == "KEY_RIGHT":
right(window, buffer, cursor)
elif k == "\n":
buffer.split(cursor)
right(window, buffer, cursor)
elif k in ("KEY_DELETE", "\x04"):
print("delete")
buffer.delete(cursor)
elif k in ("KEY_BACKSPACE", "\x7f", "\x08"):
print(f"backspace {bytes(k, 'utf-8')}")
if (cursor.row, cursor.col) > (0, 0):
left(window, buffer, cursor)
buffer.delete(cursor)
else:
print(f"unhandled k: {k}")
print(f"unhandled K: {ord(k)}")
print(f"unhandled k: {bytes(k, 'utf-8')}")
# print("updating visible cursor")
# print(f"anchored pos: {((cursor.col * 6) - 1, (cursor.row * 12) + 20)}")
visible_cursor.anchored_position = ((cursor.col * 6) - 1, (cursor.row * 12) + 20)
try:
visible_cursor.text = buffer.lines[cursor.row][cursor.col]
except IndexError:
visible_cursor.text = " "
def edit(filename, visible_cursor):
with MaybeDisableReload():
return curses.wrapper(editor, filename, visible_cursor)

View file

@ -0,0 +1,98 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries
# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import os
import usb_cdc
from . import dang as curses
from . import util
#pylint: disable=redefined-builtin
def print(message):
usb_cdc.data.write(f"{message}\r\n".encode("utf-8"))
always = ["code.py", "boot.py", "settings.toml", "boot_out.txt"]
good_extensions = [".py", ".toml", ".txt", ".json"]
def os_exists(filename):
try:
os.stat(filename)
return True
except OSError:
return False
def isdir(filename):
return os.stat(filename)[0] & 0o40_000
def has_good_extension(filename):
for g in good_extensions:
if filename.endswith(g):
return True
return False
def picker(stdscr, options, notes=(), start_idx=0):
stdscr.erase()
stdscr.addstr(curses.LINES - 1, 0, "Enter: select | ^C: quit | ^N: New")
del options[curses.LINES - 1:]
for row, option in enumerate(options):
if row < len(notes) and (note := notes[row]):
option = f"{option} {note}"
stdscr.addstr(row, 3, option)
old_idx = None
idx = start_idx
while True:
if idx != old_idx:
if old_idx is not None:
stdscr.addstr(old_idx, 0, " ")
stdscr.addstr(idx, 0, "=>")
old_idx = idx
k = stdscr.getkey()
if k == "KEY_DOWN":
idx = min(idx + 1, len(options) - 1)
elif k == "KEY_UP":
idx = max(idx - 1, 0)
elif k == "\n":
return options[idx]
# ctrl-N
elif k == "\x0E":
if not util.readonly():
new_file_name = new_file(stdscr)
if new_file_name is not None:
return new_file_name
# pylint: disable=inconsistent-return-statements
def new_file(stdscr):
stdscr.erase()
new_file_name = input("New File Name: ")
if os_exists(new_file_name):
print("Error: File Already Exists")
return
with open(new_file_name, "w") as f:
f.write("")
return new_file_name
def pick_file():
options = sorted(
(
g
for g in os.listdir(".")
if g not in always and not isdir(g) and not g.startswith(".")
),
key=lambda filename: (not has_good_extension(filename), filename),
) + always[:]
notes = [None if os_exists(filename) else "(NEW)" for filename in options]
return curses.wrapper(picker, options, notes)

View file

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries
#
# SPDX-License-Identifier: MIT
def readonly():
try:
import storage # pylint: disable=import-outside-toplevel
except ImportError:
return False
return storage.getmount("/").readonly

View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
import usb_cdc
import board
import digitalio
import storage
usb_cdc.enable(console=True, data=True) # Enable console and data
write_mode_btn = digitalio.DigitalInOut(board.D9)
write_mode_btn.direction = digitalio.Direction.INPUT
write_mode_btn.pull = digitalio.Pull.UP
storage.remount("/", readonly=write_mode_btn.value)

View file

@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
import traceback
from adafruit_editor import editor, picker
from adafruit_featherwing import tft_featherwing_35
import terminalio
import displayio
from adafruit_display_text.bitmap_label import Label
import usb_cdc
#pylint: disable=redefined-builtin,broad-except
def print(message):
usb_cdc.data.write(f"{message}\r\n".encode("utf-8"))
tft_featherwing = tft_featherwing_35.TFTFeatherWing35V2()
display = tft_featherwing.display
display.rotation = 180
customized_console_group = displayio.Group()
display.root_group = customized_console_group
customized_console_group.append(displayio.CIRCUITPYTHON_TERMINAL)
visible_cursor = Label(terminalio.FONT, text="",
color=0x000000, background_color=0xeeeeee, padding_left=1)
visible_cursor.hidden = True
visible_cursor.anchor_point = (0, 0)
customized_console_group.append(visible_cursor)
try:
while True:
try:
visible_cursor.hidden = True
filename = picker.pick_file()
except KeyboardInterrupt:
customized_console_group.remove(displayio.CIRCUITPYTHON_TERMINAL)
break
try:
visible_cursor.hidden = False
editor.edit(filename, visible_cursor)
except KeyboardInterrupt:
visible_cursor.hidden = True
# Any Exception, including Keyboard Interrupt
except Exception as e:
print("\n".join(traceback.format_exception(e)))
customized_console_group.remove(displayio.CIRCUITPYTHON_TERMINAL)