pythoncssdynamictextual

Python textual creating a CSS programmatically


I'm trying to create a Python Textual Widget that can be reused on a lot of places (basic a search widget). It should have a grid with the title, any number of labels with inputs, and a last line for the buttons (Search and Cancel). While I was able to create the Widget, whenever I reuse it, it seems that the CSS I used is being ignored, and only the first CSS is actually applied, can someone please help me out? Here's the code for the Widget:

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)         

And here's how to use it (just a snippet of course):

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)

If I call that method, it creates the widget correctly, however if I call after this a simpler one (for instance with only one dialog_input), it keeps the same CSS applied, and everything is kind out of order.

I've tried creating a random unique id for each widget (based on the name), and only the first one is still being applied, it seems that the self.CSS = ... code block is not being called, but the compose is actually trying to call the correct CSS.

Can someone help me out?

Best regards.


Solution

  • Dynamically creating your CSS is not advisable. Some of the work you are doing to build the CSS attribute can be done in plain CSS. For instance height can be set to auto which will calculate an optimal height automatically. You can also use auto in grid-columns to automatically calculate the height.

    If there is anything which is too dynamic to express in CSS, it is best done in on_mount where you can update styles programatically. The reference for each style shows how you can set the style via Python.