Created
April 11, 2022 18:27
-
-
Save elteammate/5ddf7f702a8d9f9f9651940ff36485e4 to your computer and use it in GitHub Desktop.
textarea-emulator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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