Last active
May 24, 2025 02:32
-
-
Save Pinacolada64/b978ddc6dab0976db5f2ac45c6fcf998 to your computer and use it in GitHub Desktop.
Text editor with (maybe) better design principles
This file contains hidden or 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
# kwargs practice | |
import logging | |
from enum import Enum, Flag | |
from typing import Callable, Optional | |
# original code: | |
# https://github.com/Pinacolada64/TADA/blob/e54fc3bb2ff14bae731942d24ce5addb96616216/server/text_editor.py | |
class DefaultLineRange(Enum): | |
""" | |
Default line range for dot commands. Typing a dot command which accepts a line range but not specifying one | |
sets the default line range. | |
""" | |
NONE = 0 | |
# .List: | |
ALL_LINES = 1 | |
FIRST_LINE = 2 | |
# .Edit: | |
LAST_LINE = 3 | |
class LineRange: | |
""" | |
Represents a range of lines. | |
None represents no value for the starting or ending line. | |
""" | |
def __init__(self, start: int | None, end: int | None): | |
self.start = start | |
self.end = end | |
class CommandFlags(Enum): | |
""" | |
Flags for commands. In examples, characters surrounded by brackets are things the user has typed. | |
`IMMEDIATE`: command is executed immediately without requiring a line range or Return/Enter to confirm. | |
e.g., [.A]bort # But add a confirmation prompt! | |
[.V]ersion | |
`ACCEPT_CHARACTER`: command can accept a single character as a parameter; if none is entered, the DefaultCharacter will be used. | |
e.g., [.B]order [*] # '*' is the border character to put around the text. | |
`ACCEPT_LINE_RANGE`: command can accept a line range; if none is entered, the DefaultLineRange will be used. | |
e.g., [.E]dit [1-10] | |
`ACCEPT_NUMBERS`: command can accept only number(s) as a parameter; if none or characters are entered, the DefaultNumber will be used. | |
e.g., [.C]olumns [10] | |
'ACCEPT_SUBCOMMAND': command can accept a subcommand as a parameter; | |
e.g.,: [.J]ustify [P]acked | |
- if none is entered, the DefaultSubcommand will be used. | |
""" | |
IMMEDIATE = 0 | |
ACCEPT_CHARACTER = 1 | |
ACCEPT_LINE_RANGE = 2 | |
ACCEPT_NUMBERS = 3 | |
ACCEPT_SUBCOMMAND = 4 | |
class EditorMode(Flag): | |
"""editor modes""" | |
EDITING = 0 | |
# toggled by .I: | |
INSERT = 1 | |
# toggled by .O: | |
LINE_NUMBERS = 2 | |
class Justification(Enum): | |
"""Justification options for .J command""" | |
CENTER = 0 | |
EXPAND = 1 | |
INDENT = 2 | |
LEFT = 3 | |
PACK = 4 | |
RIGHT = 5 | |
UN_INDENT = 6 | |
class LineFlag(Enum): | |
IMMUTABLE = 0 # used so the editor cannot modify quoted lines | |
MUTABLE = 1 # editable | |
QUOTE = 2 # automatically prepend "> " quote marker? | |
""" | |
QUOTE style idea: might be nice to have boxes around quotes: | |
+----- Quoting Descartes: ------+ | |
| | | |
| "Cogito, ergo sum. | | |
| I think, therefore I am." | | |
| | | |
+-------------------------------+ | |
""" | |
class Line: | |
def __init__(self, text: Optional[str], justification: Justification, line_flag: LineFlag): | |
# if text is None, the line is blank: | |
self.text = text | |
""" | |
The reason to include the justification attribute in the Line class is (again) variable-width | |
screen displays. If the text is initially typed on a 40-column screen and .Justify Center is used, | |
adding spaces at the start of the string to center it | |
it could look like this ('|' represents the left and right column margins): | |
| Nineteen characters | | |
If the text is later viewed on an 80-column screen, it will not look centered: | |
| Nineteen characters | | |
By storing the Justification value as an attribute of the Line class instead of modifying the text string itself | |
when various justification methods are used), it is a column width-independent method. | |
We can use Python string methods to center, left-justify, or right-justify the text without having to worry about | |
a fixed screen width. | |
"xyz".center(10) -> " xyz " | |
"xyz".ljust(10) -> "xyz " | |
"xyz".rjust(10) -> " xyz " | |
"abc def xyz".expand(30) -> "abc def xyz" # FIXME looks like this needs to be written | |
""" | |
self.justification = justification | |
# MUTABLE | IMMUTABLE | QUOTE: | |
self.line_flag = line_flag | |
class DotCommand: | |
""" | |
Represents a command with specific properties and behavior. | |
This class encapsulates the attributes and initial setup for a command-like | |
structure, which includes a key identifying the command, descriptive text, | |
default line range for operation, flags affecting command behavior, and the | |
associated function implementation. It is designed to provide a flexible way | |
to manage commands and assign characteristics dynamically. | |
:ivar command_key: The unique identifier or key that represents this command. | |
:type command_key: str | |
:ivar command_text: A description or textual representation of the command. | |
:type command_text: str | |
:ivar default_line_range: The default range of lines this command operates upon. | |
:type default_line_range: DefaultLineRange | |
:ivar flags: Flags that define additional behaviors or functionality of the command. | |
:type flags: CommandFlags | |
:ivar function_name: The callable (function or method) associated with the execution of the command. | |
:type function_name: Callable | |
""" | |
def __init__(self, command_key: str, command_text: str, default_line_range: DefaultLineRange, flags: CommandFlags, | |
function_name: Callable): | |
self.command_key = command_key | |
self.command_text = command_text | |
self.default_line_range = default_line_range | |
self.flags = flags | |
self.function_name = function_name | |
class Cursor: | |
"""Represents the cursor position within a text buffer. Used to keep track during move-by-word and such.""" | |
def __init__(self, row: int, column: int): | |
self.row = row | |
self.column = column | |
class Buffer: | |
"""Represents a text buffer.""" | |
def __init__(self): | |
# start out with 11 empty Lines, 0-10 | |
self.lines = [Line(None, Justification.LEFT, LineFlag.MUTABLE) for _ in range(10)] | |
self.used_lines = 0 | |
self.total_lines = len(self.lines) | |
self.current_line = 1 | |
self.cursor = Cursor(1, 1) | |
logging.debug("Buffer initialized. Total lines: %i, used: %i" % (self.total_lines, self.used_lines)) | |
def append_lines(self, lines_to_append: list, mutable: bool): | |
if lines_to_append: | |
self.lines.append(Line(text, Justification.LEFT, mutable) for text in lines_to_append) | |
# update total/used lines: | |
self.used_lines += len(lines_to_append) | |
self.total_lines += len(lines_to_append) | |
class Editor: | |
"""Represents the text editor, including the current mode and a list of available commands.""" | |
def __init__(self): | |
# TODO: look at using Enum Flags since Mode is not necessarily EditorMode.EDITING OR EditorMode.LINE_NUMBERING; | |
# they both could be enabled at once | |
self.Mode = EditorMode.EDITING | |
self.dot_command_prefixes = [".", "/"] | |
self.dot_command_table = [ | |
DotCommand("a", "Abort", default_line_range=DefaultLineRange.NONE, function_name=cmd_abort, | |
flags=CommandFlags.IMMEDIATE), | |
DotCommand("b", "Border", default_line_range=DefaultLineRange.ALL_LINES, function_name=cmd_border, | |
flags=CommandFlags.ACCEPT_CHARACTER), | |
DotCommand("c", "Columns", default_line_range=DefaultLineRange.ALL_LINES, function_name=cmd_columns, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("d", "Delete", default_line_range=DefaultLineRange.ALL_LINES, function_name=cmd_delete, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("e", "Edit", default_line_range=DefaultLineRange.LAST_LINE, function_name=cmd_edit, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("f", "Find", default_line_range=DefaultLineRange.ALL_LINES, function_name=cmd_find, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("h", "Help", default_line_range=DefaultLineRange.NONE, function_name=cmd_help, | |
flags=CommandFlags.ACCEPT_CHARACTER), | |
DotCommand("i", "Insert", DefaultLineRange.NONE, function_name=cmd_insert, | |
flags=CommandFlags.ACCEPT_NUMBERS), | |
DotCommand("j", "Justify", default_line_range=DefaultLineRange.NONE, function_name=cmd_help, | |
flags=CommandFlags.ACCEPT_CHARACTER), | |
DotCommand("k", "Search & Replace", default_line_range=DefaultLineRange.ALL_LINES, | |
function_name=cmd_search_and_replace, flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("l", "List", default_line_range=DefaultLineRange.NONE, function_name=cmd_list, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("n", "New Text", default_line_range=DefaultLineRange.NONE, function_name=cmd_new_text, | |
flags=CommandFlags.IMMEDIATE), | |
DotCommand("o", "Line Numbers", default_line_range=DefaultLineRange.NONE, | |
function_name=cmd_line_numbers, flags=CommandFlags.IMMEDIATE), | |
DotCommand("q", "Query", DefaultLineRange.NONE, function_name=cmd_query, flags=CommandFlags.IMMEDIATE), | |
DotCommand("r", "Read", default_line_range=DefaultLineRange.ALL_LINES, function_name=cmd_read, | |
flags=CommandFlags.ACCEPT_LINE_RANGE), | |
DotCommand("s", "Save", default_line_range=DefaultLineRange.NONE, function_name=cmd_save, | |
flags=CommandFlags.IMMEDIATE), | |
DotCommand("t", "Tagline", default_line_range=DefaultLineRange.NONE, function_name=cmd_tagline, | |
flags=CommandFlags.IMMEDIATE), | |
DotCommand("v", "Version", DefaultLineRange.NONE, function_name=cmd_version, | |
flags=CommandFlags.IMMEDIATE), | |
DotCommand("#", "Scale", default_line_range=DefaultLineRange.NONE, function_name=cmd_help, | |
flags=CommandFlags.IMMEDIATE), | |
] | |
# TODO: if Player.check_privileged # i.e., dungeon master / Admin: | |
self.privileged_commands = [DotCommand("$", | |
"Directory", default_line_range=DefaultLineRange.NONE, | |
flags=CommandFlags.IMMEDIATE, function_name=priv_directory), | |
DotCommand("p", "Put File", default_line_range=DefaultLineRange.NONE, flags=CommandFlags.IMMEDIATE, function_name=priv_put_file), | |
DotCommand("&", "Read File", default_line_range=DefaultLineRange.NONE, flags=CommandFlags.IMMEDIATE, function_name=priv_read_file), | |
DotCommand("g", "Get File", default_line_range=DefaultLineRange.NONE, flags=CommandFlags.IMMEDIATE, function_name=priv_get_file), | |
] | |
self.column_width = 80 # default column width | |
self.buffer = Buffer() | |
self.version_info = "2025-05-21, 05:19 PM" | |
logging.debug("Editor initialized.") | |
def input_text(self): | |
"""Allow user to input text into the buffer""" | |
print("Enter text (type '.s' to finish):") | |
while True: | |
if editor.Mode.LINE_NUMBERS: | |
print(f"{editor.buffer.current_line}:") | |
text = input() | |
# check if dot command: | |
if text[0] in self.dot_command_prefixes: | |
# check for dot command: | |
for cmd in self.dot_command_table: | |
if text[1].lower() == cmd.command_key: | |
# call dot command: | |
cmd = [cmd for cmd in self.dot_command_table if cmd.command_key == text[1]][0] | |
logging.debug(f"Calling dot command: {cmd.command_key} ({cmd.command_text})") | |
cmd.function_name(self) | |
continue | |
# check for privileged command: | |
if text[1].lower() in [cmd.command_key for cmd in self.privileged_commands]: | |
# call privileged command: | |
cmd = [cmd for cmd in self.privileged_commands if cmd.command_key == text[1]][0] | |
cmd.function_name(self) | |
continue | |
# Check for empty line to finish input | |
# if text == "" and self.buffer.used_lines > 0 and self.buffer.lines[self.buffer.used_lines - 1].text == "": | |
# break | |
# Create a new line with input text | |
else: | |
new_line = Line( | |
text=text, | |
justification=Justification.LEFT, | |
line_flag=LineFlag.MUTABLE | |
) | |
# increment current_line: | |
editor.buffer.current_line += 1 | |
# Add line to buffer | |
if self.buffer.used_lines >= len(self.buffer.lines): | |
# Extend buffer if needed | |
self.buffer.lines.append(new_line) | |
else: | |
self.buffer.lines[self.buffer.used_lines] = new_line | |
self.buffer.used_lines += 1 | |
def cmd_abort(*args, **kwargs): | |
print("Aborting command.") | |
def cmd_border(*args, **kwargs): | |
print("Border.") | |
def cmd_columns(*args, **kwargs): | |
print("Columns.") | |
if args is None: | |
print(f"Column width is set to {editor.column_width} haracters.") | |
def cmd_delete(*args, **kwargs): | |
print("Deleting.") | |
def cmd_edit(*args, **kwargs): | |
# TODO: check for LineFlag.IMMUTABLE property | |
print("Editing.") | |
def cmd_find(*args, **kwargs): | |
print("Finding.") | |
def cmd_help(*args, **kwargs): | |
print("Help.") | |
def cmd_insert(*args, **kwargs): | |
editor.Mode.INSERT = not editor.Mode.INSERT | |
print(f"Insert Mode is now {'on' if editor.Mode.INSERT else 'off'}") | |
def cmd_line_numbers(*args, **kwargs): | |
# toggle line numbering mode on/off | |
editor.Mode.LINE_NUMBERS = not editor.Mode.LINE_NUMBERS | |
print(f"Line numbers are now {'on' if editor.Mode.LINE_NUMBERS is True else 'off'}.") | |
def cmd_justify_text(*args, **kwargs): | |
"""Justify text in several different way: | |
Center: Center text in Columns | |
Expand: Add additional spaces between text to make text equal width to Columns | |
Before: "This is an Expanded Line" | |
After: "This is an Expanded line" | |
Indent: add <prompted number> spaces to left of text | |
Left: left-justify text | |
Pack: Remove spaces added by Expand: | |
Before: "This is an Expanded line" | |
After: "This is an Expanded Line" | |
, Right | |
""" | |
print("Justifying text.") | |
def cmd_search_and_replace(*args, **kwargs): | |
# .Kill | |
print("Searching and replacing.") | |
def cmd_list(*args, **kwargs): | |
print("Listing.") | |
for k, v in enumerate(editor.buffer.lines, 1): | |
print(f"{k: 3}: {v.text}") | |
def cmd_new_text(*args, **kwargs): | |
"""Erase buffer and start new text.""" | |
# TODO: ask Are you Sure?: | |
print("New text.") | |
def cmd_query(*args, **kwargs): | |
print("Query Buffer.") | |
print(f"Total lines used: {len(editor.buffer.lines)}") | |
# TODO: print("Lines free: ") | |
print("Total characters: ", sum(len(line.text) for line in editor.buffer.lines)) | |
print("Total words: ", sum(len(line.text.split()) for line in editor.buffer.lines)) | |
def cmd_read(*args, **kwargs): | |
print("Reading.") | |
def cmd_save(*args, **kwargs): | |
print("Saving.") | |
def cmd_tagline(*args, **kwargs): | |
print("Tagline.") | |
def cmd_undo(*args, **kwargs): | |
# swap undo buffer with work buffer? | |
# show changes first | |
print("Undoing most recent changes.") | |
def cmd_version(*args, **kwargs): | |
# display editor version | |
print(f"Editor version: {editor.version_info}") | |
def cmd_quoter(*args, **kwargs): | |
print("Quoting text.") | |
# FIXME: buffer.append() | |
lines = ["Quoting Pinacolada:", "> ", "> Blah"] | |
# user cannot edit immutable lines: | |
for line in lines: | |
editor.buffer.append_lines(Line(line, Justification.LEFT, LineFlag.IMMUTABLE)) | |
# Privileged commands: | |
def priv_get_file(*args, **kwargs): | |
print("Getting file.") | |
def priv_put_file(*args, **kwargs): | |
print("Putting file.") | |
def priv_read_file(*args, **kwargs): | |
print("Reading file.") | |
def priv_directory(*args, **kwargs): | |
print("Directory.") | |
# TODO: get file pattern | |
def process_line_range_string(dot_command: DotCommand, line_range: str, *args, **kwargs) -> LineRange: | |
"""process line range input string | |
:param dot_command: need line_range_default to determine which line range to apply to command | |
:param line_range: line range entered by user, could be modified if | |
"1" - start and end line is 1 | |
"1-" - start line is 1 and end line is last line | |
"-10" - start line is first line and end line is 10 | |
"5-15" - start line is 5 and end line is 15 | |
TODO: "5-+9" - start line is 5 and end line is 14 (relative to current line) | |
:returns: LineRange(start_line, end_line): None, None if string entered instead of numbers. | |
""" | |
logging.debug("mutable parameters: %s, %s" % (line_range, kwargs)) | |
if line_range: | |
try: | |
start_line, end_line = line_range.split("-") | |
except ValueError: | |
print("String entered instead of numbers.") | |
return LineRange(None, None) | |
if len(args) == 1: | |
logging.debug(f"Start line: {args[0]}") | |
return LineRange(args[0], None) | |
elif len(args) == 2: | |
logging.debug(f"Start line: {args[0]} and end line: {args[1]}") | |
return LineRange(args[0], args[1]) | |
# TODO: verify start_line <= end line, end_line >= start_line | |
else: | |
# TODO: if the dot command defaults to last line (e.g., Edit) | |
logging.debug("No line range specified, defaulting to last line.") | |
return LineRange(1, editor.buffer.used_lines) | |
# TODO: if the dot command defaults to all text entered (e.g., List) | |
# modify default line range based on dot command's DefaultLineRange property: | |
return LineRange(start_line, end_line) | |
if __name__ == '__main__': | |
# initialize logging | |
logging.basicConfig(level=logging.DEBUG, | |
format='%(levelname)10s | %(funcName)15s() | %(message)s', | |
datefmt='%m-%d %H:%M') | |
editor = Editor() | |
editor.input_text() | |
# start_line, end_line = process_line_range_string(dot_command=Editor.dot_command_table[], "1-6") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Parent: https://github.com/Pinacolada64/TADA/blob/e54fc3bb2ff14bae731942d24ce5addb96616216/server/text_editor.py