Tree.py

Summary

Lines:

223 of 223 covered (100.0%)

Branches:

98 of 98 taken (100.0%), 0 lines partially

Coverage:

100.0%

Directory:

Sphinx

Source

pyTooling/Sphinx/Tree.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
15#                                                                                                                      #
16# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
17# you may not use this file except in compliance with the License.                                                     #
18# You may obtain a copy of the License at                                                                              #
19#                                                                                                                      #
20#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
21#                                                                                                                      #
22# Unless required by applicable law or agreed to in writing, software                                                  #
23# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
25# See the License for the specific language governing permissions and                                                  #
26# limitations under the License.                                                                                       #
27#                                                                                                                      #
28# SPDX-License-Identifier: Apache-2.0                                                                                  #
29# ==================================================================================================================== #
30#
31"""
32A Sphinx directive drawing a hierarchy as a tree: a directory, a class hierarchy, the structure of a testsuite.
33
34Written as text in a code block, a hierarchy needs its lines drawn by hand, can't link to what it shows, and can't
35be folded. The ``tree`` directive takes the hierarchy as an indented list in the style of a YAML block sequence, and
36draws the lines itself:
37
38.. code-block:: ReST
39
40   .. tree::
41      :root-icon: 📦
42      :node-icon: 📁
43      :leaf-icon: 📄
44      :icons:     > 📁
45
46      - pyTooling
47        - Tree
48          - :class:`~pyTooling.Tree.Node`
49        > Graph
50        - README.md
51
52**An entry is a line starting with a marker** and a space, and an entry indented deeper than the one above it is that
53entry's child. An entry's text is inline ReST, so a role such as ``:class:`` or ``:ref:`` links to what the entry names.
54
55.. rubric:: Descriptions
56
57**A** ``|`` **between spaces separates an entry's text from its description**, which is inline ReST too. HTML draws
58the descriptions as a second column: all of them start at the same horizontal position, whatever the entry's level.
59An entry without a description stays valid:
60
61.. code-block:: ReST
62
63   .. tree::
64
65      - CoverageSummary         | the report
66        - Directory             | e.g. ``myPackage/``
67          - File                | e.g. ``myPackage/Shapes.py``
68        - Package
69
70A ``|`` within a literal or a role's text, or escaped as ``\\|``, separates nothing, and neither does a substitution
71reference such as ``|br|``.
72
73.. rubric:: Roots, nodes and leaves
74
75An entry without a parent is a root, an entry with children is a node, and every other entry is a leaf. An entry
76marked ``-`` gets the icon of its kind. Option ``:icons:`` declares more markers, each with its own icon, whatever the
77entry's kind is - e.g. a folder for an empty directory, which is a leaf.
78
79.. rubric:: Legend
80
81**An icon option may explain its icon**, behind a ``|`` between spaces: ``:leaf-icon: 📄 | file``. Option ``:icons:``
82then declares one marker per line. The texts form the tree's legend, which HTML draws below the tree - or above it, as
83option ``:legend:`` chooses:
84
85.. code-block:: ReST
86
87   .. tree::
88      :legend:    top
89      :root-icon: 📦 | package
90      :node-icon: 📁 | directory
91      :leaf-icon: 📄 | file
92      :icons:     > ⚙️ | settings
93                  # 🐍 | Python module
94
95.. rubric:: HTML and other formats
96
97HTML draws an entry with children as a ``<details>`` element, so a reader folds and unfolds it without JavaScript;
98the stylesheet draws the lines and shows the expanded or the collapsed icon. Every other format - e.g. LaTeX - finds
99no visitor for :class:`~pyTooling.Sphinx.Node.TreeItem` and :class:`~pyTooling.Sphinx.Node.TreeLabel` and renders
100their base-classes instead: a nested bullet list without icons, with a description behind its entry's text and an en
101dash. These formats have no legend, as their trees show no icons: :class:`RemoveTreeLegends` removes it.
102
103.. seealso::
104
105   :ref:`DIR/Tree`
106      |rarr| The directive's options, with rendered examples.
107   :mod:`pyTooling.Sphinx`
108      |rarr| The extension this belongs to, and what else it brings.
109"""
110from re                                import compile as re_compile
111from typing                            import Any, Iterable, Optional as Nullable
112from unicodedata                       import category, east_asian_width
113
114from docutils                          import nodes
115from docutils.parsers.rst              import directives
116from sphinx.transforms.post_transforms import SphinxPostTransform
117
118from pyTooling.Common                  import StringEnum
119from pyTooling.Decorators              import export
120from pyTooling.Tree                    import Node
121from pyTooling.Sphinx                  import BaseDirective, SphinxExtensionError, strip
122from pyTooling.Sphinx.Node             import TreeDescription, TreeItem, TreeLabel, TreeLegend, TreeLegendItem, \
123                                              TreeSeparator
124
125
126__all__ = [
127	"DEFAULT_MARKER", "DEFAULT_ICONS", "CODE_POINT_PATTERN", "SEPARATOR_PATTERN", "SEPARATOR"
128]
129
130DEFAULT_MARKER = "-"
131"""The marker of an entry whose icon is the one of its kind."""
132
133DEFAULT_ICONS = {
134	"root-icon":      "",
135	"node-icon":      "",
136	"leaf-icon":      "",
137	"expanded-icon":  "▾",
138	"collapsed-icon": "▸",
139}
140"""
141The icons drawn when the directive's options don't state one, keyed by the option's name.
142
143Roots, nodes and leaves have none, so a tree shows only its lines; a node with children shows whether it is
144expanded.
145"""
146
147CODE_POINT_PATTERN = re_compile(r"U\+([0-9A-Fa-f]{1,6})")
148"""A word of an icon option written as a Unicode code point, e.g. ``U+1F4C1``."""
149
150SEPARATOR_PATTERN = re_compile(r"\\.|``.*?``|`(?:\\.|[^`\\])*`|(?<=\s)(?P<separator>\|)(?=\s|$)")
151"""
152The separator between an entry's text and its description, as group ``separator``: a ``|`` after whitespace and
153before whitespace or the line's end.
154
155The other alternatives match what the separator isn't searched in - an escaped character, an inline literal, and
156interpreted text such as a role's -, so a ``|`` in them separates nothing.
157"""
158
159SEPARATOR = " \u2013 "
160"""What a format without columns writes between an entry's text and its description: an en dash between spaces."""
161
162_Entry = Node
163"""
164An entry of the content: its text as the value, the index of its line, its marker and its description as key-value
165pairs.
166"""
167
168
169@export
170def icon(option: Nullable[str]) -> str:
171	"""
172	Option converter reading an icon: characters as written, and a word ``U+<hex>`` as the code point it names.
173
174	A code point keeps a document readable in an editor whose font lacks the icon's glyph.
175
176	:param option:      The option's value as it was written; ``None`` if the option has no value.
177	:returns:           The icon's characters; empty if the option has no value, which draws no icon.
178	:raises ValueError: If a word names a code point beyond U+10FFFF.
179	:raises ValueError: If a word names a surrogate code point, which is no character.
180	"""
181	if option is None:
182		return ""
183
184	characters = []
185	for word in option.split():
186		if (match := CODE_POINT_PATTERN.fullmatch(word)) is None:
187			characters.append(word)
188		elif (codePoint := int(match[1], 16)) > 0x10ffff:
189			raise ValueError(f"'{word}' is beyond the last code point U+10FFFF.")
190		elif 0xd800 <= codePoint <= 0xdfff:
191			raise ValueError(f"'{word}' is a surrogate code point, which is no character.")
192		else:
193			characters.append(chr(codePoint))
194
195	return "".join(characters)
196
197
198@export
199def describedIcon(option: Nullable[str]) -> tuple[str, str]:
200	"""
201	Option converter reading an icon and the text explaining it in the tree's legend: ``<icon> | <text>``.
202
203	The icon is read by :func:`icon`. The text follows a separator ``|`` between spaces - see :data:`SEPARATOR_PATTERN`
204	-, and is inline ReST. An icon without a text has no item in the legend.
205
206	:param option:      The option's value as it was written; ``None`` if the option has no value.
207	:returns:           The icon's characters and the text; empty if the option has no value or no text.
208	:raises ValueError: If the value has more than one separator ``|``.
209	:raises ValueError: If the value has a text, but no icon.
210	"""
211	if option is None:
212		return "", ""
213
214	value = f" {option}"  # a separator at the start follows a space too
215	separators = [match.start() for match in SEPARATOR_PATTERN.finditer(value) if match["separator"] is not None]
216	if len(separators) > 1:
217		raise ValueError(f"'{option.strip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'.")
218	elif len(separators) == 0:
219		return icon(option), ""
220
221	characters = icon(value[:separators[0]])
222	text =       value[separators[0] + 1:].strip()
223	if characters == "" and text != "":
224		raise ValueError(f"'{option.strip()}' has a text, but no icon.")
225
226	return characters, text
227
228
229@export
230class LegendPosition(StringEnum):
231	"""
232	Where a legend is drawn, as a directive's option ``:legend:`` states it.
233
234	A tree's legend gets the CSS class ``tree-legend-<value>``, a unit test summary's legend
235	``report-unittest-legend-<value>``.
236	"""
237
238	Top =    "top"     #: Above the tree or table.
239	Bottom = "bottom"  #: Below the tree or table.
240	Hidden = "none"    #: Not at all.
241
242	DEFAULT = Bottom   #: An option without a value draws the legend below the tree or table.
243
244
245@export
246def markerIcons(option: Nullable[str]) -> dict[str, tuple[str, str]]:
247	"""
248	Option converter reading the markers an entry may start with, each with its icon and the text explaining it in the
249	tree's legend: ``<marker> <icon>``, separated by commas, or ``<marker> <icon> | <text>``, one per line.
250
251	A marker is one character, neither a letter, a digit, a comma nor a space. An icon and its text are read by
252	:func:`describedIcon`. A line with a separator ``|`` holds one marker, so a text may contain commas; a line without
253	one is split at its commas.
254
255	:param option:      The option's value as it was written; ``None`` if the option has no value.
256	:returns:           The icons and their texts, keyed by their marker, in the order declared.
257	:raises ValueError: If an item isn't a marker followed by a space and an icon.
258	:raises ValueError: If a marker is a letter, a digit or a comma.
259	:raises ValueError: If a marker is declared twice.
260	:raises ValueError: If an item has more than one separator ``|``.
261	"""
262	if option is None:
263		return {}
264
265	icons: dict[str, tuple[str, str]] = {}
266	for line in option.splitlines():
267		if any(match["separator"] is not None for match in SEPARATOR_PATTERN.finditer(line)):
268			items = [line]
269		else:
270			items = line.split(",")
271
272		for item in items:
273			if (item := item.strip()) == "":
274				continue
275
276			marker =    item[0]
277			separator = item[1:2]
278			value =     item[2:]
279			if separator.strip() != "" or value.strip() == "":
280				raise ValueError(f"'{item}' is not a marker followed by a space and an icon.")
281			elif marker.isalnum():
282				raise ValueError(f"Marker '{marker}' is a letter or a digit.")
283			elif marker in icons:
284				raise ValueError(f"Marker '{marker}' is declared twice.")
285
286			icons[marker] = describedIcon(value)
287
288	return icons
289
290
291@export
292class Tree(BaseDirective):
293	"""
294	The ``tree`` directive: a hierarchy, written as an indented list, drawn as a tree.
295
296	:meth:`_ParseEntries` reads the content into :class:`~pyTooling.Tree.Node` instances, one tree per root, and
297	:meth:`_BuildItem` turns them into nested bullet lists of :class:`~pyTooling.Sphinx.Node.TreeItem` and
298	:class:`~pyTooling.Sphinx.Node.TreeLabel`. The options choose the icons and how many levels are expanded initially;
299	``:class:`` puts additional CSS classes on the tree. :meth:`_BuildLegend` turns the texts of the icons into the
300	tree's legend, which ``:legend:`` places above or below the tree.
301
302	If an entry has a description, every entry's text ends with a :class:`~pyTooling.Sphinx.Node.TreeDescription`, and
303	:meth:`_ColumnWidth` estimates how wide the column of the texts in front of the descriptions is.
304	"""
305
306	directiveName: str = "tree"  #: Name the directive is invoked by.
307
308	has_content =               True   #: A boolean; ``True`` if content is allowed.
309	required_arguments =        0      #: Number of required directive arguments.
310	optional_arguments =        0      #: Number of optional arguments after the required ones.
311	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
312	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
313	# spelling of this override a conflict with one of them
314	#: Mapping of option names to validator functions.
315	option_spec: dict[str, Any] = {  # type: ignore[misc]
316		"root-icon":       describedIcon,
317		"node-icon":       describedIcon,
318		"leaf-icon":       describedIcon,
319		"expanded-icon":   icon,
320		"collapsed-icon":  icon,
321		"icons":           markerIcons,
322		"legend":          LegendPosition.Parse,
323		"expanded-levels": directives.nonnegative_int,
324		"class":           strip,
325	}
326
327	def run(self) -> list[nodes.Node]:
328		"""
329		Draw the hierarchy written in the content, and the legend of its icons.
330
331		Option ``:legend:`` without any icon having a text draws no legend, and is warned about.
332
333		:returns: The tree as a bullet list with its legend above or below it, or the message of whatever the content or
334		          the icons' texts got wrong.
335		"""
336		markers = self.options.get("icons", {})
337		describedIcons = {
338			name: self.options.get(name, (DEFAULT_ICONS[name], "")) for name in ("root-icon", "node-icon", "leaf-icon")
339		}
340		describedIcons.update(markers)
341		position = self.options.get("legend", LegendPosition.DEFAULT)
342		try:
343			roots = self._ParseEntries(self.content, (DEFAULT_MARKER, *markers))
344			legend, messages = self._BuildLegend(describedIcons.values(), position)
345		except SphinxExtensionError as ex:
346			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
347
348		icons = {name: self.options.get(name, DEFAULT_ICONS[name]) for name in ("expanded-icon", "collapsed-icon")}
349		icons.update((key, characters) for key, (characters, _) in describedIcons.items())
350		expandedLevels = self.options.get("expanded-levels", None)
351		descriptionColumn = any(entry["description"] != "" for root in roots for entry in root.IteratePreOrder())
352
353		tree = nodes.bullet_list(classes=["pytooling-tree"] + self.options.get("class", "").split())
354		for root in roots:
355			tree += self._BuildItem(root, icons, expandedLevels, descriptionColumn)
356
357		if descriptionColumn:
358			columnWidth = self._ColumnWidth(tree.findall(TreeLabel))
359			for item in tree.children:
360				item["columnWidth"] = columnWidth
361
362		if len(legend.children) == 0:
363			if "legend" in self.options and position is not LegendPosition.Hidden:
364				messages.append(self.state.document.reporter.warning(
365					f"{self.directiveName}: ':legend: {position}' without an icon having a text draws no legend.",
366					line=self.lineno
367				))
368			return [tree, *messages]
369		elif position is LegendPosition.Top:
370			return [legend, tree, *messages]
371		elif position is LegendPosition.Bottom:
372			return [tree, legend, *messages]
373		else:
374			return [tree, *messages]
375
376	def _BuildLegend(
377		self,
378		describedIcons: Iterable[tuple[str, str]],
379		position: LegendPosition
380	) -> tuple[TreeLegend, list[nodes.system_message]]:
381		"""
382		Build the legend of the tree's icons: an item per icon with a text, in the order given.
383
384		An icon given twice with the same text - e.g. a folder for a node and for a marker - is explained once. A text is
385		parsed as inline ReST at the directive's line, as an option has no line of its own.
386
387		:param describedIcons:        Each icon with its text; an icon with an empty text has no item.
388		:param position:              Where the legend is drawn; the legend gets the CSS class ``tree-legend-<position>``.
389		:returns:                     The legend - without items, if no icon has a text -, and the messages about the
390		                              texts.
391		:raises SphinxExtensionError: If an icon is given twice with different texts.
392		"""
393		texts: dict[str, str] = {}
394		for characters, text in describedIcons:
395			if text == "":
396				continue
397			elif characters not in texts:
398				texts[characters] = text
399			elif texts[characters] != text:
400				raise SphinxExtensionError(f"Icon '{characters}' has two texts: '{texts[characters]}' and '{text}'.")
401
402		legend = TreeLegend("", classes=["pytooling-tree-legend", f"tree-legend-{position}"])
403		messages: list[nodes.system_message] = []
404		for characters, text in texts.items():
405			textNodes, textMessages = self.state.inline_text(text, self.lineno)
406			legend += TreeLegendItem(text, "", *textNodes, icon=characters)
407			messages.extend(textMessages)
408
409		return legend, messages
410
411	@staticmethod
412	def _ParseEntries(content: Iterable[str], markers: Iterable[str] = (DEFAULT_MARKER, )) -> list[_Entry]:
413		"""
414		Read the content into trees: an entry per line, and an entry indented deeper than the one above is its child.
415
416		An entry's text is the node's value. Key ``index`` holds the index of the entry's line in the content, key
417		``marker`` the marker the line starts with, and key ``description`` the text behind a separator ``|`` - see
418		:data:`SEPARATOR_PATTERN` -, or an empty string.
419
420		:param content:               The directive's content, line by line.
421		:param markers:               Optional, the markers an entry may start with. Default: ``-``.
422		:returns:                     The root of each tree, in the order written.
423		:raises SphinxExtensionError: If the content holds no entry.
424		:raises SphinxExtensionError: If a line doesn't start with a marker and a space.
425		:raises SphinxExtensionError: If a line has more than one separator ``|``.
426		:raises SphinxExtensionError: If an entry has no text.
427		:raises SphinxExtensionError: If an entry is indented less than the entry above, but not as deep as one of its
428		                              ancestors.
429		"""
430		roots: list[_Entry] = []
431		ancestors: list[tuple[int, _Entry]] = []  # entries the next one can be a child or a sibling of, with indentation
432		for index, line in enumerate(content):
433			if (text := line.lstrip(" ")).strip() == "":
434				continue
435
436			indentation = len(line) - len(text)
437			marker = text[0]
438			if marker not in markers or text[1:2].strip() != "":
439				accepted = ", ".join(f"'{marker} '" for marker in markers)
440				raise SphinxExtensionError(f"'{text.rstrip()}' is not an entry, which starts with {accepted}.")
441
442			separators = [match.start() for match in SEPARATOR_PATTERN.finditer(text) if match["separator"] is not None]
443			if len(separators) > 1:
444				raise SphinxExtensionError(
445					f"'{text.rstrip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'."
446				)
447			elif len(separators) == 1:
448				value, description = text[1:separators[0]].strip(), text[separators[0] + 1:].strip()
449			else:
450				value, description = text[1:].strip(), ""
451
452			if value == "":
453				raise SphinxExtensionError(f"'{text.rstrip()}' is an entry without text.")
454
455			closed = False
456			while len(ancestors) > 0 and ancestors[-1][0] > indentation:
457				ancestors.pop()
458				closed = True
459
460			if len(ancestors) > 0 and ancestors[-1][0] == indentation:
461				ancestors.pop()
462			elif closed:
463				raise SphinxExtensionError(
464					f"'{text.rstrip()}' is indented less than the entry above, but not as deep as one of its ancestors."
465				)
466
467			parent = ancestors[-1][1] if len(ancestors) > 0 else None
468			entry: _Entry = Node(
469				value=value, keyValuePairs={"index": index, "marker": marker, "description": description}, parent=parent
470			)
471			if parent is None:
472				roots.append(entry)
473
474			ancestors.append((indentation, entry))
475
476		if len(roots) == 0:
477			raise SphinxExtensionError("The directive's content holds no entry.")
478
479		return roots
480
481	def _BuildItem(
482		self,
483		entry: _Entry,
484		icons: dict[str, str],
485		expandedLevels: Nullable[int],
486		descriptionColumn: bool
487	) -> TreeItem:
488		"""
489		Build an entry and, below it, its children.
490
491		The entry's text and description are parsed as inline ReST at the line they were written on, so a message about
492		a role in them points to that line. Such messages follow the entry's text in the list item.
493
494		:param entry:             The entry, as :meth:`_ParseEntries` read it.
495		:param icons:             The icons, keyed by the option naming them, or by their marker.
496		:param expandedLevels:    How many levels are expanded initially; ``None`` expands all.
497		:param descriptionColumn: Whether the tree has descriptions: then the entry's text ends with a description, empty
498		                          if the entry has none.
499		:returns:                 The entry as a list item, holding its text and a bullet list of its children.
500		"""
501		line = self.content_offset + entry["index"] + 1
502		textNodes, messages = self.state.inline_text(entry.Value, line)
503
504		if entry.IsRoot:
505			kind = "root"
506		elif entry.HasChildren:
507			kind = "node"
508		else:
509			kind = "leaf"
510
511		entryIcon = icons[entry["marker"]] if entry["marker"] in icons else icons[f"{kind}-icon"]
512
513		item = TreeItem("", classes=[f"tree-{kind}"] + (["tree-expandable"] if entry.HasChildren else []))
514		item["columnWidth"] = None
515		item += (label := TreeLabel(entry.Value, "", *textNodes, icon=entryIcon, level=entry.Level))
516		if descriptionColumn:
517			description = TreeDescription(entry["description"])
518			if entry["description"] != "":
519				descriptionNodes, descriptionMessages = self.state.inline_text(entry["description"], line)
520				description += descriptionNodes
521				messages += descriptionMessages
522				label += TreeSeparator("", SEPARATOR)
523
524			label += description
525
526		item += messages
527
528		if not entry.HasChildren:
529			item["expanded"] = None
530			label["expandedIcon"] = None
531			label["collapsedIcon"] = None
532			return item
533
534		item["expanded"] = expandedLevels is None or entry.Level < expandedLevels
535		label["expandedIcon"] = icons["expanded-icon"]
536		label["collapsedIcon"] = icons["collapsed-icon"]
537
538		item += (children := nodes.bullet_list())
539		for child in entry.GetChildren():
540			children += self._BuildItem(child, icons, expandedLevels, descriptionColumn)
541
542		return item
543
544	@staticmethod
545	def _ColumnWidth(labels: Iterable[TreeLabel]) -> str:
546		"""
547		Estimate the width of the column of the entries' texts in a tree with descriptions, from the tree's left edge.
548
549		An entry needs its level's indentation, its expander, and its icon, a gap and its text. HTML renders the text
550		later, in a font chosen by the theme, so the text's width is estimated in character cells, CSS unit ``ch``: two
551		for a wide character, e.g. an emoji, none for a combining or format character, e.g. a variation selector, and
552		one for every other character. The gap counts as one cell.
553
554		The width is the widest entry's: per level, the indentation and the most cells at that level, and of these the
555		maximum, computed by the browser with CSS function ``max()``. The indentation is CSS custom property
556		``--pyTooling-tree-level``, so a project's indentation is accounted for.
557
558		:param labels: The entries' texts, with their level, icon and description.
559		:returns:      The width as a CSS length.
560		"""
561		cellsPerLevel: dict[int, int] = {}
562		for label in labels:
563			text = "".join(
564				child.astext() for child in label.children if not isinstance(child, (TreeSeparator, TreeDescription))
565			)
566			if label["icon"] != "":
567				text = f"{label['icon']} {text}"
568
569			cells = sum(
570				2 if east_asian_width(character) in ("W", "F") else 0 if category(character) in ("Mn", "Me", "Cf") else 1
571				for character in text
572			)
573			cellsPerLevel[label["level"]] = max(cells, cellsPerLevel.get(label["level"], 0))
574
575		widths = ", ".join(
576			f"{cells}ch" if level == 0 else f"calc({level} * var(--pyTooling-tree-level) + {cells}ch)"
577			for level, cells in sorted(cellsPerLevel.items())
578		)
579		return f"calc(var(--pyTooling-tree-expander-width) + max({widths}))"
580
581
582@export
583class RemoveTreeLegends(SphinxPostTransform):
584	"""
585	A post-transform removing the trees' legends from the output formats, whose trees show no icons: LaTeX, text,
586	manual pages and Texinfo.
587
588	These formats write a tree as a nested bullet list, so a legend would explain icons the reader doesn't see.
589	"""
590
591	default_priority = 200                                  #: Priority among the post-transforms.
592	formats =          ("latex", "text", "man", "texinfo")  #: Output formats the transform runs for.
593
594	def run(self, **kwargs: Any) -> None:
595		"""
596		Remove every legend of a tree.
597
598		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
599		"""
600		for legend in list(self.document.findall(TreeLegend)):
601			legend.parent.remove(legend)