Skip to content

Commit 67ac9f5

Browse files
committed
Refactor content cropping and line width calculation for improved handling of tab and double-width characters
1 parent 865938f commit 67ac9f5

File tree

11 files changed

+569
-62
lines changed

11 files changed

+569
-62
lines changed

src/omnipy/data/_display/helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from rich._cell_widths import CELL_WIDTHS
2+
from rich.cells import _is_single_cell_widths
23

34
from omnipy.util.range_lookup import RangeLookup
45

@@ -26,6 +27,14 @@ def __getitem__(self, char: str) -> int:
2627
return 2
2728
return 1
2829

30+
@staticmethod
31+
def only_single_width_chars(line: str) -> bool:
32+
return _is_single_cell_widths(line)
33+
34+
def __hash__(self) -> int:
35+
# No state, so we can use a constant hash
36+
return hash('UnicodeCharWidthMap')
37+
2938

3039
def soft_wrap_words(words: list[str], max_width: int) -> list[str]:
3140
"""Wrap words into lines that don't exceed max_width.

src/omnipy/data/_display/panel/base.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from typing_extensions import TypeIs, TypeVar
66

7-
from omnipy.data._display.dimensions import Dimensions, DimensionsFit
7+
from omnipy.data._display.dimensions import (Dimensions,
8+
DimensionsFit,
9+
DimensionsWithWidthAndHeight,
10+
has_height,
11+
has_width)
812
from omnipy.data._display.frame import AnyFrame, empty_frame, Frame
913
import omnipy.util._pydantic as pyd
1014

@@ -70,12 +74,37 @@ def panel_is_fully_rendered(panel: 'Panel') -> TypeIs['FullyRenderedPanel']:
7074
return isinstance(panel, FullyRenderedPanel)
7175

7276

77+
def dims_if_cropped(
78+
dims: DimensionsWithWidthAndHeight,
79+
frame: AnyFrame,
80+
) -> DimensionsWithWidthAndHeight:
81+
if has_width(frame.dims):
82+
cropped_width = min(frame.dims.width, dims.width)
83+
else:
84+
cropped_width = dims.width
85+
86+
if has_height(frame.dims):
87+
cropped_height = min(frame.dims.height, dims.height)
88+
else:
89+
cropped_height = dims.height
90+
91+
return Dimensions(width=cropped_width, height=cropped_height)
92+
93+
7394
class DimensionsAwarePanel(Panel[FrameT], Generic[FrameT]):
7495
@cached_property
7596
@abstractmethod
7697
def dims(self) -> Dimensions[pyd.NonNegativeInt, pyd.NonNegativeInt]:
7798
...
7899

100+
@cached_property
101+
def dims_if_cropped(self,) -> DimensionsWithWidthAndHeight:
102+
"""
103+
Returns the dimensions of the panel, cropped to fit within the
104+
frame dimensions.
105+
"""
106+
return dims_if_cropped(self.dims, self.frame)
107+
79108
@cached_property
80109
def within_frame(self) -> DimensionsFit:
81110
return DimensionsFit(self.dims, self.frame.dims)

src/omnipy/data/_display/panel/draft/layout.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,18 +182,7 @@ def _priority(
182182
@cache
183183
def _panel_dims_if_cropped(
184184
panel: DimensionsAwarePanel[AnyFrame]) -> DimensionsWithWidthAndHeight:
185-
186-
if has_width(panel.frame.dims):
187-
cropped_width = min(panel.frame.dims.width, panel.dims.width)
188-
else:
189-
cropped_width = panel.dims.width
190-
191-
if has_height(panel.frame.dims):
192-
cropped_height = min(panel.frame.dims.height, panel.dims.height)
193-
else:
194-
cropped_height = panel.dims.height
195-
196-
return Dimensions(width=cropped_width, height=cropped_height)
185+
return panel.dims_if_cropped
197186

198187
@staticmethod
199188
@cache

src/omnipy/data/_display/panel/draft/monospaced.py

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from abc import ABC, abstractmethod
2-
from functools import cached_property
2+
from dataclasses import dataclass
3+
from functools import cached_property, lru_cache
34
from typing import ClassVar, Generic
45

5-
from omnipy.data._display.dimensions import Dimensions, has_width_and_height
6+
from omnipy.data._display.config import HorizontalOverflowMode, OutputConfig
7+
from omnipy.data._display.dimensions import Dimensions, has_width, has_width_and_height
68
from omnipy.data._display.frame import AnyFrame
79
from omnipy.data._display.helpers import UnicodeCharWidthMap
810
from omnipy.data._display.panel.base import DimensionsAwarePanel, FrameT
@@ -25,19 +27,17 @@ class MonospacedDraftPanel(
2527
def _content_lines(self) -> list[str]:
2628
...
2729

30+
def _line_width(self, line):
31+
stats = _calc_line_stats(
32+
line,
33+
self.config.tab_size,
34+
self._char_width_map,
35+
)
36+
return stats.line_width
37+
2838
@cached_property
2939
def _width(self) -> pyd.NonNegativeInt:
30-
def _line_len(line: str) -> int:
31-
tab_size = self.config.tab_size
32-
line_len = 0
33-
for c in line:
34-
if c == '\t':
35-
line_len += tab_size - (line_len % tab_size)
36-
else:
37-
line_len += self._char_width_map[c]
38-
return line_len
39-
40-
return max((_line_len(line) for line in self._content_lines), default=0)
40+
return max((self._line_width(line) for line in self._content_lines), default=0)
4141

4242
@cached_property
4343
def _height(self) -> pyd.NonNegativeInt:
@@ -48,6 +48,47 @@ def dims(self) -> Dimensions[pyd.NonNegativeInt, pyd.NonNegativeInt]:
4848
return Dimensions(width=self._width, height=self._height)
4949

5050

51+
@dataclass
52+
class LineStats:
53+
line_width: pyd.NonNegativeInt = 0
54+
char_count: pyd.NonNegativeInt = 0
55+
overflow: bool = False
56+
57+
def register_char(self, char_width: pyd.NonNegativeInt):
58+
self.line_width += char_width
59+
self.char_count += 1
60+
61+
62+
@lru_cache(maxsize=4096)
63+
def _calc_line_stats(
64+
line: str,
65+
tab_size: int,
66+
char_width_map: UnicodeCharWidthMap,
67+
width_limit: pyd.NonNegativeInt | None = None,
68+
) -> LineStats:
69+
if char_width_map.only_single_width_chars(line):
70+
return LineStats(
71+
line_width=len(line),
72+
char_count=len(line),
73+
)
74+
else:
75+
stats = LineStats()
76+
for ch in line:
77+
if ch == '\t':
78+
char_width = tab_size - (stats.line_width % tab_size)
79+
else:
80+
char_width = char_width_map[ch]
81+
82+
if width_limit is not None:
83+
if stats.line_width + char_width > width_limit:
84+
stats.overflow = True
85+
return stats
86+
87+
stats.register_char(char_width)
88+
89+
return stats
90+
91+
5192
def crop_content_lines_for_resizing(
5293
all_content_lines: list[str],
5394
frame: AnyFrame,
@@ -71,3 +112,39 @@ def crop_content_lines_for_resizing(
71112
return all_content_lines[:frame.dims.height]
72113

73114
return all_content_lines
115+
116+
117+
def crop_content_with_extra_wide_chars(
118+
all_content_lines: list[str],
119+
frame: AnyFrame,
120+
config: OutputConfig,
121+
char_width_map: UnicodeCharWidthMap,
122+
) -> list[str]:
123+
if has_width(frame.dims) \
124+
and frame.fixed_width is False \
125+
and frame.dims.width > 0:
126+
127+
ellipsis_if_overflow = config.horizontal_overflow_mode == HorizontalOverflowMode.ELLIPSIS
128+
129+
for i, line in enumerate(all_content_lines):
130+
stats = _calc_line_stats(
131+
line,
132+
config.tab_size,
133+
char_width_map,
134+
width_limit=frame.dims.width,
135+
)
136+
137+
cropped_line = line[:stats.char_count]
138+
139+
if stats.overflow and ellipsis_if_overflow:
140+
if stats.line_width == frame.dims.width > 0:
141+
# Exactly at the limit, must remove the last character
142+
# to have space for the ellipsis
143+
cropped_line = cropped_line[:-1]
144+
cropped_line += '…'
145+
146+
cropped_line = cropped_line.rstrip('\t')
147+
148+
all_content_lines[i] = cropped_line
149+
150+
return all_content_lines

src/omnipy/data/_display/panel/draft/text.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from functools import cached_property
22
import re
3-
from typing import ClassVar, Generic
3+
from typing import Generic
44

55
from omnipy.data._display.constraints import ConstraintsSatisfaction
6-
from omnipy.data._display.helpers import UnicodeCharWidthMap
76
from omnipy.data._display.panel.base import FrameT, FullyRenderedPanel
87
from omnipy.data._display.panel.draft.monospaced import (crop_content_lines_for_resizing,
8+
crop_content_with_extra_wide_chars,
99
MonospacedDraftPanel)
1010
import omnipy.util._pydantic as pyd
1111

@@ -16,16 +16,20 @@ class ReflowedTextDraftPanel(
1616
MonospacedDraftPanel[str, FrameT],
1717
Generic[FrameT],
1818
):
19-
_char_width_map: ClassVar[UnicodeCharWidthMap] = UnicodeCharWidthMap()
20-
2119
@cached_property
2220
def _content_lines(self) -> list[str]:
2321
# Typical repr output should not end with newline. Hence, a regular split on newline is
2422
# correct behaviour. An empty string is the split into a list of one element. If
2523
# splitlines() had been used, the list would be empty.
2624
all_content_lines = self.content.split('\n')
27-
28-
return crop_content_lines_for_resizing(all_content_lines, self.frame)
25+
all_content_lines = crop_content_lines_for_resizing(all_content_lines, self.frame)
26+
all_content_lines = crop_content_with_extra_wide_chars(
27+
all_content_lines,
28+
self.frame,
29+
self.config,
30+
self._char_width_map,
31+
)
32+
return all_content_lines
2933

3034
@cached_property
3135
def max_container_width_across_lines(self) -> pyd.NonNegativeInt:

src/omnipy/data/_display/panel/styling/base.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,8 @@
1717
from typing_extensions import override, TypeVar
1818

1919
from omnipy.data._display.config import ConsoleColorSystem, HorizontalOverflowMode
20-
from omnipy.data._display.dimensions import (Dimensions,
21-
DimensionsWithWidthAndHeight,
22-
has_height,
23-
has_width)
24-
from omnipy.data._display.panel.base import FullyRenderedPanel, OutputVariant
20+
from omnipy.data._display.dimensions import Dimensions, DimensionsWithWidthAndHeight
21+
from omnipy.data._display.panel.base import dims_if_cropped, FullyRenderedPanel, OutputVariant
2522
from omnipy.data._display.panel.draft.base import ContentT, FrameT
2623
from omnipy.data._display.panel.draft.monospaced import MonospacedDraftPanel
2724
import omnipy.util._pydantic as pyd
@@ -42,19 +39,19 @@ class StylizedMonospacedPanel(
4239
Generic[PanelT, ContentT, FrameT],
4340
ABC,
4441
):
45-
# TODO: Return to _panel_calculated_dims when Pydantic 2.0 is released
42+
# TODO: Return to _input_panel_dims_if_cropped when Pydantic 2.0 is released
4643
#
4744
# Pydantic 1.10 emits a RuntimeWarning for dataclasses with private
4845
# fields (starting with '_')
4946
# See https://github.com/pydantic/pydantic/issues/2816
50-
# _panel_calculated_dims: DimensionsWithWidthAndHeight = Dimensions(width=0, height=0)
47+
# _input_panel_dims_if_croppeds: DimensionsWithWidthAndHeight = Dimensions(width=0, height=0)
5148

52-
panel_calculated_dims: DimensionsWithWidthAndHeight = Dimensions(width=0, height=0)
49+
input_panel_dims_if_cropped: DimensionsWithWidthAndHeight = Dimensions(width=0, height=0)
5350

5451
def __init__(self, panel: MonospacedDraftPanel[ContentT, FrameT]):
5552
super().__init__(panel.content, panel.frame, panel.constraints, panel.config)
56-
# object.__setattr__(self, '_panel_calculated_dims', panel.dims)
57-
object.__setattr__(self, 'panel_calculated_dims', panel.dims)
53+
# object.__setattr__(self, '_input_panel_dims_if_cropped', panel.dims_if_cropped)
54+
object.__setattr__(self, 'input_panel_dims_if_cropped', panel.dims_if_cropped)
5855

5956
@staticmethod
6057
def _clean_rich_style_caches():
@@ -98,23 +95,16 @@ def _stylized_content_html_impl(self) -> StylizedRichTypes:
9895

9996
@cached_property
10097
def _console_dimensions(self) -> DimensionsWithWidthAndHeight:
101-
frame_dims = self.frame.dims
102-
panel_dims = self.panel_calculated_dims
103-
104-
console_width = frame_dims.width if has_width(frame_dims) else panel_dims.width
105-
console_height = frame_dims.height if has_height(frame_dims) else panel_dims.height
106-
107-
return self._apply_console_newline_hack(
108-
Dimensions(width=console_width, height=console_height))
98+
input_panel_dims = self.input_panel_dims_if_cropped
99+
return self._apply_console_newline_hack(dims_if_cropped(input_panel_dims, self.frame))
109100

110101
def _apply_console_newline_hack(
111102
self, console_dims: DimensionsWithWidthAndHeight) -> DimensionsWithWidthAndHeight:
112103
"""
113104
Hack to allow rich.console to output newline contents when
114105
width == 0
115106
"""
116-
panel_dims = self.panel_calculated_dims
117-
if panel_dims.width == 0 and console_dims.width == 0 and console_dims.height > 0:
107+
if console_dims.width == 0 and console_dims.height > 0:
118108
return Dimensions(width=1, height=console_dims.height)
119109

120110
return console_dims
@@ -183,7 +173,6 @@ def _console_html(self) -> rich.console.Console:
183173
def _content_lines(self) -> list[str]:
184174
"""
185175
Returns the plain terminal output of the panel as a list of lines.
186-
Here, as compared to output should end with a
187176
"""
188177
return self.plain.terminal.splitlines()
189178

0 commit comments

Comments
 (0)