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
相关产品推荐
相关产品推荐

