feat(assessment): auto comprehensive assessment - rules engine from college scoring standard, per-semester stats, manual overrides, word export
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""Wrap content lines to the terminal width before rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Self
|
||||
|
||||
from .content import ContentFragment, ContentLine
|
||||
from .types import CursorXY, ScreenInfoRow
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LayoutRow:
|
||||
"""Metadata for one physical screen row.
|
||||
|
||||
For the row ``>>> def greet(name):``::
|
||||
|
||||
>>> def greet(name):
|
||||
╰─╯ ╰──────────────╯
|
||||
4 char_widths=(1,1,1,…) ← 16 entries
|
||||
buffer_advance=17 ← includes the newline
|
||||
"""
|
||||
|
||||
prompt_width: int
|
||||
char_widths: tuple[int, ...]
|
||||
suffix_width: int = 0
|
||||
buffer_advance: int = 0
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.prompt_width + sum(self.char_widths) + self.suffix_width
|
||||
|
||||
@property
|
||||
def screeninfo(self) -> ScreenInfoRow:
|
||||
widths = list(self.char_widths)
|
||||
if self.suffix_width:
|
||||
widths.append(self.suffix_width)
|
||||
return self.prompt_width, widths
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LayoutMap:
|
||||
"""Mapping between buffer positions and screen coordinates.
|
||||
|
||||
Single source of truth for cursor placement. Given::
|
||||
|
||||
>>> def greet(name): ← row 0, buffer_advance=17
|
||||
... return name ← row 1, buffer_advance=15
|
||||
▲cursor
|
||||
|
||||
``pos_to_xy(31)`` → ``(18, 1)``: prompt width 4 + 14 body chars.
|
||||
"""
|
||||
rows: tuple[LayoutRow, ...]
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> Self:
|
||||
return cls((LayoutRow(0, ()),))
|
||||
|
||||
@property
|
||||
def screeninfo(self) -> list[ScreenInfoRow]:
|
||||
return [row.screeninfo for row in self.rows]
|
||||
|
||||
def max_column(self, y: int) -> int:
|
||||
return self.rows[y].width
|
||||
|
||||
def max_row(self) -> int:
|
||||
return len(self.rows) - 1
|
||||
|
||||
def pos_to_xy(self, pos: int) -> CursorXY:
|
||||
if not self.rows:
|
||||
return 0, 0
|
||||
|
||||
remaining = pos
|
||||
for y, row in enumerate(self.rows):
|
||||
if remaining <= len(row.char_widths):
|
||||
# Prompt-only leading rows are terminal scenery, not real
|
||||
# buffer positions. Treating them as real just manufactures
|
||||
# bugs.
|
||||
if remaining == 0 and not row.char_widths and row.buffer_advance == 0 and y < len(self.rows) - 1:
|
||||
continue
|
||||
x = row.prompt_width
|
||||
for width in row.char_widths[:remaining]:
|
||||
x += width
|
||||
return x, y
|
||||
remaining -= row.buffer_advance
|
||||
last_row = self.rows[-1]
|
||||
return last_row.width - last_row.suffix_width, len(self.rows) - 1
|
||||
|
||||
def xy_to_pos(self, x: int, y: int) -> int:
|
||||
if not self.rows:
|
||||
return 0
|
||||
|
||||
pos = 0
|
||||
for row in self.rows[:y]:
|
||||
pos += row.buffer_advance
|
||||
|
||||
row = self.rows[y]
|
||||
cur_x = row.prompt_width
|
||||
char_widths = row.char_widths
|
||||
i = 0
|
||||
for i, width in enumerate(char_widths):
|
||||
if cur_x >= x:
|
||||
# Include trailing zero-width (combining) chars at this position
|
||||
for trailing_width in char_widths[i:]:
|
||||
if trailing_width == 0:
|
||||
pos += 1
|
||||
else:
|
||||
break
|
||||
return pos
|
||||
if width == 0:
|
||||
pos += 1
|
||||
continue
|
||||
cur_x += width
|
||||
pos += 1
|
||||
return pos
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WrappedRow:
|
||||
"""One physical screen row after wrapping, ready for rendering.
|
||||
|
||||
When a line overflows the terminal width, it splits into
|
||||
multiple rows with a ``\\`` continuation marker::
|
||||
|
||||
>>> x = "a very long li\\ ← suffix="\\", suffix_width=1
|
||||
ne that wraps" ← prompt_text="" (continuation)
|
||||
"""
|
||||
prompt_text: str = ""
|
||||
prompt_width: int = 0
|
||||
fragments: tuple[ContentFragment, ...] = ()
|
||||
layout_widths: tuple[int, ...] = ()
|
||||
suffix: str = ""
|
||||
suffix_width: int = 0
|
||||
buffer_advance: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LayoutResult:
|
||||
wrapped_rows: tuple[WrappedRow, ...]
|
||||
layout_map: LayoutMap
|
||||
line_end_offsets: tuple[int, ...]
|
||||
|
||||
|
||||
def layout_content_lines(
|
||||
lines: tuple[ContentLine, ...],
|
||||
width: int,
|
||||
start_offset: int,
|
||||
) -> LayoutResult:
|
||||
"""Wrap content lines to fit *width* columns.
|
||||
|
||||
A short line passes through as one ``WrappedRow``; a long line is
|
||||
split at the column boundary with ``\\`` markers::
|
||||
|
||||
>>> short = 1 ← one WrappedRow
|
||||
>>> x = "a long stri\\ ← two WrappedRows, first has suffix="\\"
|
||||
ng"
|
||||
"""
|
||||
if width <= 0:
|
||||
return LayoutResult((), LayoutMap(()), ())
|
||||
|
||||
offset = start_offset
|
||||
wrapped_rows: list[WrappedRow] = []
|
||||
layout_rows: list[LayoutRow] = []
|
||||
line_end_offsets: list[int] = []
|
||||
|
||||
for line in lines:
|
||||
newline_advance = int(line.source.has_newline)
|
||||
for leading in line.prompt.leading_lines:
|
||||
line_end_offsets.append(offset)
|
||||
wrapped_rows.append(
|
||||
WrappedRow(
|
||||
fragments=(leading,),
|
||||
)
|
||||
)
|
||||
layout_rows.append(LayoutRow(0, (), buffer_advance=0))
|
||||
|
||||
prompt_text = line.prompt.text
|
||||
prompt_width = line.prompt.width
|
||||
body = tuple(line.body)
|
||||
body_widths = tuple(fragment.width for fragment in body)
|
||||
|
||||
# Fast path: line fits on one row.
|
||||
if not body_widths or (sum(body_widths) + prompt_width) < width:
|
||||
offset += len(body) + newline_advance
|
||||
line_end_offsets.append(offset)
|
||||
wrapped_rows.append(
|
||||
WrappedRow(
|
||||
prompt_text=prompt_text,
|
||||
prompt_width=prompt_width,
|
||||
fragments=body,
|
||||
layout_widths=body_widths,
|
||||
buffer_advance=len(body) + newline_advance,
|
||||
)
|
||||
)
|
||||
layout_rows.append(
|
||||
LayoutRow(
|
||||
prompt_width,
|
||||
body_widths,
|
||||
buffer_advance=len(body) + newline_advance,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Slow path: line needs wrapping.
|
||||
current_prompt = prompt_text
|
||||
current_prompt_width = prompt_width
|
||||
start = 0
|
||||
total = len(body)
|
||||
while True:
|
||||
# Find how many characters fit on this row.
|
||||
index_to_wrap_before = 0
|
||||
column = 0
|
||||
for char_width in body_widths[start:]:
|
||||
if column + char_width + current_prompt_width >= width:
|
||||
break
|
||||
index_to_wrap_before += 1
|
||||
column += char_width
|
||||
|
||||
if index_to_wrap_before == 0 and start < total:
|
||||
index_to_wrap_before = 1 # force progress
|
||||
|
||||
at_line_end = (start + index_to_wrap_before) >= total
|
||||
if at_line_end:
|
||||
offset += index_to_wrap_before + newline_advance
|
||||
suffix = ""
|
||||
suffix_width = 0
|
||||
buffer_advance = index_to_wrap_before + newline_advance
|
||||
else:
|
||||
offset += index_to_wrap_before
|
||||
suffix = "\\"
|
||||
suffix_width = 1
|
||||
buffer_advance = index_to_wrap_before
|
||||
|
||||
end = start + index_to_wrap_before
|
||||
row_fragments = body[start:end]
|
||||
row_widths = body_widths[start:end]
|
||||
line_end_offsets.append(offset)
|
||||
wrapped_rows.append(
|
||||
WrappedRow(
|
||||
prompt_text=current_prompt,
|
||||
prompt_width=current_prompt_width,
|
||||
fragments=row_fragments,
|
||||
layout_widths=row_widths,
|
||||
suffix=suffix,
|
||||
suffix_width=suffix_width,
|
||||
buffer_advance=buffer_advance,
|
||||
)
|
||||
)
|
||||
layout_rows.append(
|
||||
LayoutRow(
|
||||
current_prompt_width,
|
||||
row_widths,
|
||||
suffix_width=suffix_width,
|
||||
buffer_advance=buffer_advance,
|
||||
)
|
||||
)
|
||||
|
||||
start = end
|
||||
current_prompt = ""
|
||||
current_prompt_width = 0
|
||||
if at_line_end:
|
||||
break
|
||||
|
||||
return LayoutResult(
|
||||
tuple(wrapped_rows),
|
||||
LayoutMap(tuple(layout_rows)),
|
||||
tuple(line_end_offsets),
|
||||
)
|
||||
Reference in New Issue
Block a user