Add a new Window class with read/write primitives
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
"""A python UI framework for command-line applications using curses."""
|
"""A python UI framework for command-line applications using curses."""
|
||||||
|
|
||||||
|
from .attribute_mask import AttributeMask
|
||||||
from .logger import Logger, LogLevel
|
from .logger import Logger, LogLevel
|
||||||
|
from .window import Window
|
||||||
|
|
||||||
from .session import Session # uses Logger
|
from .session import Session # uses Logger, Window
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AttributeMask",
|
||||||
"Logger",
|
"Logger",
|
||||||
"LogLevel",
|
"LogLevel",
|
||||||
"Session",
|
"Session",
|
||||||
|
"Window",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
+77
-64
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
from pycursesui import Logger
|
from pycursesui import Logger, Window
|
||||||
|
from typing import Callable, Tuple
|
||||||
|
|
||||||
__all__ = ["Session"]
|
__all__ = ["Session"]
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ class Session(object):
|
|||||||
|
|
||||||
def __init__(self, logger=None):
|
def __init__(self, logger=None):
|
||||||
"""Create a new Session."""
|
"""Create a new Session."""
|
||||||
self._screen = None
|
self._window = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
# Properties ###################################################################################
|
# Properties ###################################################################################
|
||||||
@@ -22,7 +23,7 @@ class Session(object):
|
|||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Get whether the session is currently active."""
|
"""Get whether the session is currently active."""
|
||||||
return (self.screen is not None)
|
return (self.window is not None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logger(self) -> Logger:
|
def logger(self) -> Logger:
|
||||||
@@ -31,79 +32,91 @@ class Session(object):
|
|||||||
|
|
||||||
@logger.setter
|
@logger.setter
|
||||||
def logger(self, value: Logger):
|
def logger(self, value: Logger):
|
||||||
if value is None:
|
value = value if value is not None else Logger()
|
||||||
value = Logger()
|
|
||||||
self._logger = value
|
self._logger = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def screen(self):
|
def window(self) -> Window:
|
||||||
"""Get the screen associated with this session (if any)."""
|
"""Get the window associated with this session (if any)."""
|
||||||
return self._screen
|
return self._window
|
||||||
|
|
||||||
# Magic Methods ################################################################################
|
# Public Methods ###############################################################################
|
||||||
|
|
||||||
def __enter__(self):
|
def start(self) -> "Session":
|
||||||
"""Enter a 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")
|
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:
|
raw_window, error = self._attempt(lambda: curses.initscr())
|
||||||
curses.noecho()
|
if raw_window:
|
||||||
except Exception as e:
|
self._window = Window(raw_window)
|
||||||
self.logger.error("Could not set up no ech mode", e)
|
if error:
|
||||||
curses.echo()
|
self.logger.error("could not initialize a curses window", error)
|
||||||
curses.endwin()
|
self._attempt(lambda: curses.endwin())
|
||||||
raise e
|
return None
|
||||||
|
|
||||||
try:
|
_, error = self._attempt(lambda: curses.start_color())
|
||||||
curses.cbreak()
|
if error:
|
||||||
except Exception as e:
|
self.logger.error("could not start color session", error)
|
||||||
self.logger.error("Could not set up character break mode", e)
|
self._attempt(lambda: curses.endwin())
|
||||||
curses.nocbreak()
|
|
||||||
curses.echo()
|
|
||||||
curses.endwin()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
try:
|
_, error = self._attempt(lambda: curses.noecho())
|
||||||
self.screen.keypad(True)
|
if error:
|
||||||
except Exception as e:
|
self.logger.error("Could not set up no echo mode", error)
|
||||||
self.logger.error("Could not set up keypad", e)
|
self._attempt(lambda: curses.echo())
|
||||||
self.screen.keypad(False)
|
self._attempt(lambda: curses.endwin())
|
||||||
curses.nocbreak()
|
|
||||||
curses.echo()
|
_, error = self._attempt(lambda: curses.cbreak())
|
||||||
curses.endwin()
|
if error:
|
||||||
raise e
|
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
|
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):
|
def __exit__(self, type, value, traceback):
|
||||||
"""Exit a session."""
|
"""Exit a session."""
|
||||||
self.logger.info("Shutting down curses session")
|
self.stop()
|
||||||
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
|
|
||||||
|
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ")
|
||||||
+6
-4
@@ -6,10 +6,12 @@ from pycursesui import Logger, Session
|
|||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
|
|
||||||
logger = Logger()
|
logger = Logger().add_file_channel("main", "session.log")
|
||||||
logger.add_file_channel("main", "session.log")
|
|
||||||
|
|
||||||
with Session(logger) as session:
|
with Session(logger) as session:
|
||||||
session.screen.border()
|
session.window.write("alpha", 10, 10)
|
||||||
session.screen.refresh()
|
time.sleep(1)
|
||||||
|
session.window.write("bravo", 10, 11)
|
||||||
|
time.sleep(1)
|
||||||
|
session.window.write("charlie", 10, 12)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|||||||
Reference in New Issue
Block a user