You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Python Textual通用搜索组件CSS复用失效问题求助

问题:Textual模态组件动态CSS仅首次生效,复用布局错乱

我开发了一个可复用的Python Textual Widget——通用搜索模态组件DSASearchDialog,包含标题栏、任意数量标签输入组和底部操作按钮。但复用组件时发现,动态生成的CSS仅首次生效,后续实例的CSS被忽略,导致布局混乱,即使给组件设置唯一ID也无法解决。

组件实现代码:

from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Button, Static, Input
from textual.containers import Grid
from textual import events


class DSASearchDialog(ModalScreen[dict]):
    """Generic dialog to create a search dialog. If you need anything more complex, please write your own dialog"""
    dialog_title = 'Sample Search Dialog'
    dialog_inputs = [
        {
            'name': 'Sample Label 1',
            'id': 'dsa-search-sample-name-label-1',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'text'
        },
        {
            'name': 'Sample Label 2',
            'id': 'dsa-search-sample-name-label-2',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'text'
        },
        {
            'name': 'Sample Label 3',
            'id': 'dsa-search-sample-name-label-3',
            'classes': 'dsa-search-label',
            'input-classes': 'mandatory',
            'type': 'text'
        }
    ]
    
    def build_base_css(self) -> None:
        """Build the base css"""
        # Each input has a height of 3
        input_height = len(self.dialog_inputs)*3
        # plus 4 for the buttons and an extra 3 for the title
        total_height = input_height + 4 + 5
        # Create the rows sizes
        grid_rows_txt = "grid-rows: 3" # title
        # each input
        max_label_length = 10
        for i in range(len(self.dialog_inputs)):
            grid_rows_txt += " 3"
            if len(self.dialog_inputs[i]['name']) > max_label_length:
                max_label_length = len(self.dialog_inputs[i]['name'])+3
        grid_rows_txt += " 4;" #buttons
        self.CSS = ""\
            f"#dsa-search-grid"+" {\n" \
                f"grid-size: 1 {len(self.dialog_inputs)+2};\n"\
                f"{grid_rows_txt}\n"\
                "background: $surface;\n"\
                "border: thick $background 80%;\n"\
                "padding-right: 1;\n"\
                "padding-left: 1;\n"\
                "width: 80;\n"\
                f"height: {total_height};\n"\
            "}\n"\
            ".dsa-input-field {\n"\
                "grid-size: 2;\n"\
                "align: center middle;\n"\
                f"grid-columns: {max_label_length} 1fr;\n"\
            "}"\
            
    
    def compose(self) -> ComposeResult:
        """Dynamically build the dialog based on the amount of inputs"""
        inputs_grid = Grid(
            Static(self.dialog_title, classes='dsa-search-title'),
            id=f"dsa-search-grid"
        )
        # For each input, create the Static and Input widgets, and mount it on the inputs_grid
        for input_data in self.dialog_inputs:
            grid_input = Grid(
                    Static(input_data['name'], classes=input_data['classes'], id='dsa-search-label'),
                    Input(id=f"{input_data['id']}-input", classes=input_data['input-classes']),
                    classes='dsa-input-field',
                    id=input_data['id']
                    )
            inputs_grid.mount(grid_input)
        # Create the buttons
        buttons_grid = Grid(
            Button('Search', id='dsa-search-button', classes='button'),
            Button('Cancel', id='dsa-cancel-button', classes='button', variant='error'),
            id='dsa-search-buttons'
        )
        inputs_grid.mount(buttons_grid)
        # Now yield everything
        yield inputs_grid
        
    def on_key(self, event: events.Key) -> None:
        """When the escape key is pressed, dismiss the modal, when the enter is pressed, execute the search"""
        if event.key == 'escape':
            self.dismiss(None)
        elif event.key == 'enter':
            self.on_button_pressed(Button.Pressed(self.query_one('#dsa-search-button'))) # Call the button pressed method to get the information and dismiss the modal
            event.stop()
    
    def on_button_pressed(self, event: Button.Pressed) -> None:
        """When the search button is pressed, get the information and dismiss the modal"""
        # Get the information
        information = {}
        for input_data in self.dialog_inputs:
            information[input_data['id']] = self.query_one(f'#{input_data["id"]}-input').value
        # Dismiss the modal
        if event.button.id == 'dsa-search-button':
            self.dismiss(information)
        else:
            self.dismiss(None)
        event.stop() # Stop the event propagation so the modal doesn't dismiss itself)         

组件使用示例:

def action_search(self) -> None:
        """Start the generic search dialog, and return the data needed"""
        dlg = DSASearchDialog()
        dlg.dialog_title = "Search Users on DSA"
        dlg.dialog_inputs = [
            {
                'name': 'E-Mail:',
                'id': 'dsa-search-username-label',
                'classes': 'dsa-search-label',
                'input-classes': 'optional',
                'type': 'text'
            },
            {
                'name': 'User ID:',
                'id': 'dsa-search-user-id-label',
                'classes': 'dsa-search-label',
                'input-classes': 'optional',
                'type': 'number'
            }
        ]
        dlg.build_base_css()
        self.app.push_screen(dlg, self.__search_return)

调用上述方法时组件显示正常,但调用配置更简单(仅一个输入框)的实例时,仍沿用首次生成的CSS,导致布局错乱。


原因分析

Textual中,类级别的CSS属性是静态共享的,所有类实例都会共用这一份CSS定义。首次调用build_base_css()时修改了类的CSS属性,后续实例再修改时,Textual的样式系统已经缓存了初始的CSS规则,不会重新加载更新后的内容,导致后续实例沿用第一次的CSS。


解决方案

1. 核心思路

放弃修改类级CSS,改用实例级内联样式,让每个组件实例的样式独立计算、独立应用,避免样式缓存冲突。

2. 修复后的组件代码

from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Button, Static, Input
from textual.containers import Grid
from textual import events


class DSASearchDialog(ModalScreen[dict]):
    """Generic dialog to create a search dialog. If you need anything more complex, please write your own dialog"""
    dialog_title = 'Sample Search Dialog'
    dialog_inputs = [
        {
            'name': 'Sample Label 1',
            'id': 'dsa-search-sample-name-label-1',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'text'
        },
        {
            'name': 'Sample Label 2',
            'id': 'dsa-search-sample-name-label-2',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'text'
        },
        {
            'name': 'Sample Label 3',
            'id': 'dsa-search-sample-name-label-3',
            'classes': 'dsa-search-label',
            'input-classes': 'mandatory',
            'type': 'text'
        }
    ]
    
    def compose(self) -> ComposeResult:
        """Dynamically build the dialog based on the amount of inputs"""
        # 计算当前实例的布局参数
        input_count = len(self.dialog_inputs)
        input_height = input_count * 3
        total_height = input_height + 4 + 5
        max_label_length = max(len(item['name']) + 3 for item in self.dialog_inputs) if input_count else 10
        
        # 创建主Grid并设置独立内联样式,给每个实例分配唯一ID
        inputs_grid = Grid(
            Static(self.dialog_title, classes='dsa-search-title'),
            id=f"dsa-search-grid-{id(self)}",
            styles={
                "grid_size": (1, input_count + 2),
                "grid_rows": "3 " + " 3"*input_count + " 4",
                "background": "$surface",
                "border": "thick $background 80%",
                "padding_right": 1,
                "padding_left": 1,
                "width": 80,
                "height": total_height,
            }
        )
        
        # 生成输入组,每个输入组也设置独立样式
        for input_data in self.dialog_inputs:
            grid_input = Grid(
                Static(input_data['name'], classes=input_data['classes']),
                Input(id=f"{input_data['id']}-input", classes=input_data['input-classes']),
                classes='dsa-input-field',
                id=input_data['id'],
                styles={
                    "grid_size": 2,
                    "align": "center middle",
                    "grid_columns": f"{max_label_length} 1fr",
                }
            )
            inputs_grid.mount(grid_input)
        
        # 生成按钮组
        buttons_grid = Grid(
            Button('Search', id='dsa-search-button', classes='button'),
            Button('Cancel', id='dsa-cancel-button', classes='button', variant='error'),
            id='dsa-search-buttons'
        )
        inputs_grid.mount(buttons_grid)
        
        yield inputs_grid
        
    def on_key(self, event: events.Key) -> None:
        if event.key == 'escape':
            self.dismiss(None)
        elif event.key == 'enter':
            self.on_button_pressed(Button.Pressed(self.query_one('#dsa-search-button')))
            event.stop()
    
    def on_button_pressed(self, event: Button.Pressed) -> None:
        information = {}
        for input_data in self.dialog_inputs:
            information[input_data['id']] = self.query_one(f'#{input_data["id"]}-input').value
            
        self.dismiss(information if event.button.id == 'dsa-search-button' else None)
        event.stop()

3. 调整后的使用方式

不再需要手动调用build_base_css(),直接实例化配置参数即可:

def action_search(self) -> None:
    """Start the generic search dialog, and return the data needed"""
    dlg = DSASearchDialog()
    dlg.dialog_title = "Search Users on DSA"
    dlg.dialog_inputs = [
        {
            'name': 'E-Mail:',
            'id': 'dsa-search-username-label',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'text'
        },
        {
            'name': 'User ID:',
            'id': 'dsa-search-user-id-label',
            'classes': 'dsa-search-label',
            'input-classes': 'optional',
            'type': 'number'
        }
    ]
    self.app.push_screen(dlg, self.__search_return)

关键改动说明

  • 移除了build_base_css()方法,将样式计算逻辑整合到compose阶段
  • 给每个实例的主Grid设置唯一ID(id=f"dsa-search-grid-{id(self)}"),彻底避免ID冲突
  • 使用styles字典直接给元素设置内联样式,每个实例的样式独立存储,不会互相覆盖
  • 简化了最长标签长度的计算逻辑,用max()函数一行完成

内容的提问来源于stack exchange,提问作者Mateus Interciso

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 13:17:33