mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
fix stable scrollbars
This commit is contained in:
@@ -3,22 +3,21 @@ Screen {
|
||||
}
|
||||
|
||||
#tree-view {
|
||||
display: none;
|
||||
display: none;
|
||||
scrollbar-gutter: stable;
|
||||
width: auto;
|
||||
overflow: auto;
|
||||
overflow-y: scroll;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
dock: left;
|
||||
|
||||
}
|
||||
|
||||
CodeBrowser.-show-tree #tree-view {
|
||||
display: block;
|
||||
dock: left;
|
||||
height: 100%;
|
||||
display: block;
|
||||
max-width: 50%;
|
||||
background: #151C25;
|
||||
}
|
||||
|
||||
DirectoryTree {
|
||||
padding-right: 1;
|
||||
}
|
||||
|
||||
#code-view {
|
||||
overflow: auto scroll;
|
||||
|
||||
@@ -39,7 +39,7 @@ class CodeBrowser(App):
|
||||
path = "./" if len(sys.argv) < 2 else sys.argv[1]
|
||||
yield Header()
|
||||
yield Container(
|
||||
Vertical(DirectoryTree(path), id="tree-view"),
|
||||
DirectoryTree(path, id="tree-view"),
|
||||
Vertical(Static(id="code", expand=True), id="code-view"),
|
||||
)
|
||||
yield Footer()
|
||||
@@ -47,8 +47,11 @@ class CodeBrowser(App):
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self.query_one(DirectoryTree).focus()
|
||||
|
||||
def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None:
|
||||
def on_directory_tree_file_selected(
|
||||
self, event: DirectoryTree.FileSelected
|
||||
) -> None:
|
||||
"""Called when the user click a file in the directory tree."""
|
||||
event.stop()
|
||||
code_view = self.query_one("#code", Static)
|
||||
try:
|
||||
syntax = Syntax.from_path(
|
||||
|
||||
@@ -458,6 +458,7 @@ class Compositor:
|
||||
|
||||
# Add top level (root) widget
|
||||
add_widget(root, size.region, size.region, ((0,),), layer_order, size.region)
|
||||
root.log(map)
|
||||
return map, widgets
|
||||
|
||||
@property
|
||||
|
||||
@@ -62,6 +62,8 @@ def get_box_model(
|
||||
content_width = Fraction(
|
||||
get_content_width(content_container - styles.margin.totals, viewport)
|
||||
)
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
|
||||
content_width += styles.scrollbar_size_vertical
|
||||
else:
|
||||
# An explicit width
|
||||
styles_width = styles.width
|
||||
@@ -97,6 +99,8 @@ def get_box_model(
|
||||
content_height = Fraction(
|
||||
get_content_height(content_container, viewport, int(content_width))
|
||||
)
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto":
|
||||
content_height += styles.scrollbar_size_horizontal
|
||||
else:
|
||||
styles_height = styles.height
|
||||
# Explicit height set
|
||||
|
||||
@@ -556,7 +556,7 @@ class StylesBase(ABC):
|
||||
"""Get the style properties associated with this node only (not including parents in the DOM).
|
||||
|
||||
Returns:
|
||||
Style: Rich Style object
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
style = Style(
|
||||
color=(self.color.rich_color if self.has_rule("color") else None),
|
||||
|
||||
@@ -75,8 +75,9 @@ class ScrollView(Widget):
|
||||
):
|
||||
self._size = size
|
||||
virtual_size = self.virtual_size
|
||||
self._scroll_update(virtual_size)
|
||||
self._container_size = size - self.styles.gutter.totals
|
||||
self._scroll_update(virtual_size)
|
||||
|
||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||
self.refresh()
|
||||
|
||||
|
||||
@@ -386,6 +386,7 @@ class Widget(DOMNode):
|
||||
|
||||
Args:
|
||||
name (str): Name of component.
|
||||
partial (bool, optional): Return a partial style (not combined with parent).
|
||||
|
||||
Returns:
|
||||
Style: A Rich style object.
|
||||
@@ -915,8 +916,6 @@ class Widget(DOMNode):
|
||||
int: Number of rows in the horizontal scrollbar.
|
||||
"""
|
||||
styles = self.styles
|
||||
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
|
||||
return styles.scrollbar_size_horizontal
|
||||
return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0
|
||||
|
||||
@property
|
||||
|
||||
@@ -652,7 +652,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
|
||||
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
|
||||
region = self._get_cell_region(self.cursor_row, self.cursor_column)
|
||||
spacing = self._get_cell_border() + self.scrollbar_gutter
|
||||
spacing = self._get_cell_border()
|
||||
self.scroll_to_region(region, animate=animate, spacing=spacing)
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
|
||||
@@ -5,9 +5,11 @@ from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from rich.text import Text, TextType
|
||||
|
||||
from ..message import Message
|
||||
from ._tree import Tree, TreeNode, TOGGLE_STYLE
|
||||
from .._types import MessageTarget
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,6 +53,11 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSelected(Message, bubble=True):
|
||||
def __init__(self, sender: MessageTarget, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__(sender)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
@@ -68,6 +75,22 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def process_label(self, label: TextType):
|
||||
"""Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
label (TextType): Label.
|
||||
|
||||
Returns:
|
||||
Text: A Rich Text object.
|
||||
"""
|
||||
if isinstance(label, str):
|
||||
text_label = Text(label)
|
||||
else:
|
||||
text_label = label
|
||||
first_line = text_label.split()[0]
|
||||
return first_line
|
||||
|
||||
def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
|
||||
node_label = node._label.copy()
|
||||
node_label.stylize(style)
|
||||
@@ -120,8 +143,20 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
self.load_directory(self.root)
|
||||
|
||||
def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None:
|
||||
event.stop()
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if dir_entry.is_dir and not dir_entry.loaded:
|
||||
self.load_directory(event.node)
|
||||
if dir_entry.is_dir:
|
||||
if not dir_entry.loaded:
|
||||
self.load_directory(event.node)
|
||||
else:
|
||||
self.emit_no_wait(self.FileSelected(self, dir_entry.path))
|
||||
|
||||
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||
event.stop()
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if not dir_entry.is_dir:
|
||||
self.emit_no_wait(self.FileSelected(self, dir_entry.path))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, ClassVar, Generic, NewType, TypeVar
|
||||
from typing import ClassVar, Generic, NewType, TypeVar
|
||||
|
||||
import rich.repr
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.style import Style, NULL_STYLE
|
||||
from rich.text import Text, TextType
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class _TreeLine:
|
||||
"""The node associated with this line."""
|
||||
return self.path[-1]
|
||||
|
||||
def _get_line_width(self, guide_depth: int) -> int:
|
||||
def _get_guide_width(self, guide_depth: int, show_root: bool) -> int:
|
||||
"""Get the cell width of the line as rendered.
|
||||
|
||||
Args:
|
||||
@@ -50,7 +50,8 @@ class _TreeLine:
|
||||
Returns:
|
||||
int: Width in cells.
|
||||
"""
|
||||
return (len(self.path)) + self.path[-1]._label.cell_len - guide_depth
|
||||
guides = max(0, len(self.path) - (1 if show_root else 2)) * guide_depth
|
||||
return guides
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -74,8 +75,8 @@ class TreeNode(Generic[TreeDataType]):
|
||||
self._expanded = expanded
|
||||
self._children: list[TreeNode] = []
|
||||
|
||||
self._hover = False
|
||||
self._selected = False
|
||||
self._hover_ = False
|
||||
self._selected_ = False
|
||||
self._allow_expand = allow_expand
|
||||
self._updates: int = 0
|
||||
self._line: int = -1
|
||||
@@ -85,8 +86,8 @@ class TreeNode(Generic[TreeDataType]):
|
||||
yield self.data
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._hover = False
|
||||
self._selected = False
|
||||
self._hover_ = False
|
||||
self._selected_ = False
|
||||
self._updates += 1
|
||||
|
||||
def line(self) -> int:
|
||||
@@ -94,22 +95,24 @@ class TreeNode(Generic[TreeDataType]):
|
||||
return self._line
|
||||
|
||||
@property
|
||||
def hover(self) -> bool:
|
||||
return self._hover
|
||||
def _hover(self) -> bool:
|
||||
"""bool: Check if the mouse is over the node."""
|
||||
return self._hover_
|
||||
|
||||
@hover.setter
|
||||
def hover(self, hover: bool) -> None:
|
||||
@_hover.setter
|
||||
def _hover(self, hover: bool) -> None:
|
||||
self._updates += 1
|
||||
self._hover = hover
|
||||
self._hover_ = hover
|
||||
|
||||
@property
|
||||
def selected(self) -> bool:
|
||||
return self._selected
|
||||
def _selected(self) -> bool:
|
||||
"""bool: Check if the node is selected."""
|
||||
return self._selected_
|
||||
|
||||
@selected.setter
|
||||
def selected(self, selected: bool) -> None:
|
||||
@_selected.setter
|
||||
def _selected(self, selected: bool) -> None:
|
||||
self._updates += 1
|
||||
self._selected = selected
|
||||
self._selected_ = selected
|
||||
|
||||
@property
|
||||
def id(self) -> NodeID:
|
||||
@@ -147,16 +150,12 @@ class TreeNode(Generic[TreeDataType]):
|
||||
|
||||
@property
|
||||
def is_expanded(self) -> bool:
|
||||
"""Check if the node is expanded."""
|
||||
"""bool: Check if the node is expanded."""
|
||||
return self._expanded
|
||||
|
||||
@property
|
||||
def is_last(self) -> bool:
|
||||
"""Check if this is the last child.
|
||||
|
||||
Returns:
|
||||
bool: True if this is the last child, otherwise False.
|
||||
"""
|
||||
"""bool: Check if this is the last child."""
|
||||
if self._parent is None:
|
||||
return True
|
||||
return bool(
|
||||
@@ -165,6 +164,7 @@ class TreeNode(Generic[TreeDataType]):
|
||||
|
||||
@property
|
||||
def allow_expand(self) -> bool:
|
||||
"""bool: Check if the node is allowed to expand."""
|
||||
return self._allow_expand
|
||||
|
||||
def add(
|
||||
@@ -186,10 +186,7 @@ class TreeNode(Generic[TreeDataType]):
|
||||
Returns:
|
||||
TreeNode[TreeDataType]: A new Tree node
|
||||
"""
|
||||
if isinstance(label, str):
|
||||
text_label = Text.from_markup(label)
|
||||
else:
|
||||
text_label = label
|
||||
text_label = self._tree.process_label(label)
|
||||
node = self._tree._add_node(self, text_label, data)
|
||||
node._expanded = expand
|
||||
node._allow_expand = allow_expand
|
||||
@@ -284,6 +281,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
}
|
||||
|
||||
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is selected."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
@@ -291,6 +290,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
super().__init__(sender)
|
||||
|
||||
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is expanded."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
@@ -298,6 +299,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
super().__init__(sender)
|
||||
|
||||
class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True):
|
||||
"""Event sent when a node is collapsed."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
|
||||
) -> None:
|
||||
@@ -324,24 +327,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024)
|
||||
self._tree_lines_cached: list[_TreeLine] | None = None
|
||||
self.cursor_node: TreeNode[TreeDataType] | None = None
|
||||
self._cursor_node: TreeNode[TreeDataType] | None = None
|
||||
|
||||
# @property
|
||||
# def cursor_node(self) -> TreeNode[TreeDataType] | None:
|
||||
# """The node under the cursor, which may be None if no cursor is visible.
|
||||
@property
|
||||
def cursor_node(self) -> TreeNode[TreeDataType] | None:
|
||||
"""TreeNode | Node: The currently selected node, or ``None`` if no selection."""
|
||||
return self._cursor_node
|
||||
|
||||
# Returns:
|
||||
# TreeNode[TreeDataType] | None: A Tree node or None.
|
||||
# """
|
||||
# if self.cursor_line == -1:
|
||||
# return None
|
||||
# try:
|
||||
# self._tree_lines[self.cursor_line].path[-1]
|
||||
# except IndexError:
|
||||
# return None
|
||||
|
||||
@classmethod
|
||||
def process_label(cls, label: TextType):
|
||||
def process_label(self, label: TextType):
|
||||
"""Process a str or Text in to a label. Maybe overridden in a subclass to change modify how labels are rendered.
|
||||
|
||||
Args:
|
||||
@@ -394,6 +387,21 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
text = Text.assemble(prefix, node_label)
|
||||
return text
|
||||
|
||||
def get_label_width(self, node: TreeNode[TreeDataType]) -> int:
|
||||
"""Get the width of the nodes label.
|
||||
|
||||
The default behavior is to call `render_node` and return the cell length. This method may be
|
||||
overridden in a sub-class if it can be done more efficiently.
|
||||
|
||||
Args:
|
||||
node (TreeNode[TreeDataType]): A node.
|
||||
|
||||
Returns:
|
||||
int: Width in cells.
|
||||
"""
|
||||
label = self.render_label(node, NULL_STYLE, NULL_STYLE)
|
||||
return label.cell_len
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all nodes under root."""
|
||||
self._tree_lines_cached = None
|
||||
@@ -508,25 +516,25 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
previous_node = self._get_node(previous_hover_line)
|
||||
if previous_node is not None:
|
||||
self._refresh_node(previous_node)
|
||||
previous_node.hover = False
|
||||
previous_node._hover = False
|
||||
|
||||
node = self._get_node(hover_line)
|
||||
if node is not None:
|
||||
self._refresh_node(node)
|
||||
node.hover = True
|
||||
node._hover = True
|
||||
|
||||
def watch_cursor_line(self, previous_line: int, line: int) -> None:
|
||||
previous_node = self._get_node(previous_line)
|
||||
if previous_node is not None:
|
||||
self._refresh_node(previous_node)
|
||||
previous_node.selected = False
|
||||
self.cursor_node = None
|
||||
previous_node._selected = False
|
||||
self._cursor_node = None
|
||||
|
||||
node = self._get_node(line)
|
||||
if node is not None:
|
||||
self._refresh_node(node)
|
||||
node.selected = True
|
||||
self.cursor_node = node
|
||||
node._selected = True
|
||||
self._cursor_node = node
|
||||
|
||||
def watch_guide_depth(self, guide_depth: int) -> None:
|
||||
self._invalidate()
|
||||
@@ -588,7 +596,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
return self._tree_lines_cached
|
||||
|
||||
def _build(self) -> None:
|
||||
"""Builds the tree by traversing nodes, and creating tree lines."""
|
||||
|
||||
TreeLine = _TreeLine
|
||||
lines: list[_TreeLine] = []
|
||||
add_line = lines.append
|
||||
|
||||
@@ -597,10 +607,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None:
|
||||
child_path = [*path, node]
|
||||
node._line = len(lines)
|
||||
add_line(_TreeLine(child_path, last))
|
||||
add_line(TreeLine(child_path, last))
|
||||
if node._expanded:
|
||||
for last, child in loop_last(node._children):
|
||||
add_node(child_path, child, last=last)
|
||||
add_node(child_path, child, last)
|
||||
|
||||
if self.show_root:
|
||||
add_node([], root, True)
|
||||
@@ -610,8 +620,16 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._tree_lines_cached = lines
|
||||
|
||||
guide_depth = self.guide_depth
|
||||
show_root = self.show_root
|
||||
get_label_width = self.get_label_width
|
||||
|
||||
def get_line_width(line: _TreeLine) -> int:
|
||||
return get_label_width(line.node) + line._get_guide_width(
|
||||
guide_depth, show_root
|
||||
)
|
||||
|
||||
if lines:
|
||||
width = max([line._get_line_width(guide_depth) for line in lines])
|
||||
width = max([get_line_width(line) for line in lines])
|
||||
else:
|
||||
width = self.size.width
|
||||
|
||||
@@ -620,6 +638,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self.cursor_line = self.cursor_node._line
|
||||
if self.cursor_line >= len(lines):
|
||||
self.cursor_line = -1
|
||||
self.refresh()
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
width = self.size.width
|
||||
@@ -751,6 +770,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._invalidate()
|
||||
|
||||
def _toggle_node(self, node: TreeNode[TreeDataType]) -> None:
|
||||
if not node.allow_expand:
|
||||
return
|
||||
if node.is_expanded:
|
||||
node.collapse()
|
||||
self.post_message_no_wait(self.NodeCollapsed(self, node))
|
||||
@@ -768,10 +789,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._toggle_node(node)
|
||||
|
||||
else:
|
||||
if self.cursor_line == cursor_line:
|
||||
await self.action("select_cursor")
|
||||
else:
|
||||
self.cursor_line = cursor_line
|
||||
self.cursor_line = cursor_line
|
||||
await self.action("select_cursor")
|
||||
|
||||
def _on_styles_updated(self) -> None:
|
||||
self._invalidate()
|
||||
|
||||
Reference in New Issue
Block a user