The interactive viewer: state, key handling and the redraw loop.
Renders at full resolution when idle and at draft resolution while a key is
held, so turning a structure stays responsive in graphics mode where a
full-quality frame costs longer than a keypress.
Argument parsing and loading live in __main__; this module starts from an
already-loaded list of frames, which is what makes the whole thing drivable
from a test without a terminal.
State
dataclass
Source code in chmpy/tui/app.py
| @dataclass
class State:
frames: list
index: int = 0
view: str = "structure"
rotation: np.ndarray | None = None
direction: str = "" # label of the aimed view, blank once freely rotated
prompt: str | None = None # direction being typed, when the prompt is open
zoom: float = 1.0
style: str = "ball-and-stick"
shading: str = "lit"
cells: int = 1
frame: tuple | None = None # framing extent, expand-only
message: str = ""
theme: object = field(default_factory=lambda: theme_for(None))
cache: dict = field(default_factory=dict)
def __post_init__(self):
if self.rotation is None:
self.rotation, self.direction = default_view(self.structure)
@property
def structure(self):
return self.frames[self.index].structure
def step(self, delta):
"""Move through the ensemble, stopping at the ends rather than wrapping.
Rotation, zoom and framing deliberately survive the move: comparing
frames is the whole point, and a view that reset itself each step
would make that impossible.
"""
target = int(np.clip(self.index + delta, 0, len(self.frames) - 1))
if target == self.index:
return
was_crystal = self.is_crystal
self.index = target
if self.is_crystal != was_crystal: # mixed ensemble; the view no longer applies
self.rotation, self.direction = default_view(self.structure)
self.frame = None
def aim(self, indices, kind):
self.rotation = view_rotation(indices, kind, self.structure
if self.is_crystal else None)
self.direction = format_direction(indices, kind)
self.frame = None # a new direction deserves a fresh tight fit
@property
def is_crystal(self):
return isinstance(self.structure, Crystal)
def views(self):
return ["structure", "powder"] if self.is_crystal else ["structure"]
def memo(self, key, build):
if key not in self.cache:
if len(self.cache) > 8:
self.cache.clear()
self.cache[key] = build()
return self.cache[key]
|
step(delta)
Move through the ensemble, stopping at the ends rather than wrapping.
Rotation, zoom and framing deliberately survive the move: comparing
frames is the whole point, and a view that reset itself each step
would make that impossible.
Source code in chmpy/tui/app.py
| def step(self, delta):
"""Move through the ensemble, stopping at the ends rather than wrapping.
Rotation, zoom and framing deliberately survive the move: comparing
frames is the whole point, and a view that reset itself each step
would make that impossible.
"""
target = int(np.clip(self.index + delta, 0, len(self.frames) - 1))
if target == self.index:
return
was_crystal = self.is_crystal
self.index = target
if self.is_crystal != was_crystal: # mixed ensemble; the view no longer applies
self.rotation, self.direction = default_view(self.structure)
self.frame = None
|
default_view(structure)
Down c for a crystal, down z for a molecule already in its PCA frame.
Source code in chmpy/tui/app.py
| def default_view(structure):
"""Down c for a crystal, down z for a molecule already in its PCA frame."""
if isinstance(structure, Crystal):
return view_rotation((0, 0, 1), "uvw", structure), "[001]"
return np.eye(3), "z"
|
draw(state, terminal, oversample, size=None)
Repaint the screen.
Every row is placed with an absolute cursor move and nothing emits a
trailing newline. Letting the rows flow with \n scrolls the screen the
moment the content is one line too tall - and a graphics-protocol image
advances the cursor by its full height - which silently pushes the header
off the top. Text-only views never showed the problem, which is what made
it look like the header was missing only in the render view.
size is the (columns, rows) to draw for; the caller passes the size it
measured so a window resized mid-frame is noticed rather than missed.
Source code in chmpy/tui/app.py
| def draw(state, terminal, oversample, size=None):
"""Repaint the screen.
Every row is placed with an absolute cursor move and nothing emits a
trailing newline. Letting the rows flow with \\n scrolls the screen the
moment the content is one line too tall - and a graphics-protocol image
advances the cursor by its full height - which silently pushes the header
off the top. Text-only views never showed the problem, which is what made
it look like the header was missing only in the render view.
`size` is the (columns, rows) to draw for; the caller passes the size it
measured so a window resized mid-frame is noticed rather than missed.
"""
cols, rows = size or tty.size()
width = max(20, cols - 2)
top, status_rows = 1, 2
body_top = top + 1
body_rows = max(4, rows - top - status_rows)
t0 = time.monotonic()
payload, kind = render_body(state, terminal, width, body_rows, oversample)
elapsed = (time.monotonic() - t0) * 1000
out = [tty.DELETE_IMAGES]
out.append(tty.goto(top, 1) + tty.CLEAR_LINE)
out.append(title_bar(state, terminal, width).render(colors=terminal.colors))
# wipe the body before drawing, since an image leaves no text to overwrite
for i in range(body_rows):
out.append(tty.goto(body_top + i, 1) + tty.CLEAR_LINE)
if kind == "canvas":
lines = payload.render(colors=terminal.colors).split("\n")
elif terminal.graphics:
show_cols = min(width, max(8, payload.shape[1] // oversample))
lines = [display(payload, terminal, cols=show_cols).rstrip("\n")]
else:
lines = payload.to_canvas().render(colors=terminal.colors).split("\n")
for i, line in enumerate(lines[:body_rows]):
out.append(tty.goto(body_top + i, 1) + line)
status = status_bar(state, width, elapsed).render(colors=terminal.colors)
for i, line in enumerate(status.split("\n")[:status_rows]):
out.append(
tty.goto(rows - status_rows + 1 + i, 1) + tty.CLEAR_LINE + line
)
sys.stdout.write("".join(out))
sys.stdout.flush()
|
handle(state, key)
Update state for a keypress. Returns False to quit.
Source code in chmpy/tui/app.py
| def handle(state, key):
"""Update state for a keypress. Returns False to quit."""
if state.prompt is not None:
return handle_prompt(state, key)
state.message = ""
views = state.views()
if key in ("q", "ctrl-c", "escape"):
return False
if key == "tab":
state.view = views[(views.index(state.view) + 1) % len(views)]
elif key == "shift-tab":
state.view = views[(views.index(state.view) - 1) % len(views)]
elif key == "n":
state.step(1)
elif key == "p":
state.step(-1)
elif key == "N":
state.step(10)
elif key == "P":
state.step(-10)
elif key == "r":
state.rotation, state.direction = default_view(state.structure)
state.zoom = 1.0
state.frame = None
elif state.view == "structure":
if key in ("left", "h"):
state.rotation = YAW.T @ state.rotation
state.direction = ""
elif key in ("right", "l"):
state.rotation = YAW @ state.rotation
state.direction = ""
elif key in ("up", "k"):
state.rotation = PITCH.T @ state.rotation
state.direction = ""
elif key in ("down", "j"):
state.rotation = PITCH @ state.rotation
state.direction = ""
elif key == "d":
state.prompt = ""
elif key in ("1", "2", "3"):
state.aim(*QUICK_AXES[state.is_crystal][int(key) - 1][1:])
elif key in ("+", "="):
state.zoom = min(state.zoom * 1.2, 12.0)
elif key in ("-", "_"):
state.zoom = max(state.zoom / 1.2, 0.2)
elif key == "s":
state.style = (
"ball-and-stick" if state.style == "space-filling" else "space-filling"
)
state.frame = None
elif key == "f":
state.shading = "flat" if state.shading == "lit" else "lit"
elif key == "c" and state.is_crystal:
state.cells = state.cells % 3 + 1
state.frame = None
return True
|
handle_prompt(state, key)
Keys while a direction is being typed. Never quits.
Source code in chmpy/tui/app.py
| def handle_prompt(state, key):
"""Keys while a direction is being typed. Never quits."""
if key == "enter":
text, state.prompt = state.prompt, None
if text.strip():
try:
state.aim(*parse_direction(text))
except ValueError as exc:
state.message = str(exc)
elif key == "escape":
state.prompt = None
elif key == "backspace":
state.prompt = state.prompt[:-1]
elif len(key) == 1 and key.isprintable():
state.prompt += key
return True
|
The identity of what is on screen, most important first.
Leads with whatever the file calls the structure - a CIF data block name,
an SDF title - rather than the file name, which is often a database code
and tells you nothing about what you are looking at.
Source code in chmpy/tui/app.py
| def header_segments(state):
"""The identity of what is on screen, most important first.
Leads with whatever the file calls the structure - a CIF data block name,
an SDF title - rather than the file name, which is often a database code
and tells you nothing about what you are looking at.
"""
t = state.theme
frame = state.frames[state.index]
structure = state.structure
segments = [(frame.title, t.text)]
# a CIF block name usually becomes the structure title as well; showing it
# twice pushes the genuinely different fields off the end of the line
if frame.label and frame.label != frame.title:
segments.append((frame.label, t.accent))
formula = (
structure.molecular_formula
if hasattr(structure, "molecular_formula")
else structure.asymmetric_unit.formula
)
# some formats (PDB) name a structure by its formula; do not say it twice
if formula != frame.title:
segments.append((formula, t.dim))
if state.is_crystal:
segments.append((structure.space_group.symbol, t.dim))
uc = structure.unit_cell
segments.append((f"{uc.a:.2f} {uc.b:.2f} {uc.c:.2f} Å", t.dim))
# only worth showing when it says something the title does not
if Path(frame.source).stem != frame.title:
segments.append((frame.source, t.dim))
return segments
|
render_body(state, terminal, cols, rows, oversample)
Produce (payload, kind) where kind is 'pixels' or 'canvas'.
Source code in chmpy/tui/app.py
| def render_body(state, terminal, cols, rows, oversample):
"""Produce (payload, kind) where kind is 'pixels' or 'canvas'."""
if state.view not in state.views():
state.view = "structure"
if state.view == "powder":
pattern = state.memo(
("powder", state.index),
lambda: state.structure.powder_pattern(two_theta_range=(5, 55)),
)
return powder_plot(
pattern, ncols=cols, height=max(6, rows - 5), theme=state.theme
), "canvas"
factor = oversample if terminal.graphics else 1
scene = build_scene(state)
rotation = state.rotation
# start tight, then only ever grow: a fit recomputed from scratch each
# frame would make the structure swell and shrink as it turns, and a fixed
# rotation-invariant bound wastes most of the frame
needed = scene.view_extent(rotation)
state.frame = (
needed
if state.frame is None
else (max(state.frame[0], needed[0]), max(state.frame[1], needed[1]))
)
px = render_scene(
scene,
rotation,
width=cols * factor,
height=rows * 2 * factor,
zoom=state.zoom,
theme=state.theme,
shading=state.shading,
extent=state.frame,
)
return px, "pixels"
|
run(frames, terminal, args=None)
Run the interactive loop over an already-loaded ensemble.
Source code in chmpy/tui/app.py
| def run(frames, terminal, args=None):
"""Run the interactive loop over an already-loaded ensemble."""
state = State(frames=frames, theme=theme_for(tty.query_background()))
if args is not None:
state.style = getattr(args, "style", state.style)
state.shading = getattr(args, "shading", state.shading)
state.cells = getattr(args, "cells", state.cells)
if getattr(args, "direction", None):
state.aim(*parse_direction(args.direction))
with tty.fullscreen():
oversample = FULL_OVERSAMPLE
dirty, drafting = True, False
size = tty.size()
while True:
if dirty:
size = tty.size()
draw(state, terminal, oversample, size)
dirty = False
key = tty.read_key(timeout=0.15 if drafting else IDLE_TIMEOUT)
if key is None:
if drafting: # input stopped: redraw at full quality
drafting, oversample, dirty = False, FULL_OVERSAMPLE, True
elif tty.size() != size:
# polled rather than driven by SIGWINCH: a signal handler
# does not interrupt the select() above, so it would not
# repaint until the next keypress anyway
dirty = True
continue
if not handle(state, key):
break
tty.drain() # do not queue frames for a held-down key
drafting, oversample, dirty = True, DRAFT_OVERSAMPLE, True
return 0
|
title_bar(state, terminal, cols)
Where you are, what you are looking at, and how it is being drawn.
Laid out right to left so the fixed-width indicators keep their place,
then filled left to right dropping whole segments that will not fit
rather than truncating one mid-word.
Source code in chmpy/tui/app.py
| def title_bar(state, terminal, cols):
"""Where you are, what you are looking at, and how it is being drawn.
Laid out right to left so the fixed-width indicators keep their place,
then filled left to right dropping whole segments that will not fit
rather than truncating one mid-word.
"""
t = state.theme
bar = Canvas(1, cols)
# most important first; the rightmost is dropped first when space runs out
right = []
if state.direction and state.view == "structure":
right.append((f"along {state.direction}", t.accent))
mode = "images" if terminal.graphics else f"{terminal.colors} blocks"
right.append((mode, t.dim))
# never let the indicators crowd out the name of the thing on screen
budget = cols // 2
while right and sum(len(text) + 2 for text, _ in right) > budget:
right.pop()
limit = cols
for text, colour in reversed(right):
limit -= len(text) + 2
bar.text(0, limit + 1, text, colour)
x = 1
if len(state.frames) > 1:
counter = f"{state.index + 1}/{len(state.frames)}"
if x + len(counter) < limit:
bar.text(0, x, counter, t.accent)
x += len(counter) + 2
for i, (text, colour) in enumerate(header_segments(state)):
if not text:
continue
if i and x + 3 + len(text) > limit:
break
if i:
bar.text(0, x, "·", t.dim)
x += 2
bar.text(0, x, text[: max(0, limit - x)], colour)
x += len(text) + 1
return bar
|