urwid 2.6.0.post0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of urwid might be problematic. Click here for more details.

Files changed (75) hide show
  1. urwid/__init__.py +333 -0
  2. urwid/canvas.py +1413 -0
  3. urwid/command_map.py +137 -0
  4. urwid/container.py +59 -0
  5. urwid/decoration.py +65 -0
  6. urwid/display/__init__.py +97 -0
  7. urwid/display/_posix_raw_display.py +413 -0
  8. urwid/display/_raw_display_base.py +914 -0
  9. urwid/display/_web.css +12 -0
  10. urwid/display/_web.js +462 -0
  11. urwid/display/_win32.py +171 -0
  12. urwid/display/_win32_raw_display.py +269 -0
  13. urwid/display/common.py +1219 -0
  14. urwid/display/curses.py +690 -0
  15. urwid/display/escape.py +624 -0
  16. urwid/display/html_fragment.py +251 -0
  17. urwid/display/lcd.py +518 -0
  18. urwid/display/raw.py +37 -0
  19. urwid/display/web.py +636 -0
  20. urwid/event_loop/__init__.py +55 -0
  21. urwid/event_loop/abstract_loop.py +175 -0
  22. urwid/event_loop/asyncio_loop.py +231 -0
  23. urwid/event_loop/glib_loop.py +294 -0
  24. urwid/event_loop/main_loop.py +721 -0
  25. urwid/event_loop/select_loop.py +230 -0
  26. urwid/event_loop/tornado_loop.py +206 -0
  27. urwid/event_loop/trio_loop.py +302 -0
  28. urwid/event_loop/twisted_loop.py +269 -0
  29. urwid/event_loop/zmq_loop.py +275 -0
  30. urwid/font.py +695 -0
  31. urwid/graphics.py +96 -0
  32. urwid/highlight.css +19 -0
  33. urwid/listbox.py +1899 -0
  34. urwid/monitored_list.py +522 -0
  35. urwid/numedit.py +376 -0
  36. urwid/signals.py +330 -0
  37. urwid/split_repr.py +130 -0
  38. urwid/str_util.py +358 -0
  39. urwid/text_layout.py +632 -0
  40. urwid/treetools.py +515 -0
  41. urwid/util.py +557 -0
  42. urwid/version.py +16 -0
  43. urwid/vterm.py +1806 -0
  44. urwid/widget/__init__.py +181 -0
  45. urwid/widget/attr_map.py +161 -0
  46. urwid/widget/attr_wrap.py +140 -0
  47. urwid/widget/bar_graph.py +649 -0
  48. urwid/widget/big_text.py +77 -0
  49. urwid/widget/box_adapter.py +126 -0
  50. urwid/widget/columns.py +1145 -0
  51. urwid/widget/constants.py +574 -0
  52. urwid/widget/container.py +227 -0
  53. urwid/widget/divider.py +110 -0
  54. urwid/widget/edit.py +718 -0
  55. urwid/widget/filler.py +403 -0
  56. urwid/widget/frame.py +539 -0
  57. urwid/widget/grid_flow.py +539 -0
  58. urwid/widget/line_box.py +194 -0
  59. urwid/widget/overlay.py +829 -0
  60. urwid/widget/padding.py +597 -0
  61. urwid/widget/pile.py +971 -0
  62. urwid/widget/popup.py +170 -0
  63. urwid/widget/progress_bar.py +141 -0
  64. urwid/widget/scrollable.py +597 -0
  65. urwid/widget/solid_fill.py +44 -0
  66. urwid/widget/text.py +354 -0
  67. urwid/widget/widget.py +852 -0
  68. urwid/widget/widget_decoration.py +166 -0
  69. urwid/widget/wimp.py +792 -0
  70. urwid/wimp.py +23 -0
  71. urwid-2.6.0.post0.dist-info/COPYING +504 -0
  72. urwid-2.6.0.post0.dist-info/METADATA +332 -0
  73. urwid-2.6.0.post0.dist-info/RECORD +75 -0
  74. urwid-2.6.0.post0.dist-info/WHEEL +5 -0
  75. urwid-2.6.0.post0.dist-info/top_level.txt +1 -0
urwid/text_layout.py ADDED
@@ -0,0 +1,632 @@
1
+ # Urwid Text Layout classes
2
+ # Copyright (C) 2004-2011 Ian Ward
3
+ #
4
+ # This library is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU Lesser General Public
6
+ # License as published by the Free Software Foundation; either
7
+ # version 2.1 of the License, or (at your option) any later version.
8
+ #
9
+ # This library is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12
+ # Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public
15
+ # License along with this library; if not, write to the Free Software
16
+ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17
+ #
18
+ # Urwid web site: https://urwid.org/
19
+
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ import typing
25
+
26
+ from urwid.str_util import calc_text_pos, calc_width, get_char_width, is_wide_char, move_next_char, move_prev_char
27
+ from urwid.util import calc_trim_text, get_encoding
28
+
29
+ if typing.TYPE_CHECKING:
30
+ from typing_extensions import Literal
31
+
32
+ from urwid.widget import Align, WrapMode
33
+
34
+
35
+ @functools.lru_cache(maxsize=4)
36
+ def get_ellipsis_char(encoding: str) -> bytes:
37
+ """Get ellipsis character for given encoding."""
38
+ try:
39
+ return "…".encode(encoding)
40
+ except UnicodeEncodeError:
41
+ return b"..."
42
+
43
+
44
+ @functools.lru_cache(maxsize=4)
45
+ def get_ellipsis_width(encoding: str) -> int:
46
+ """Get ellipsis character width for given encoding."""
47
+ return sum(get_char_width(char) for char in get_ellipsis_char(get_encoding()).decode(get_encoding()))
48
+
49
+
50
+ class TextLayout:
51
+ def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
52
+ """Return True if align is a supported align mode."""
53
+ return True
54
+
55
+ def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
56
+ """Return True if wrap is a supported wrap mode."""
57
+ return True
58
+
59
+ def layout(
60
+ self,
61
+ text: str | bytes,
62
+ width: int,
63
+ align: Literal["left", "center", "right"] | Align,
64
+ wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
65
+ ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
66
+ """
67
+ Return a layout structure for text.
68
+
69
+ :param text: string in current encoding or unicode string
70
+ :param width: number of screen columns available
71
+ :param align: align mode for text
72
+ :param wrap: wrap mode for text
73
+
74
+ Layout structure is a list of line layouts, one per output line.
75
+ Line layouts are lists than may contain the following tuples:
76
+
77
+ * (column width of text segment, start offset, end offset)
78
+ * (number of space characters to insert, offset or None)
79
+ * (column width of insert text, offset, "insert text")
80
+
81
+ The offset in the last two tuples is used to determine the
82
+ attribute used for the inserted spaces or text respectively.
83
+ The attribute used will be the same as the attribute at that
84
+ text offset. If the offset is None when inserting spaces
85
+ then no attribute will be used.
86
+ """
87
+ raise NotImplementedError(
88
+ "This function must be overridden by a real text layout class. (see StandardTextLayout)"
89
+ )
90
+
91
+
92
+ class CanNotDisplayText(Exception):
93
+ pass
94
+
95
+
96
+ class StandardTextLayout(TextLayout):
97
+ def __init__(self): # , tab_stops=(), tab_stop_every=8):
98
+ pass
99
+ # """
100
+ # tab_stops -- list of screen column indexes for tab stops
101
+ # tab_stop_every -- repeated interval for following tab stops
102
+ # """
103
+ # assert tab_stop_every is None or type(tab_stop_every)==int
104
+ # if not tab_stops and tab_stop_every:
105
+ # self.tab_stops = (tab_stop_every,)
106
+ # self.tab_stops = tab_stops
107
+ # self.tab_stop_every = tab_stop_every
108
+
109
+ def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
110
+ """Return True if align is 'left', 'center' or 'right'."""
111
+ return align in {"left", "center", "right"}
112
+
113
+ def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
114
+ """Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'."""
115
+ return wrap in {"any", "space", "clip", "ellipsis"}
116
+
117
+ def layout(
118
+ self,
119
+ text: str | bytes,
120
+ width: int,
121
+ align: Literal["left", "center", "right"] | Align,
122
+ wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
123
+ ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
124
+ """Return a layout structure for text."""
125
+ try:
126
+ segs = self.calculate_text_segments(text, width, wrap)
127
+ return self.align_layout(text, width, segs, wrap, align)
128
+ except CanNotDisplayText:
129
+ return [[]]
130
+
131
+ def pack(
132
+ self,
133
+ maxcol: int,
134
+ layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
135
+ ) -> int:
136
+ """Return a minimal maxcol value that would result in the same number of lines for layout.
137
+
138
+ layout must be a layout structure returned by self.layout().
139
+ """
140
+ maxwidth = 0
141
+ if not layout:
142
+ raise ValueError(f"huh? empty layout?: {layout!r}")
143
+ for lines in layout:
144
+ lw = line_width(lines)
145
+ if lw >= maxcol:
146
+ return maxcol
147
+ maxwidth = max(maxwidth, lw)
148
+ return maxwidth
149
+
150
+ def align_layout(
151
+ self,
152
+ text: str | bytes,
153
+ width: int,
154
+ segs: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
155
+ wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
156
+ align: Literal["left", "center", "right"] | Align,
157
+ ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
158
+ """Convert the layout segments to an aligned layout."""
159
+ out = []
160
+ for lines in segs:
161
+ sc = line_width(lines)
162
+ if sc == width or align == "left":
163
+ out.append(lines)
164
+ continue
165
+
166
+ if align == "right":
167
+ out.append([(width - sc, None), *lines])
168
+ continue
169
+ if align != "center":
170
+ raise ValueError(align)
171
+ pad_trim_left = (width - sc + 1) // 2
172
+ out.append([(pad_trim_left, None), *lines] if pad_trim_left else lines)
173
+ return out
174
+
175
+ def _calculate_trimmed_segments(
176
+ self,
177
+ text: str | bytes,
178
+ width: int,
179
+ wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
180
+ ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
181
+ """Calculate text segments for cases of a text trimmed (wrap is clip or ellipsis)."""
182
+ segments = []
183
+
184
+ nl = "\n" if isinstance(text, str) else b"\n"
185
+ ellipsis_char = get_ellipsis_char(get_encoding())
186
+ width_ellipsis = get_ellipsis_width(get_encoding())
187
+
188
+ idx = 0
189
+
190
+ while idx <= len(text):
191
+ nl_pos = text.find(nl, idx)
192
+ if nl_pos == -1:
193
+ nl_pos = len(text)
194
+ screen_columns = calc_width(text, idx, nl_pos)
195
+
196
+ # trim line to max width if needed, add ellipsis if trimmed
197
+ if wrap == "ellipsis" and screen_columns > width:
198
+ trimmed = True
199
+
200
+ start_off, end_off, pad_left, pad_right = calc_trim_text(text, idx, nl_pos, 0, width - width_ellipsis)
201
+ # pad_left should be 0, because the start_col parameter was 0 (no trimming on the left)
202
+ # similarly spos should not be changed from p
203
+ if pad_left != 0:
204
+ raise ValueError(f"Invalid padding for start column==0: {pad_left!r}")
205
+ if start_off != idx:
206
+ raise ValueError(f"Invalid start offset for start column==0 and position={idx!r}: {start_off!r}")
207
+ screen_columns = width - 1 - pad_right
208
+
209
+ else:
210
+ trimmed = False
211
+ end_off = nl_pos
212
+ pad_right = 0
213
+
214
+ line = []
215
+ if idx != end_off:
216
+ line += [(screen_columns, idx, end_off)]
217
+ if trimmed:
218
+ line += [(1, end_off, ellipsis_char)]
219
+ line += [(pad_right, end_off)]
220
+ segments.append(line)
221
+ idx = nl_pos + 1
222
+ return segments
223
+
224
+ def calculate_text_segments(
225
+ self,
226
+ text: str | bytes,
227
+ width: int,
228
+ wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
229
+ ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
230
+ """
231
+ Calculate the segments of text to display given width screen columns to display them.
232
+
233
+ text - unicode text or byte string to display
234
+ width - number of available screen columns
235
+ wrap - wrapping mode used
236
+
237
+ Returns a layout structure without an alignment applied.
238
+ """
239
+ if wrap in {"clip", "ellipsis"}:
240
+ return self._calculate_trimmed_segments(text, width, wrap)
241
+
242
+ nl, nl_o, sp_o = "\n", "\n", " "
243
+ if isinstance(text, bytes):
244
+ nl = b"\n" # can only find bytes in python3 bytestrings
245
+ nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value
246
+ sp_o = ord(sp_o)
247
+ segments = []
248
+ idx = 0
249
+
250
+ while idx <= len(text):
251
+ # look for the next eligible line break
252
+ nl_pos = text.find(nl, idx)
253
+ if nl_pos == -1:
254
+ nl_pos = len(text)
255
+
256
+ screen_columns = calc_width(text, idx, nl_pos)
257
+ if screen_columns == 0:
258
+ # removed character hint
259
+ segments.append([(0, nl_pos)])
260
+ idx = nl_pos + 1
261
+ continue
262
+
263
+ if screen_columns <= width:
264
+ # this segment fits
265
+ segments.append([(screen_columns, idx, nl_pos), (0, nl_pos)])
266
+ # removed character hint
267
+
268
+ idx = nl_pos + 1
269
+ continue
270
+
271
+ pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)
272
+ if pos == idx: # pathological width=1 double-byte case
273
+ raise CanNotDisplayText("Wide character will not fit in 1-column width")
274
+
275
+ if wrap == "any":
276
+ segments.append([(screen_columns, idx, pos)])
277
+ idx = pos
278
+ continue
279
+
280
+ if wrap != "space":
281
+ raise ValueError(wrap)
282
+
283
+ if text[pos] == sp_o:
284
+ # perfect space wrap
285
+ segments.append([(screen_columns, idx, pos), (0, pos)])
286
+ # removed character hint
287
+
288
+ idx = pos + 1
289
+ continue
290
+
291
+ if is_wide_char(text, pos):
292
+ # perfect next wide
293
+ segments.append([(screen_columns, idx, pos)])
294
+ idx = pos
295
+ continue
296
+
297
+ prev = pos
298
+ while prev > idx:
299
+ prev = move_prev_char(text, idx, prev)
300
+ if text[prev] == sp_o:
301
+ screen_columns = calc_width(text, idx, prev)
302
+ line = [(0, prev)]
303
+ if idx != prev:
304
+ line = [(screen_columns, idx, prev), *line]
305
+ segments.append(line)
306
+ idx = prev + 1
307
+ break
308
+
309
+ if is_wide_char(text, prev):
310
+ # wrap after wide char
311
+ next_char = move_next_char(text, prev, pos)
312
+ screen_columns = calc_width(text, idx, next_char)
313
+ segments.append([(screen_columns, idx, next_char)])
314
+ idx = next_char
315
+ break
316
+
317
+ else:
318
+ # unwrap previous line space if possible to
319
+ # fit more text (we're breaking a word anyway)
320
+ if segments and (len(segments[-1]) == 2 or (len(segments[-1]) == 1 and len(segments[-1][0]) == 2)):
321
+ # look for the removed space above
322
+ if len(segments[-1]) == 1:
323
+ [(h_sc, h_off)] = segments[-1]
324
+ p_sc = 0
325
+ p_off = _p_end = h_off
326
+
327
+ else:
328
+ [(p_sc, p_off, _p_end), (h_sc, h_off)] = segments[-1]
329
+
330
+ if p_sc < width and h_sc == 0 and text[h_off] == sp_o:
331
+ # combine with the previous line
332
+ del segments[-1]
333
+ idx = p_off
334
+ pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)
335
+ segments.append([(screen_columns, idx, pos)])
336
+ # check for trailing " " or "\n"
337
+ idx = pos
338
+ if idx < len(text) and (text[idx] in {sp_o, nl_o}):
339
+ # removed character hint
340
+ segments[-1].append((0, idx))
341
+ idx += 1
342
+ continue
343
+
344
+ # force any char wrap
345
+ segments.append([(screen_columns, idx, pos)])
346
+ idx = pos
347
+ return segments
348
+
349
+
350
+ ######################################
351
+ # default layout object to use
352
+ default_layout = StandardTextLayout()
353
+ ######################################
354
+
355
+
356
+ class LayoutSegment:
357
+ def __init__(self, seg: tuple[int, int, int | bytes] | tuple[int, int | None]) -> None:
358
+ """Create object from line layout segment structure"""
359
+
360
+ if not isinstance(seg, tuple):
361
+ raise TypeError(seg)
362
+ if len(seg) not in {2, 3}:
363
+ raise ValueError(seg)
364
+
365
+ self.sc, self.offs = seg[:2]
366
+
367
+ if not isinstance(self.sc, int):
368
+ raise TypeError(self.sc)
369
+
370
+ if len(seg) == 3:
371
+ if not isinstance(self.offs, int):
372
+ raise TypeError(self.offs)
373
+ if self.sc <= 0:
374
+ raise ValueError(seg)
375
+ t = seg[2]
376
+ if isinstance(t, bytes):
377
+ self.text: bytes | None = t
378
+ self.end = None
379
+ else:
380
+ if not isinstance(t, int):
381
+ raise TypeError(t)
382
+ self.text = None
383
+ self.end = t
384
+ else:
385
+ if len(seg) != 2:
386
+ raise ValueError(seg)
387
+ if self.offs is not None:
388
+ if self.sc < 0:
389
+ raise ValueError(seg)
390
+ if not isinstance(self.offs, int):
391
+ raise TypeError(self.offs)
392
+ self.text = self.end = None
393
+
394
+ def subseg(self, text: str | bytes, start: int, end: int) -> list[tuple[int, int] | tuple[int, int, int | bytes]]:
395
+ """
396
+ Return a "sub-segment" list containing segment structures
397
+ that make up a portion of this segment.
398
+
399
+ A list is returned to handle cases where wide characters
400
+ need to be replaced with a space character at either edge
401
+ so two or three segments will be returned.
402
+ """
403
+ start = max(start, 0)
404
+ end = min(end, self.sc)
405
+
406
+ if start >= end:
407
+ return [] # completely gone
408
+ if self.text:
409
+ # use text stored in segment (self.text)
410
+ spos, epos, pad_left, pad_right = calc_trim_text(self.text, 0, len(self.text), start, end)
411
+ return [(end - start, self.offs, b"".ljust(pad_left) + self.text[spos:epos] + b"".ljust(pad_right))]
412
+ if self.end:
413
+ # use text passed as parameter (text)
414
+ spos, epos, pad_left, pad_right = calc_trim_text(text, self.offs, self.end, start, end)
415
+ lines = []
416
+ if pad_left:
417
+ lines.append((1, spos - 1))
418
+ lines.append((end - start - pad_left - pad_right, spos, epos))
419
+ if pad_right:
420
+ lines.append((1, epos))
421
+ return lines
422
+
423
+ return [(end - start, self.offs)]
424
+
425
+
426
+ def line_width(segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]]) -> int:
427
+ """
428
+ Return the screen column width of one line of a text layout structure.
429
+
430
+ This function ignores any existing shift applied to the line,
431
+ represented by an (amount, None) tuple at the start of the line.
432
+ """
433
+ sc = 0
434
+ seglist = segs
435
+ if segs and len(segs[0]) == 2 and segs[0][1] is None:
436
+ seglist = segs[1:]
437
+ for s in seglist:
438
+ sc += s[0]
439
+ return sc
440
+
441
+
442
+ def shift_line(
443
+ segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],
444
+ amount: int,
445
+ ) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:
446
+ """
447
+ Return a shifted line from a layout structure to the left or right.
448
+ segs -- line of a layout structure
449
+ amount -- screen columns to shift right (+ve) or left (-ve)
450
+ """
451
+ if not isinstance(amount, int):
452
+ raise TypeError(amount)
453
+
454
+ if segs and len(segs[0]) == 2 and segs[0][1] is None:
455
+ # existing shift
456
+ amount += segs[0][0]
457
+ if amount:
458
+ return [(amount, None)] + segs[1:]
459
+ return segs[1:]
460
+
461
+ if amount:
462
+ return [(amount, None), *segs]
463
+ return segs
464
+
465
+
466
+ def trim_line(
467
+ segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],
468
+ text: str | bytes,
469
+ start: int,
470
+ end: int,
471
+ ) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:
472
+ """
473
+ Return a trimmed line of a text layout structure.
474
+ text -- text to which this layout structure applies
475
+ start -- starting screen column
476
+ end -- ending screen column
477
+ """
478
+ result = []
479
+ x = 0
480
+ for seg in segs:
481
+ sc = seg[0]
482
+ if start or sc < 0:
483
+ if start >= sc:
484
+ start -= sc
485
+ x += sc
486
+ continue
487
+ s = LayoutSegment(seg)
488
+ if x + sc >= end:
489
+ # can all be done at once
490
+ return s.subseg(text, start, end - x)
491
+ result += s.subseg(text, start, sc)
492
+ start = 0
493
+ x += sc
494
+ continue
495
+ if x >= end:
496
+ break
497
+ if x + sc > end:
498
+ s = LayoutSegment(seg)
499
+ result += s.subseg(text, 0, end - x)
500
+ break
501
+ result.append(seg)
502
+ return result
503
+
504
+
505
+ def calc_line_pos(text: str | bytes, line_layout, pref_col: Literal["left", "right"] | int):
506
+ """
507
+ Calculate the closest linear position to pref_col given a
508
+ line layout structure. Returns None if no position found.
509
+ """
510
+ closest_sc = None
511
+ closest_pos = None
512
+ current_sc = 0
513
+
514
+ if pref_col == "left":
515
+ for seg in line_layout:
516
+ s = LayoutSegment(seg)
517
+ if s.offs is not None:
518
+ return s.offs
519
+ return None
520
+ if pref_col == "right":
521
+ for seg in line_layout:
522
+ s = LayoutSegment(seg)
523
+ if s.offs is not None:
524
+ closest_pos = s
525
+ s = closest_pos
526
+ if s is None:
527
+ return None
528
+ if s.end is None:
529
+ return s.offs
530
+ return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]
531
+
532
+ for seg in line_layout:
533
+ s = LayoutSegment(seg)
534
+ if s.offs is not None:
535
+ if s.end is not None:
536
+ if current_sc <= pref_col < current_sc + s.sc:
537
+ # exact match within this segment
538
+ return calc_text_pos(text, s.offs, s.end, pref_col - current_sc)[0]
539
+ if current_sc <= pref_col:
540
+ closest_sc = current_sc + s.sc - 1
541
+ closest_pos = s
542
+
543
+ if closest_sc is None or (abs(pref_col - current_sc) < abs(pref_col - closest_sc)):
544
+ # this screen column is closer
545
+ closest_sc = current_sc
546
+ closest_pos = s.offs
547
+ if current_sc > closest_sc:
548
+ # we're moving past
549
+ break
550
+ current_sc += s.sc
551
+
552
+ if closest_pos is None or isinstance(closest_pos, int):
553
+ return closest_pos
554
+
555
+ # return the last positions in the segment "closest_pos"
556
+ s = closest_pos
557
+ return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]
558
+
559
+
560
+ def calc_pos(
561
+ text: str | bytes,
562
+ layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
563
+ pref_col: Literal["left", "right"] | int,
564
+ row: int,
565
+ ) -> int:
566
+ """
567
+ Calculate the closest linear position to pref_col and row given a
568
+ layout structure.
569
+ """
570
+
571
+ if row < 0 or row >= len(layout):
572
+ raise ValueError("calculate_pos: out of layout row range")
573
+
574
+ pos = calc_line_pos(text, layout[row], pref_col)
575
+ if pos is not None:
576
+ return pos
577
+
578
+ rows_above = list(range(row - 1, -1, -1))
579
+ rows_below = list(range(row + 1, len(layout)))
580
+ while rows_above and rows_below:
581
+ if rows_above:
582
+ r = rows_above.pop(0)
583
+ pos = calc_line_pos(text, layout[r], pref_col)
584
+ if pos is not None:
585
+ return pos
586
+ if rows_below:
587
+ r = rows_below.pop(0)
588
+ pos = calc_line_pos(text, layout[r], pref_col)
589
+ if pos is not None:
590
+ return pos
591
+ return 0
592
+
593
+
594
+ def calc_coords(
595
+ text: str | bytes,
596
+ layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
597
+ pos: int,
598
+ clamp: int = 1,
599
+ ) -> tuple[int, int]:
600
+ """
601
+ Calculate the coordinates closest to position pos in text with layout.
602
+
603
+ text -- raw string or unicode string
604
+ layout -- layout structure applied to text
605
+ pos -- integer position into text
606
+ clamp -- ignored right now
607
+ """
608
+ closest: tuple[int, tuple[int, int]] | None = None
609
+ y = 0
610
+ for line_layout in layout:
611
+ x = 0
612
+ for seg in line_layout:
613
+ s = LayoutSegment(seg)
614
+ if s.offs is None:
615
+ x += s.sc
616
+ continue
617
+ if s.offs == pos:
618
+ return x, y
619
+ if s.end is not None and s.offs <= pos < s.end:
620
+ x += calc_width(text, s.offs, pos)
621
+ return x, y
622
+ distance = abs(s.offs - pos)
623
+ if s.end is not None and s.end < pos:
624
+ distance = pos - (s.end - 1)
625
+ if closest is None or distance < closest[0]: # pylint: disable=unsubscriptable-object
626
+ closest = distance, (x, y)
627
+ x += s.sc
628
+ y += 1
629
+
630
+ if closest:
631
+ return closest[1]
632
+ return 0, 0