Tree.py

Summary

Lines:

219 of 219 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
123from pyTooling.Sphinx.Node             import TreeSeparator
124
125
126__all__ = [
127	"DEFAULT_MARKER", "DEFAULT_ICONS", "CODE_POINT_PATTERN", "SEPARATOR_PATTERN", "SEPARATOR"
128]
129
130#: The marker of an entry whose icon is the one of its kind.
131DEFAULT_MARKER = "-"
132
133#: The icons drawn when the directive's options don't state one, keyed by the option's name.
134#:
135#: Roots, nodes and leaves have none, so a tree shows only its lines; a node with children shows whether it is
136#: expanded.
137DEFAULT_ICONS = {
138	"root-icon":      "",
139	"node-icon":      "",
140	"leaf-icon":      "",
141	"expanded-icon":  "▾",
142	"collapsed-icon": "▸",
143}
144
145#: A word of an icon option written as a Unicode code point, e.g. ``U+1F4C1``.
146CODE_POINT_PATTERN = re_compile(r"U\+([0-9A-Fa-f]{1,6})")
147
148#: The separator between an entry's text and its description, as group ``separator``: a ``|`` after whitespace and
149#: before whitespace or the line's end.
150#:
151#: The other alternatives match what the separator isn't searched in - an escaped character, an inline literal, and
152#: interpreted text such as a role's -, so a ``|`` in them separates nothing.
153SEPARATOR_PATTERN = re_compile(r"\\.|``.*?``|`(?:\\.|[^`\\])*`|(?<=\s)(?P<separator>\|)(?=\s|$)")
154
155#: What a format without columns writes between an entry's text and its description: an en dash between spaces.
156SEPARATOR = " \u2013 "
157
158_Entry = Node
159"""
160An entry of the content: its text as the value, the index of its line, its marker and its description as key-value
161pairs.
162"""
163
164
165@export
166def icon(option: Nullable[str]) -> str:
167	"""
168	Option converter reading an icon: characters as written, and a word ``U+<hex>`` as the code point it names.
169
170	A code point keeps a document readable in an editor whose font lacks the icon's glyph.
171
172	:param option:      The option's value as it was written; ``None`` if the option has no value.
173	:returns:           The icon's characters; empty if the option has no value, which draws no icon.
174	:raises ValueError: If a word names a code point beyond U+10FFFF.
175	:raises ValueError: If a word names a surrogate code point, which is no character.
176	"""
177	if option is None:
178		return ""
179
180	characters = []
181	for word in option.split():
182		if (match := CODE_POINT_PATTERN.fullmatch(word)) is None:
183			characters.append(word)
184		elif (codePoint := int(match[1], 16)) > 0x10ffff:
185			raise ValueError(f"'{word}' is beyond the last code point U+10FFFF.")
186		elif 0xd800 <= codePoint <= 0xdfff:
187			raise ValueError(f"'{word}' is a surrogate code point, which is no character.")
188		else:
189			characters.append(chr(codePoint))
190
191	return "".join(characters)
192
193
194@export
195def describedIcon(option: Nullable[str]) -> tuple[str, str]:
196	"""
197	Option converter reading an icon and the text explaining it in the tree's legend: ``<icon> | <text>``.
198
199	The icon is read by :func:`icon`. The text follows a separator ``|`` between spaces - see :data:`SEPARATOR_PATTERN`
200	-, and is inline ReST. An icon without a text has no item in the legend.
201
202	:param option:      The option's value as it was written; ``None`` if the option has no value.
203	:returns:           The icon's characters and the text; empty if the option has no value or no text.
204	:raises ValueError: If the value has more than one separator ``|``.
205	:raises ValueError: If the value has a text, but no icon.
206	"""
207	if option is None:
208		return "", ""
209
210	value = f" {option}"  # a separator at the start follows a space too
211	separators = [match.start() for match in SEPARATOR_PATTERN.finditer(value) if match["separator"] is not None]
212	if len(separators) > 1:
213		raise ValueError(f"'{option.strip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'.")
214	elif len(separators) == 0:
215		return icon(option), ""
216
217	characters = icon(value[:separators[0]])
218	text =       value[separators[0] + 1:].strip()
219	if characters == "" and text != "":
220		raise ValueError(f"'{option.strip()}' has a text, but no icon.")
221
222	return characters, text
223
224
225@export
226class LegendPosition(StringEnum):
227	"""
228	Where a tree's legend is drawn, as option ``:legend:`` of the ``tree`` directive states it.
229
230	The legend gets the CSS class ``tree-legend-<value>``.
231	"""
232
233	Top =    "top"     #: Above the tree.
234	Bottom = "bottom"  #: Below the tree.
235	Hidden = "none"    #: Not at all.
236
237	DEFAULT = Bottom   #: An option without a value draws the legend below the tree.
238
239
240@export
241def markerIcons(option: Nullable[str]) -> dict[str, tuple[str, str]]:
242	"""
243	Option converter reading the markers an entry may start with, each with its icon and the text explaining it in the
244	tree's legend: ``<marker> <icon>``, separated by commas, or ``<marker> <icon> | <text>``, one per line.
245
246	A marker is one character, neither a letter, a digit, a comma nor a space. An icon and its text are read by
247	:func:`describedIcon`. A line with a separator ``|`` holds one marker, so a text may contain commas; a line without
248	one is split at its commas.
249
250	:param option:      The option's value as it was written; ``None`` if the option has no value.
251	:returns:           The icons and their texts, keyed by their marker, in the order declared.
252	:raises ValueError: If an item isn't a marker followed by a space and an icon.
253	:raises ValueError: If a marker is a letter, a digit or a comma.
254	:raises ValueError: If a marker is declared twice.
255	:raises ValueError: If an item has more than one separator ``|``.
256	"""
257	if option is None:
258		return {}
259
260	icons: dict[str, tuple[str, str]] = {}
261	for line in option.splitlines():
262		if any(match["separator"] is not None for match in SEPARATOR_PATTERN.finditer(line)):
263			items = [line]
264		else:
265			items = line.split(",")
266
267		for item in items:
268			if (item := item.strip()) == "":
269				continue
270
271			marker =    item[0]
272			separator = item[1:2]
273			value =     item[2:]
274			if separator.strip() != "" or value.strip() == "":
275				raise ValueError(f"'{item}' is not a marker followed by a space and an icon.")
276			elif marker.isalnum():
277				raise ValueError(f"Marker '{marker}' is a letter or a digit.")
278			elif marker in icons:
279				raise ValueError(f"Marker '{marker}' is declared twice.")
280
281			icons[marker] = describedIcon(value)
282
283	return icons
284
285
286@export
287class Tree(BaseDirective):
288	"""
289	The ``tree`` directive: a hierarchy, written as an indented list, drawn as a tree.
290
291	:meth:`_ParseEntries` reads the content into :class:`~pyTooling.Tree.Node` instances, one tree per root, and
292	:meth:`_BuildItem` turns them into nested bullet lists of :class:`~pyTooling.Sphinx.Node.TreeItem` and
293	:class:`~pyTooling.Sphinx.Node.TreeLabel`. The options choose the icons and how many levels are expanded initially;
294	``:class:`` puts additional CSS classes on the tree. :meth:`_BuildLegend` turns the texts of the icons into the
295	tree's legend, which ``:legend:`` places above or below the tree.
296
297	If an entry has a description, every entry's text ends with a :class:`~pyTooling.Sphinx.Node.TreeDescription`, and
298	:meth:`_ColumnWidth` estimates how wide the column of the texts in front of the descriptions is.
299	"""
300
301	directiveName: str = "tree"  #: Name the directive is invoked by.
302
303	has_content =               True   #: A boolean; ``True`` if content is allowed.
304	required_arguments =        0      #: Number of required directive arguments.
305	optional_arguments =        0      #: Number of optional arguments after the required ones.
306	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
307	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
308	# spelling of this override a conflict with one of them
309	#: Mapping of option names to validator functions.
310	option_spec: dict[str, Any] = {  # type: ignore[misc]
311		"root-icon":       describedIcon,
312		"node-icon":       describedIcon,
313		"leaf-icon":       describedIcon,
314		"expanded-icon":   icon,
315		"collapsed-icon":  icon,
316		"icons":           markerIcons,
317		"legend":          LegendPosition.Parse,
318		"expanded-levels": directives.nonnegative_int,
319		"class":           strip,
320	}
321
322	def run(self) -> list[nodes.Node]:
323		"""
324		Draw the hierarchy written in the content, and the legend of its icons.
325
326		Option ``:legend:`` without any icon having a text draws no legend, and is warned about.
327
328		:returns: The tree as a bullet list with its legend above or below it, or the message of whatever the content or
329		          the icons' texts got wrong.
330		"""
331		markers = self.options.get("icons", {})
332		describedIcons = {
333			name: self.options.get(name, (DEFAULT_ICONS[name], "")) for name in ("root-icon", "node-icon", "leaf-icon")
334		}
335		describedIcons.update(markers)
336		position = self.options.get("legend", LegendPosition.DEFAULT)
337		try:
338			roots = self._ParseEntries(self.content, (DEFAULT_MARKER, *markers))
339			legend, messages = self._BuildLegend(describedIcons.values(), position)
340		except SphinxExtensionError as ex:
341			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
342
343		icons = {name: self.options.get(name, DEFAULT_ICONS[name]) for name in ("expanded-icon", "collapsed-icon")}
344		icons.update((key, characters) for key, (characters, _) in describedIcons.items())
345		expandedLevels = self.options.get("expanded-levels", None)
346		descriptionColumn = any(entry["description"] != "" for root in roots for entry in root.IteratePreOrder())
347
348		tree = nodes.bullet_list(classes=["pytooling-tree"] + self.options.get("class", "").split())
349		for root in roots:
350			tree += self._BuildItem(root, icons, expandedLevels, descriptionColumn)
351
352		if descriptionColumn:
353			columnWidth = self._ColumnWidth(tree.findall(TreeLabel))
354			for item in tree.children:
355				item["columnWidth"] = columnWidth
356
357		if len(legend.children) == 0:
358			if "legend" in self.options and position is not LegendPosition.Hidden:
359				messages.append(self.state.document.reporter.warning(
360					f"{self.directiveName}: ':legend: {position}' without an icon having a text draws no legend.",
361					line=self.lineno
362				))
363			return [tree, *messages]
364		elif position is LegendPosition.Top:
365			return [legend, tree, *messages]
366		elif position is LegendPosition.Bottom:
367			return [tree, legend, *messages]
368		else:
369			return [tree, *messages]
370
371	def _BuildLegend(
372		self,
373		describedIcons: Iterable[tuple[str, str]],
374		position: LegendPosition
375	) -> tuple[TreeLegend, list[nodes.system_message]]:
376		"""
377		Build the legend of the tree's icons: an item per icon with a text, in the order given.
378
379		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
380		parsed as inline ReST at the directive's line, as an option has no line of its own.
381
382		:param describedIcons:        Each icon with its text; an icon with an empty text has no item.
383		:param position:              Where the legend is drawn; the legend gets the CSS class ``tree-legend-<position>``.
384		:returns:                     The legend - without items, if no icon has a text -, and the messages about the
385		                              texts.
386		:raises SphinxExtensionError: If an icon is given twice with different texts.
387		"""
388		texts: dict[str, str] = {}
389		for characters, text in describedIcons:
390			if text == "":
391				continue
392			elif characters not in texts:
393				texts[characters] = text
394			elif texts[characters] != text:
395				raise SphinxExtensionError(f"Icon '{characters}' has two texts: '{texts[characters]}' and '{text}'.")
396
397		legend = TreeLegend("", classes=["pytooling-tree-legend", f"tree-legend-{position}"])
398		messages: list[nodes.system_message] = []
399		for characters, text in texts.items():
400			textNodes, textMessages = self.state.inline_text(text, self.lineno)
401			legend += TreeLegendItem(text, "", *textNodes, icon=characters)
402			messages.extend(textMessages)
403
404		return legend, messages
405
406	@staticmethod
407	def _ParseEntries(content: Iterable[str], markers: Iterable[str] = (DEFAULT_MARKER, )) -> list[_Entry]:
408		"""
409		Read the content into trees: an entry per line, and an entry indented deeper than the one above is its child.
410
411		An entry's text is the node's value. Key ``index`` holds the index of the entry's line in the content, key
412		``marker`` the marker the line starts with, and key ``description`` the text behind a separator ``|`` - see
413		:data:`SEPARATOR_PATTERN` -, or an empty string.
414
415		:param content:               The directive's content, line by line.
416		:param markers:               Optional, the markers an entry may start with. Default: ``-``.
417		:returns:                     The root of each tree, in the order written.
418		:raises SphinxExtensionError: If the content holds no entry.
419		:raises SphinxExtensionError: If a line doesn't start with a marker and a space.
420		:raises SphinxExtensionError: If a line has more than one separator ``|``.
421		:raises SphinxExtensionError: If an entry has no text.
422		:raises SphinxExtensionError: If an entry is indented less than the entry above, but not as deep as one of its
423		                              ancestors.
424		"""
425		roots: list[_Entry] = []
426		ancestors: list[tuple[int, _Entry]] = []  # entries the next one can be a child or a sibling of, with indentation
427		for index, line in enumerate(content):
428			if (text := line.lstrip(" ")).strip() == "":
429				continue
430
431			indentation = len(line) - len(text)
432			marker = text[0]
433			if marker not in markers or text[1:2].strip() != "":
434				accepted = ", ".join(f"'{marker} '" for marker in markers)
435				raise SphinxExtensionError(f"'{text.rstrip()}' is not an entry, which starts with {accepted}.")
436
437			separators = [match.start() for match in SEPARATOR_PATTERN.finditer(text) if match["separator"] is not None]
438			if len(separators) > 1:
439				raise SphinxExtensionError(
440					f"'{text.rstrip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'."
441				)
442			elif len(separators) == 1:
443				value, description = text[1:separators[0]].strip(), text[separators[0] + 1:].strip()
444			else:
445				value, description = text[1:].strip(), ""
446
447			if value == "":
448				raise SphinxExtensionError(f"'{text.rstrip()}' is an entry without text.")
449
450			closed = False
451			while len(ancestors) > 0 and ancestors[-1][0] > indentation:
452				ancestors.pop()
453				closed = True
454
455			if len(ancestors) > 0 and ancestors[-1][0] == indentation:
456				ancestors.pop()
457			elif closed:
458				raise SphinxExtensionError(
459					f"'{text.rstrip()}' is indented less than the entry above, but not as deep as one of its ancestors."
460				)
461
462			parent = ancestors[-1][1] if len(ancestors) > 0 else None
463			entry: _Entry = Node(
464				value=value, keyValuePairs={"index": index, "marker": marker, "description": description}, parent=parent
465			)
466			if parent is None:
467				roots.append(entry)
468
469			ancestors.append((indentation, entry))
470
471		if len(roots) == 0:
472			raise SphinxExtensionError("The directive's content holds no entry.")
473
474		return roots
475
476	def _BuildItem(
477		self,
478		entry: _Entry,
479		icons: dict[str, str],
480		expandedLevels: Nullable[int],
481		descriptionColumn: bool
482	) -> TreeItem:
483		"""
484		Build an entry and, below it, its children.
485
486		The entry's text and description are parsed as inline ReST at the line they were written on, so a message about
487		a role in them points to that line. Such messages follow the entry's text in the list item.
488
489		:param entry:             The entry, as :meth:`_ParseEntries` read it.
490		:param icons:             The icons, keyed by the option naming them, or by their marker.
491		:param expandedLevels:    How many levels are expanded initially; ``None`` expands all.
492		:param descriptionColumn: Whether the tree has descriptions: then the entry's text ends with a description, empty
493		                          if the entry has none.
494		:returns:                 The entry as a list item, holding its text and a bullet list of its children.
495		"""
496		line = self.content_offset + entry["index"] + 1
497		textNodes, messages = self.state.inline_text(entry.Value, line)
498
499		if entry.IsRoot:
500			kind = "root"
501		elif entry.HasChildren:
502			kind = "node"
503		else:
504			kind = "leaf"
505
506		entryIcon = icons[entry["marker"]] if entry["marker"] in icons else icons[f"{kind}-icon"]
507
508		item = TreeItem("", classes=[f"tree-{kind}"] + (["tree-expandable"] if entry.HasChildren else []))
509		item["columnWidth"] = None
510		item += (label := TreeLabel(entry.Value, "", *textNodes, icon=entryIcon, level=entry.Level))
511		if descriptionColumn:
512			description = TreeDescription(entry["description"])
513			if entry["description"] != "":
514				descriptionNodes, descriptionMessages = self.state.inline_text(entry["description"], line)
515				description += descriptionNodes
516				messages += descriptionMessages
517				label += TreeSeparator("", SEPARATOR)
518
519			label += description
520
521		item += messages
522
523		if not entry.HasChildren:
524			item["expanded"] = None
525			label["expandedIcon"] = None
526			label["collapsedIcon"] = None
527			return item
528
529		item["expanded"] = expandedLevels is None or entry.Level < expandedLevels
530		label["expandedIcon"] = icons["expanded-icon"]
531		label["collapsedIcon"] = icons["collapsed-icon"]
532
533		item += (children := nodes.bullet_list())
534		for child in entry.GetChildren():
535			children += self._BuildItem(child, icons, expandedLevels, descriptionColumn)
536
537		return item
538
539	@staticmethod
540	def _ColumnWidth(labels: Iterable[TreeLabel]) -> str:
541		"""
542		Estimate the width of the column of the entries' texts in a tree with descriptions, from the tree's left edge.
543
544		An entry needs its level's indentation, its expander, and its icon, a gap and its text. HTML renders the text
545		later, in a font chosen by the theme, so the text's width is estimated in character cells, CSS unit ``ch``: two
546		for a wide character, e.g. an emoji, none for a combining or format character, e.g. a variation selector, and
547		one for every other character. The gap counts as one cell.
548
549		The width is the widest entry's: per level, the indentation and the most cells at that level, and of these the
550		maximum, computed by the browser with CSS function ``max()``. The indentation is CSS custom property
551		``--pyTooling-tree-level``, so a project's indentation is accounted for.
552
553		:param labels: The entries' texts, with their level, icon and description.
554		:returns:      The width as a CSS length.
555		"""
556		cellsPerLevel: dict[int, int] = {}
557		for label in labels:
558			text = "".join(
559				child.astext() for child in label.children if not isinstance(child, (TreeSeparator, TreeDescription))
560			)
561			if label["icon"] != "":
562				text = f"{label['icon']} {text}"
563
564			cells = sum(
565				2 if east_asian_width(character) in ("W", "F") else 0 if category(character) in ("Mn", "Me", "Cf") else 1
566				for character in text
567			)
568			cellsPerLevel[label["level"]] = max(cells, cellsPerLevel.get(label["level"], 0))
569
570		widths = ", ".join(
571			f"{cells}ch" if level == 0 else f"calc({level} * var(--pyTooling-tree-level) + {cells}ch)"
572			for level, cells in sorted(cellsPerLevel.items())
573		)
574		return f"calc(var(--pyTooling-tree-expander-width) + max({widths}))"
575
576
577@export
578class RemoveTreeLegends(SphinxPostTransform):
579	"""
580	A post-transform removing the trees' legends from the output formats, whose trees show no icons: LaTeX, text,
581	manual pages and Texinfo.
582
583	These formats write a tree as a nested bullet list, so a legend would explain icons the reader doesn't see.
584	"""
585
586	default_priority = 200                                  #: Priority among the post-transforms.
587	formats =          ("latex", "text", "man", "texinfo")  #: Output formats the transform runs for.
588
589	def run(self, **kwargs: Any) -> None:
590		"""
591		Remove every legend of a tree.
592
593		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
594		"""
595		for legend in list(self.document.findall(TreeLegend)):
596			legend.parent.remove(legend)