Skip to content

Instantly share code, notes, and snippets.

@Pinacolada64
Last active May 24, 2025 02:32
Show Gist options
  • Save Pinacolada64/b978ddc6dab0976db5f2ac45c6fcf998 to your computer and use it in GitHub Desktop.
Save Pinacolada64/b978ddc6dab0976db5f2ac45c6fcf998 to your computer and use it in GitHub Desktop.
Text editor with (maybe) better design principles
# 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")
@Pinacolada64
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment