From 63269ac7e0c7b75cac9a1df24df8a3520f1f6222 Mon Sep 17 00:00:00 2001 From: Andrew Miner Date: Sat, 13 Jul 2019 19:52:48 -0600 Subject: [PATCH] Add a new Window class with read/write primitives --- src/pycursesui/__init__.py | 6 +- src/pycursesui/attribute_mask.py | 92 +++++++++++++++++ src/pycursesui/attribute_mask_spec.py | 53 ++++++++++ src/pycursesui/session.py | 141 ++++++++++++++------------ src/pycursesui/window.py | 60 +++++++++++ src/pycursesui/window_spec.py | 54 ++++++++++ src/test/main.py | 10 +- 7 files changed, 347 insertions(+), 69 deletions(-) create mode 100644 src/pycursesui/attribute_mask.py create mode 100644 src/pycursesui/attribute_mask_spec.py create mode 100644 src/pycursesui/window.py create mode 100644 src/pycursesui/window_spec.py diff --git a/src/pycursesui/__init__.py b/src/pycursesui/__init__.py index 5359564..68505ec 100644 --- a/src/pycursesui/__init__.py +++ b/src/pycursesui/__init__.py @@ -1,11 +1,15 @@ """A python UI framework for command-line applications using curses.""" +from .attribute_mask import AttributeMask from .logger import Logger, LogLevel +from .window import Window -from .session import Session # uses Logger +from .session import Session # uses Logger, Window __all__ = [ + "AttributeMask", "Logger", "LogLevel", "Session", + "Window", ] diff --git a/src/pycursesui/attribute_mask.py b/src/pycursesui/attribute_mask.py new file mode 100644 index 0000000..7e75eb8 --- /dev/null +++ b/src/pycursesui/attribute_mask.py @@ -0,0 +1,92 @@ +"""Define the AttributeMask class.""" + +import curses + + +######################################################################################################################## + +class AttributeMask(object): + """AttributeMask defines the attributes associated with a certain part of the screen.""" + + def __init__(self, value: int=curses.A_NORMAL): + """Create a new Attribute.""" + self._value = curses.A_NORMAL + self.value = value + + # Properties ################################################################################### + + @property + def value(self) -> int: + """Get the actual numeric value underlying the mask.""" + return self._value + + @value.setter + def value(self, value: int): + if not isinstance(value, int): + raise TypeError(f"value must be an int, but was a {type(value)}") + + self._value = value + + # Flag Properties ############################################################################## + + @property + def blink(self) -> bool: + """Get whether the text is blinking.""" + return self._read(curses.A_BLINK) + + @blink.setter + def blink(self, value: bool): + self._assign(curses.A_BLINK, value) + + @property + def bold(self) -> bool: + """Get whether the text is bolded.""" + return self._read(curses.A_BOLD) + + @bold.setter + def bold(self, value: bool): + self._assign(curses.A_BOLD, value) + + @property + def dim(self) -> bool: + """Get whether the text is dimmed.""" + return self._read(curses.A_DIM) + + @dim.setter + def dim(self, value: bool): + self._assign(curses.A_DIM, value) + + @property + def standout(self) -> bool: + """Get whether the text is standout.""" + return self._read(curses.A_STANDOUT) + + @standout.setter + def standout(self, value: bool): + self._assign(curses.A_STANDOUT, value) + + @property + def underline(self) -> bool: + """Get whether the text is underline.""" + return self._read(curses.A_UNDERLINE) + + @underline.setter + def underline(self, value: bool): + self._assign(curses.A_UNDERLINE, value) + + # Private ###################################################################################### + + def _assign(self, flag: int, value: bool): + if value: + self._set(flag) + else: + self._clear(flag) + + def _clear(self, flag: int): + self.value = self.value & (~ flag) + + def _read(self, flag: int) -> bool: + return self.value & flag != 0 + + def _set(self, flag: int): + self.value = self.value | flag diff --git a/src/pycursesui/attribute_mask_spec.py b/src/pycursesui/attribute_mask_spec.py new file mode 100644 index 0000000..2b56226 --- /dev/null +++ b/src/pycursesui/attribute_mask_spec.py @@ -0,0 +1,53 @@ +"""Unit tests for the AttributeMask class.""" + +import curses +import sure + +from mamba import before, description, it + +from pycursesui import AttributeMask + +__all__ = [] +assert sure # prevent linter errors + + +######################################################################################################################## + +with description("AttributeMask:", "unit") as self: + + with description("starting with a normal mask"): + + with before.each: + self.mask = AttributeMask() + + with it("has all its flags turned off"): + self.mask.blink.should.be.false + self.mask.bold.should.be.false + self.mask.underline.should.be.false + + with description("after activating one of the flags"): + + with before.each: + self.mask.bold = True + + with it("only has the bold flag turned on"): + self.mask.blink.should.be.false + self.mask.bold.should.be.true + self.mask.underline.should.be.false + + with it("has the expected value"): + self.mask.value.should.equal(curses.A_NORMAL | curses.A_BOLD) + + with description("after switching the flag for another"): + + with before.each: + self.mask.bold = False + self.mask.underline = True + + with it("reports the flags having switched"): + self.mask.blink.should.be.false + self.mask.bold.should.be.false + self.mask.underline.should.be.true + + with it("reports the correct value"): + self.mask.value.should.equal(curses.A_NORMAL | curses.A_UNDERLINE) diff --git a/src/pycursesui/session.py b/src/pycursesui/session.py index 8f7a052..c90031f 100644 --- a/src/pycursesui/session.py +++ b/src/pycursesui/session.py @@ -2,7 +2,8 @@ import curses -from pycursesui import Logger +from pycursesui import Logger, Window +from typing import Callable, Tuple __all__ = ["Session"] @@ -14,7 +15,7 @@ class Session(object): def __init__(self, logger=None): """Create a new Session.""" - self._screen = None + self._window = None self.logger = logger # Properties ################################################################################### @@ -22,7 +23,7 @@ class Session(object): @property def is_running(self) -> bool: """Get whether the session is currently active.""" - return (self.screen is not None) + return (self.window is not None) @property def logger(self) -> Logger: @@ -31,79 +32,91 @@ class Session(object): @logger.setter def logger(self, value: Logger): - if value is None: - value = Logger() + value = value if value is not None else Logger() self._logger = value @property - def screen(self): - """Get the screen associated with this session (if any).""" - return self._screen + def window(self) -> Window: + """Get the window associated with this session (if any).""" + return self._window - # Magic Methods ################################################################################ + # Public Methods ############################################################################### - def __enter__(self): - """Enter a session.""" + def start(self) -> "Session": + """ + Start a new session. + + This will take over the current TTY and begin a curses session. The main window will be available from this + object's `window` property. The `stop` method *must* be called to restore the TTY back to its original + condition. + """ self.logger.info("Starting curses session") - try: - self._screen = curses.initscr() - except Exception as e: - self.logger.error("Could not initialize a curses screen", e) - curses.endwin() - raise e - try: - curses.noecho() - except Exception as e: - self.logger.error("Could not set up no ech mode", e) - curses.echo() - curses.endwin() - raise e + raw_window, error = self._attempt(lambda: curses.initscr()) + if raw_window: + self._window = Window(raw_window) + if error: + self.logger.error("could not initialize a curses window", error) + self._attempt(lambda: curses.endwin()) + return None - try: - curses.cbreak() - except Exception as e: - self.logger.error("Could not set up character break mode", e) - curses.nocbreak() - curses.echo() - curses.endwin() - raise e + _, error = self._attempt(lambda: curses.start_color()) + if error: + self.logger.error("could not start color session", error) + self._attempt(lambda: curses.endwin()) - try: - self.screen.keypad(True) - except Exception as e: - self.logger.error("Could not set up keypad", e) - self.screen.keypad(False) - curses.nocbreak() - curses.echo() - curses.endwin() - raise e + _, error = self._attempt(lambda: curses.noecho()) + if error: + self.logger.error("Could not set up no echo mode", error) + self._attempt(lambda: curses.echo()) + self._attempt(lambda: curses.endwin()) + + _, error = self._attempt(lambda: curses.cbreak()) + if error: + self.logger.error("Could not set up chracter break mode", error) + self._attempt(lambda: curses.cnobreak()) + self._attempt(lambda: curses.echo()) + self._attempt(lambda: curses.endwin()) + + _, error = self._attempt(lambda: raw_window.keypad(True)) + if error: + self.logger.error("Could not set up keypad", error) + self._attempt(lambda: raw_window.keypad(False)) + self._attempt(lambda: curses.cnobreak()) + self._attempt(lambda: curses.echo()) + self._attempt(lambda: curses.endwin()) return self + def stop(self) -> "Session": + """Stop the current session.""" + self.logger.info("Shutting down curses session") + self._attempt(lambda: self.window.raw.keypad(False)) + self._attempt(lambda: curses.nocbreak()) + self._attempt(lambda: curses.echo()) + self._attempt(lambda: curses.endwin()) + + self._window = None + return self + + # Magic Methods ################################################################################ + + def __enter__(self) -> "Session": + """Enter a session.""" + return self.start() + def __exit__(self, type, value, traceback): """Exit a session.""" - self.logger.info("Shutting down curses session") - try: - self.screen.keypad(False) - except Exception as e: - self.logger.error("Could not reset keypad", e) - - try: - curses.nocbreak() - except Exception as e: - self.logger.error("Could not reset character break mode", e) - - try: - curses.echo() - except Exception as e: - self.logger.error("Could not reset echo mode", e) - - try: - curses.endwin() - except Exception as e: - self.logger.error("Could not shut down curses session", e) - - self._screen = None - + self.stop() return False + + # Private Methods ############################################################################## + + def _attempt(self, task: Callable) -> Tuple[object, Exception]: + result, error = None, None + try: + result = task() + except Exception as e: + error = e + + return result, error diff --git a/src/pycursesui/window.py b/src/pycursesui/window.py new file mode 100644 index 0000000..014d794 --- /dev/null +++ b/src/pycursesui/window.py @@ -0,0 +1,60 @@ +"""Define the Window class.""" + +from pycursesui import AttributeMask + +######################################################################################################################## + +ENCODING = "ascii" + + +######################################################################################################################## + +class Window(object): + """Window provides a wrapper around a raw curses window.""" + + def __init__(self, raw): + """Create a new Window wrapper.""" + self._raw = None + self.raw = raw + + # Properties ################################################################################### + + @property + def raw(self): + """Get the raw curses window wrapped by this object.""" + return self._raw + + @raw.setter + def raw(self, value): + if self._raw is not None: + raise ValueError("cannot change raw once initialized") + if value is None: + raise ValueError("a value must be provided for raw") + + self._raw = value + + # Public Methods ############################################################################### + + def read(self, x: int, y: int, length: int=1) -> str: + """Read a string from the screen at the given location.""" + return self.raw.instr(y, x, length).decode(ENCODING) + + def write(self, value: str, x: int, y: int, length: int=-1, attributes: AttributeMask=None): + """ + Write a portion of a string onto the window at a certain location. + + Arguments: + value: the string to be written + x: the x-coordinate of where the first character should be placed + y: the y-coordinate of where the first character should be placed + length: the maximum number of characters to write + attributes: an Attributes object giving the attributes to be applied + """ + attributes = attributes if attributes is not None else AttributeMask() + if (length >= 0) and (len(value) > length): + value = value[0:length] + + self.raw.addstr(y, x, value, attributes.value) + self.raw.refresh() + + return self diff --git a/src/pycursesui/window_spec.py b/src/pycursesui/window_spec.py new file mode 100644 index 0000000..663c2ab --- /dev/null +++ b/src/pycursesui/window_spec.py @@ -0,0 +1,54 @@ +"""Unit tests for the Window class.""" + +import sure + +from io import StringIO +from mamba import after, before, description, it + +from pycursesui import Logger, Session +from pycursesui.logger import LogLevel + +__all__ = [] +assert sure # prevent linter errors + + +######################################################################################################################## + +with description("Window:", "unit") as self: + + with before.each: + self.session = None + try: + self.stdout = StringIO() + self.logger = Logger().add_channel("debug", self.stdout, LogLevel.DEBUG) + self.session = Session(self.logger).start() + if self.session is not None: + self.window = self.session.window + except Exception as e: + print(f"log:\n>>>\n{self.stdout.getvalue()}\n<<<\n") + raise e + + with after.each: + if self.session is not None: + self.session.stop() + + with description("using the default window from a new session"): + + with it("doesn't contain any text in the test region"): + self.window.read(0, 0, 10).should.equal(" ") + + with description("after writing a string into the test region"): + + with before.each: + self.window.write("alpha", 0, 0) + + with it("now contains the written text"): + self.window.read(0, 0, 10).should.equal("alpha ") + + with description("after multiple overlapping writes"): + + with before.each: + self.window.write("alpha", 0, 0).write("bravo", 3, 0) + + with it("should have replaced the last portion of the first word"): + self.window.read(0, 0, 10).should.equal("alpbravo ") diff --git a/src/test/main.py b/src/test/main.py index 4a4a666..0dce867 100644 --- a/src/test/main.py +++ b/src/test/main.py @@ -6,10 +6,12 @@ from pycursesui import Logger, Session ######################################################################################################################## -logger = Logger() -logger.add_file_channel("main", "session.log") +logger = Logger().add_file_channel("main", "session.log") with Session(logger) as session: - session.screen.border() - session.screen.refresh() + session.window.write("alpha", 10, 10) + time.sleep(1) + session.window.write("bravo", 10, 11) + time.sleep(1) + session.window.write("charlie", 10, 12) time.sleep(3)