Skip to content

Instantly share code, notes, and snippets.

@elteammate
Created April 11, 2022 18:27
Show Gist options
  • Save elteammate/5ddf7f702a8d9f9f9651940ff36485e4 to your computer and use it in GitHub Desktop.
Save elteammate/5ddf7f702a8d9f9f9651940ff36485e4 to your computer and use it in GitHub Desktop.
textarea-emulator
import enum
from copy import copy
from typing import List, Callable, Tuple, Union
import dataclasses
import string
import unicodedata
import functools
class CharCategory(enum.Enum):
LETTER = "L"
PUNCTUATION = "P"
SEPARATOR = "Z"
OTHER = "?"
@staticmethod
def of(c: str):
assert len(c) == 1, "Supplied string does not consist of a single character"
if c in string.punctuation and c != '_':
return CharCategory.PUNCTUATION
elif c in string.whitespace:
return CharCategory.SEPARATOR
elif unicodedata.category(c)[0] in "LNS" or c == '_':
# letter, number, symbol
# https://unicodebook.readthedocs.io/unicode.html
return CharCategory.LETTER
else:
return CharCategory.OTHER
@functools.total_ordering
@dataclasses.dataclass
class Position:
line: int
char: int
def __lt__(self, other: "Position"):
return (self.line, self.char) < (other.line, other.char)
@dataclasses.dataclass
class Range:
start: Position
end: Position
@property
def empty(self):
return self.start == self.end
@property
def correct(self):
return Range(min(self.start, self.end), max(self.start, self.end))
def clear(self):
self.start = Position(0, 0)
self.end = Position(0, 0)
@dataclasses.dataclass
class KeyPress:
key: str
shift: bool = False
ctrl: bool = False
class TextArea:
"""
General info:
This class contains information about the textarea, which consists of
1) Textarea content - represented by 2D array of characters
2) Cursor position - represented by position object
3) Selection - represented by pair of positions
Cursor position may lay outside of content array bounds (if not justified).
Selection can be empty.
Selection end represents the controlled by user end and not right border.
Every keypress in handled by `.emulate(keypress)` method.
If keypress is a letter key - puts that key into text or handles ctrl+_ key,
if ctrl in pressed. Otherwise, tries to call one of the handlers, denoted by
`@KeyHandler.handle` decorator
Public interface consists of properties for content and cursor position and
`emulate` method.
"""
def __init__(self):
self._content: List[List[str]] = [[]]
self._cursor: Position = Position(0, 0)
self._true_selection: Range = Range(self._cursor, self._cursor)
@property
def _selection(self) -> Range:
if self._true_selection.empty:
self._true_selection = Range(self._cursor, self._cursor)
return self._true_selection
@_selection.setter
def _selection(self, value):
self._true_selection = value
@property
def content(self) -> str:
return "\n".join(map(lambda x: "".join(x), self._content))
@content.setter
def content(self, content: str):
self._content = list(map(list, content.split('\n')))
@property
def cursor_position(self):
return self._cursor
@cursor_position.setter
def cursor_position(self, position: Position):
self._cursor = position
def emulate(self, keypress: KeyPress):
if len(keypress.key) == 1:
if keypress.ctrl and not keypress.shift:
self._handle_ctrl_hotkey(keypress)
else:
self._put_char(keypress.key)
else:
self._try_handle(keypress)
def _at(self, pos: Position) -> str:
return self._content[pos.line][pos.char]
def _in_bounds(self, pos: Position) -> bool:
""" Returns True if the given position can be indexed by `._at()` """
return 0 <= pos.line < len(self._content) and 0 <= pos.char < len(self._content[pos.line])
def _get_start_of_line(self, arg: Union[Position, int]) -> Position:
if isinstance(arg, int):
return Position(arg, 0)
else:
return self._get_start_of_line(arg.line)
def _get_end_of_line(self, arg: Union[Position, int]) -> Position:
if isinstance(arg, int):
return Position(arg, len(self._content[arg]))
else:
return self._get_end_of_line(arg.line)
# noinspection PyMethodMayBeStatic
def _get_start_of_text(self) -> Position:
return Position(0, 0)
def _get_end_of_text(self) -> Position:
return Position(len(self._content) - 1, len(self._content[-1]))
@dataclasses.dataclass
class KeyHandler:
""" Helper functor """
HandlerType = Callable[["Textarea", KeyPress], None]
def __init__(self, function: HandlerType, keys: Tuple[str, ...]):
self.keys = keys
self.function = function
@classmethod
def handle(cls, *keys: str) -> Callable[[HandlerType], HandlerType]:
def _handle(function: "TextArea.KeyHandler.HandlerType"):
return cls(function, keys)
return _handle
def __call__(self, instance: "TextArea", keypress: KeyPress):
self.function(instance, keypress)
def _try_handle(self, keypress: KeyPress):
""" Tries to handle a special key, such as Backspace """
for method in dir(self):
method = getattr(self, method)
if isinstance(method, TextArea.KeyHandler):
if keypress.key in method.keys:
method(self, keypress)
return
raise AttributeError(f"Key {keypress} does not have a handler")
def _get_justified_position(self, position: Position) -> Position:
""" Clamps a given position to the line bounds """
return Position(
position.line,
min(position.char, len(self._content[position.line]))
)
def _justify_cursor_position(self):
""" Clamps a cursor position """
self._cursor = self._get_justified_position(self._cursor)
def _clear_range(self, range_: Range):
""" Removes all characters in given range (not necessarily correct) """
if range_.empty:
return
range_ = range_.correct
start, end = range_.start, range_.end
if start.line == end.line:
line = self._content[start.line]
self._content[start.line] = line[:start.char] + line[end.char:]
else:
self._content[start.line] = \
self._content[start.line][:start.char] + \
self._content[end.line][end.char:]
del self._content[start.line + 1:end.line + 1]
if len(self._content) == 0:
self._content = [[]]
def _clear_selection(self):
""" Removes all characters from selection, clears selection and adjusts cursor position """
if self._selection.empty:
return
self._clear_range(self._selection)
self._cursor = min(self._selection.start, self._selection.end)
self._selection.clear()
def _put_char(self, char: str):
""" Inserts a single char into the content after cursor """
self._clear_selection()
self._justify_cursor_position()
self._content[self._cursor.line].insert(self._cursor.char, char)
self._cursor = self._get_right(self._cursor)
def _get_left(self, pos: Position) -> Position:
""" Returns a position to the left of given position """
pos = self._get_justified_position(pos)
if pos.char > 0:
pos.char -= 1
elif pos.line > 0:
pos.line -= 1
pos.char = len(self._content[pos.line])
return pos
def _get_right(self, pos: Position) -> Position:
""" Returns a position to the right of given position """
pos = self._get_justified_position(pos)
if pos.char < len(self._content[pos.line]):
pos.char += 1
elif pos.line < len(self._content) - 1:
pos.line += 1
pos.char = 0
return pos
# noinspection PyMethodMayBeStatic
def _get_up(self, pos: Position) -> Position:
if pos.line > 0:
pos.line -= 1
else:
pos.char = 0
return pos
def _get_down(self, pos: Position) -> Position:
if pos.line < len(self._content) + 1:
pos.line += 1
else:
# do not break cursor justification
pos.char = max(len(self._content[-1]), pos.char)
return pos
def _get_next_word_end(self, pos: Position) -> Position:
""" Advances given position the end of a word """
p = self._get_right(pos)
if p.line != pos.line or p == pos:
return p
prev = pos
while self._in_bounds(p) and not \
(CharCategory.of(self._at(p)) != CharCategory.of(self._at(prev)) and
CharCategory.of(self._at(prev)) != CharCategory.SEPARATOR):
prev = p
p = self._get_right(p)
return p
def _get_prev_word_start(self, pos: Position) -> Position:
""" Advances given position backwards to the start of a word """
p = self._get_left(pos)
while p.line == pos.line and p > self._get_start_of_text():
prev = self._get_left(p)
if self._in_bounds(p) and CharCategory.of(self._at(p)) != CharCategory.SEPARATOR and \
(not self._in_bounds(prev) or CharCategory.of(self._at(prev))) != CharCategory.of(self._at(p)):
break
p = prev
return p
def _simulate_movement(self, keypress: KeyPress, cursor: Position = None) -> Position:
"""
Performs a specific movement from given position
to a position of cursor after execution of a given keypress,
without side effects
"""
if cursor is None:
cursor = copy(self._cursor)
if keypress.key in ("ArrowRight", "Delete"):
if keypress.ctrl:
return self._get_next_word_end(cursor)
else:
return self._get_right(cursor)
if keypress.key in ("ArrowLeft", "Backspace"):
if keypress.ctrl:
return self._get_prev_word_start(cursor)
else:
return self._get_left(cursor)
if keypress.key == "ArrowUp":
return self._get_up(cursor)
if keypress.key == "ArrowDown":
return self._get_down(cursor)
if keypress.key == "PageUp":
return Position(0, 0)
if keypress.key == "PageDown":
return Position(len(self._content) - 1, len(self._content[-1]))
if keypress.key == "End":
return Position(cursor.line, len(self._content[cursor.line]))
if keypress.key == "Home":
return Position(cursor.line, 0)
raise ValueError(f"Unsupported key {keypress}")
@KeyHandler.handle("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "End", "Home", "PageUp", "PageDown")
def _movements(self, keypress: KeyPress):
""" Handles general arrow and special key movements, that does not mutate the content """
if not self._selection.empty and not keypress.shift:
if keypress.key in ("ArrowLeft", "Home", "ArrowUp"): # left-alike
self._cursor = self._selection.correct.start
elif keypress.key in ("ArrowRight", "End", "ArrowDown"): # right-alike
self._cursor = self._selection.correct.end
if keypress.key not in ("ArrowLeft", "ArrowRight"):
self._cursor = self._simulate_movement(keypress)
self._selection.clear()
else:
moved = self._simulate_movement(keypress)
if keypress.shift:
self._selection.end = moved
self._cursor = moved
@KeyHandler.handle("Enter")
def _enter(self, keypress: KeyPress):
if keypress.ctrl:
return
self._clear_selection()
suffix = self._content[self._cursor.line][self._cursor.char:]
del self._content[self._cursor.line][self._cursor.char:]
self._content.insert(self._cursor.line + 1, suffix)
self._cursor = Position(self._cursor.line + 1, 0)
@KeyHandler.handle("Backspace", "Delete")
def _backspace(self, keypress: KeyPress):
if not self._selection.empty:
self._clear_selection()
return
moved = self._simulate_movement(keypress)
self._clear_range(Range(self._cursor, moved))
self._cursor = min(self._cursor, moved)
def _handle_ctrl_hotkey(self, keypress: KeyPress):
if keypress.key.lower() == "a":
self._selection = Range(self._get_start_of_text(), self._get_end_of_text())
else:
pass # ignoring other hotkeys
@KeyHandler.handle(
"Alt", "Shift", "Control", "Tab", "Escape", "CapsLock",
*(f"F{i}" for i in range(1, 31)),
)
def _ignore(self, _):
""" Handles the keys that are known to not affect the textarea """
pass
let log = [];
function callback(e) {
console.log(e)
log.push({key: e.key, shift: e.shiftKey, ctrl: e.ctrlKey});
}
function getLog() {
return log;
}
function resetLog() {
log = [];
}
function register(textarea) {
textarea.onkeydown = callback;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>testing textarea</title>
<script src="recorder.js"></script>
</head>
<body>
<label>
<textarea id="textarea"></textarea>
</label>
<button onclick="sendToValidation()">Validate</button>
<script defer>
const textarea = document.getElementById("textarea");
register(textarea);
function sendToValidation() {
const log = getLog();
fetch('/validate/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
initial: {
text: "",
position: {
line: 0,
char: 0,
},
},
log: log,
final: {
text: textarea.value,
},
}),
}).then(result => result.json()).then(json => console.log(json));
resetLog();
textarea.value = "";
}
</script>
</body>
</html>
from flask import Flask, render_template, request
import traceback
from emulator import TextArea, KeyPress, Position
app = Flask(__name__)
app.template_folder = "."
@app.route("/")
def index():
return render_template("testing_page.html")
@app.route("/recorder.js")
def recorder():
return render_template("recorder.js") # ugly
@app.route("/validate/", methods=['POST'])
def validate():
json = request.get_json()
initial = json["initial"]
log = json["log"]
final = json["final"]
area = TextArea()
area.content = initial["text"]
area.cursor_position = Position(initial["position"]["line"], initial["position"]["char"])
valid = None
reason = None
exception = None
# noinspection PyBroadException
try:
for action in log:
area.emulate(KeyPress(action["key"], action["shift"], action["ctrl"]))
except Exception as e:
valid = False
exception = traceback.format_exc()
if valid is None:
valid = final["text"] == area.content
if not valid:
reason = "Content values do not match"
return {
"valid": valid,
"exception": exception,
"reason": reason,
"initial": initial,
"final": {
"result": {
"text": area.content,
"position": {
"line": area.cursor_position.line,
"char": area.cursor_position.char,
},
},
"expected": final,
} if exception is None else None,
}
if __name__ == '__main__':
app.run()
import pytest
from emulator import *
def test_movement():
t = TextArea()
t.set_content(
'abc abc abc\n'
'a b\n'
' a b \n'
' a a\n'
)
print(t._content)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(0, 1)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(0, 2)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(0, 3)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(0, 4)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(0, 5)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(0, 7)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(0, 11)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(1, 0)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(1, 1)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(1, 11)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(2, 0)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(2, 5)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(2, 9)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(2, 12)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(3, 0)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(3, 4)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(3, 10)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(4, 0)
t.emulate(KeyPress("ArrowRight", ctrl=True))
assert t._cursor == Position(4, 0)
t.emulate(KeyPress("ArrowRight"))
assert t._cursor == Position(4, 0)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(3, 10)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(3, 9)
t.emulate(KeyPress("ArrowLeft"))
assert t._cursor == Position(3, 8)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(3, 3)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(2, 12)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(2, 8)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(2, 4)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(1, 11)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(1, 10)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(1, 0)
t.emulate(KeyPress("ArrowLeft"))
assert t._cursor == Position(0, 11)
t.emulate(KeyPress("ArrowLeft"))
assert t._cursor == Position(0, 10)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(0, 8)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(0, 4)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(0, 0)
t.emulate(KeyPress("ArrowLeft", ctrl=True))
assert t._cursor == Position(0, 0)
t.emulate(KeyPress("ArrowLeft"))
assert t._cursor == Position(0, 0)
t.set_content("abcd\nabcd\nabcd")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment