Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Table header columns width mismatch #4404

Closed
max-arnold opened this issue Apr 8, 2024 · 12 comments
Closed

Table header columns width mismatch #4404

max-arnold opened this issue Apr 8, 2024 · 12 comments

Comments

@max-arnold
Copy link

Is the header column width mismatch a known issue? Happens intermittently and goes away on terminal defocus. Seems to be caused by this method on my Screen subclass that updates the Static widget under the table:

    def on_data_table_row_highlighted(self, row: DataTable.RowSelected) -> None:
        self.account = row.row_key.value
        accdet = self.query_one("#accdet")
        accdet.update(self.account or "")
CleanShot 2024-04-09 at 04 36 03@2x
from textual import work
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, DataTable, Static
from textual.binding import Binding

import time, datetime


class AccountsListScreen(Screen[None]):
    BINDINGS = [
        ("ctrl+r", "refresh", "Refresh"),
    ]

    account: str | None = None
    accounts: dict[str, dict] = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield DataTable(cursor_type="row")
        yield Static(id="accdet")
        yield Footer()

    def on_mount(self) -> None:
        self.sub_title = "Accounts"
        table = self.query_one(DataTable)
        #table.styles.height = '20%'
        table.add_columns("Name", "Instance", "Status", "Created at", "Updated at")
        self.get_accounts()

    def action_refresh(self) -> None:
        self.get_accounts()

    def on_data_table_row_highlighted(self, row: DataTable.RowSelected) -> None:
        self.account = row.row_key.value
        accdet = self.query_one("#accdet")
        accdet.update(self.account or "")
        # self.notify(f"Row {row.row_key.value}", title="Row selected")

    @work(exclusive=True, thread=True)
    def get_accounts(self) -> None:
        table = self.query_one(DataTable)
        table.loading = True
        table.clear()
        time.sleep(0.5)  # synchronous DB call
        accounts = [
            {
                "name": 'Test Account',
                "instance": "default",
                "status": "active",
                "created_at": datetime.datetime.now(),
                "updated_at": datetime.datetime.now(),
            },
            {
                "name": 'Second Test Account',
                "instance": "default",
                "status": "active",
                "created_at": datetime.datetime.now(),
                "updated_at": datetime.datetime.now(),
            },
        ]
        self.account = None
        for acc in accounts:
            table.add_row(
                acc["name"],
                acc["instance"],
                acc["status"],
                acc["created_at"].strftime('%Y-%m-%d %H:%M:%S'),
                acc["updated_at"].strftime('%Y-%m-%d %H:%M:%S'),
                key=acc["name"],
            )
            self.accounts[acc["name"]] = acc
            self.account = self.account or acc["name"]
        table.border_title = f"{len(accounts)} accounts"
        table.loading = False
        table.focus()


class BackofficeApp(App):
    MODES = {
        "accounts": AccountsListScreen,
    }

    BINDINGS = [
        ("q", "quit()", "Quit"),
        Binding("f12", "take_screenshot()", "Take screenshot", show=False),
    ]

    CSS = """
    DataTable {
    border: solid $accent;
    }
    """

    def on_mount(self) -> None:
        self.title = "Title"
        self.switch_mode("accounts")

    def action_take_screenshot(self) -> None:
        filename = self.save_screenshot()
        self.notify(f"Saved in {filename}", title="Screenshot saved")


app = BackofficeApp()

if __name__ == "__main__":
    app.run()
textual diagnose
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.56.0 |
| Rich    | 13.7.1 |

## Python

| Name           | Value                                                   |
|----------------|---------------------------------------------------------|
| Version        | 3.12.0                                                  |
| Implementation | CPython                                                 |
| Compiler       | Clang 14.0.3 (clang-1403.0.22.14.1)                     |
| Executable     | /Users/user/.virtualenvs/textual-db/bin/python |

## Operating System

| Name    | Value                                                                                                  |
|---------|--------------------------------------------------------------------------------------------------------|
| System  | Darwin                                                                                                 |
| Release | 22.6.0                                                                                                 |
| Version | Darwin Kernel Version 22.6.0: Mon Feb 19 19:48:53 PST 2024; root:xnu-8796.141.3.704.6~1/RELEASE_X86_64 |

## Terminal

| Name                 | Value              |
|----------------------|--------------------|
| Terminal Application | iTerm.app (3.4.23) |
| TERM                 | xterm-256color     |
| COLORTERM            | truecolor          |
| FORCE_COLOR          | *Not set*          |
| NO_COLOR             | *Not set*          |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=114, height=55 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 114                  |
| is_terminal    | False                |
| encoding       | utf-8                |
| max_height     | 55                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |
Copy link

github-actions bot commented Apr 8, 2024

We found the following entries in the FAQ which you may find helpful:

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by FAQtory

@willmcgugan
Copy link
Collaborator

See the following advice about threaded workers https://textual.textualize.io/guide/workers/#posting-messages

I suspect fixing that will resolve your issue.

@max-arnold
Copy link
Author

Yep, that fixed the problem! Sorry for the noise.

Copy link

github-actions bot commented Apr 8, 2024

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

@max-arnold
Copy link
Author

max-arnold commented Apr 13, 2024

Updated the code to send a Message, but I still get race conditions when navigating around the table (rarely) or holding Ctrl+R for a couple of seconds (more often). The end result is an empty Markdown widget and refresh doesn't help anymore:

CleanShot 2024-04-13 at 18 18 45@2x
import json

from textual import work
from textual.app import App, ComposeResult
from textual.message import Message
from textual.screen import Screen
from textual.widgets import Header, Footer, DataTable, Markdown
from textual.binding import Binding

import time, datetime


class AccountsListScreen(Screen[None]):
    BINDINGS = [
        ("ctrl+r", "refresh", "Refresh"),
    ]

    CSS = """
    Markdown {
    margin: 0 0 0 0;
    padding: 1 1 0 1;
    border: solid $panel-lighten-3;
    }
    """
    account: str | None = None
    accounts: dict[str, dict] = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield DataTable(cursor_type="row")
        yield Markdown(id="detail")
        yield Footer()

    def on_mount(self) -> None:
        self.sub_title = "Accounts"
        table = self.query_one(DataTable)
        table.add_columns("Name", "Instance", "Status", "Created at", "Updated at")
        self.refresh_accounts()

    def action_refresh(self) -> None:
        self.refresh_accounts()

    def on_data_table_row_highlighted(self, row: DataTable.RowSelected) -> None:
        self.account = row.row_key.value
        detail = self.query_one("#detail")
        if self.account:
            acc = self.accounts[self.account].copy()
            acc["created_at"] = acc["created_at"].strftime('%Y-%m-%d %H:%M:%S')
            acc["updated_at"] = acc["updated_at"].strftime('%Y-%m-%d %H:%M:%S')
            MD = f"""
* Name: {acc["name"]}
* Instance: {acc["instance"]}
* Status: {acc["status"]}
* Created at: {acc["created_at"]}
* Updated at: {acc["updated_at"]}

### Params
```json
{json.dumps(acc, indent=2)}
```
""".strip()
            detail.update(MD)
            detail.border_title = self.account
        else:
            detail.update("")
            detail.border_title = ""

    class DBReturn(Message):
        def __init__(self, accounts) -> None:
            self.accounts = accounts
            super().__init__()

    #@work(exclusive=True)
    def refresh_accounts(self) -> None:
        table = self.query_one(DataTable)
        # if table.loading:
        #     return
        table.loading = True
        self.get_accounts()

    @work(exclusive=True, thread=True)
    def get_accounts(self) -> None:
        time.sleep(1)  # synchronous DB call
        accounts = [
            {
                "name": f'Test Account {i}',
                "instance": "default",
                "status": "active",
                "created_at": datetime.datetime.now(),
                "updated_at": datetime.datetime.now(),
            }
            for i in range(10)
        ]
        # self.app.call_from_thread(self.on_accounts_list_screen_dbreturn, accounts)
        self.post_message(self.DBReturn(accounts))

    # @work(exclusive=True)
    def on_accounts_list_screen_dbreturn(self, accounts):
        table = self.query_one(DataTable)
        table.clear()
        self.account = None
        self.accounts = {}
        for acc in accounts.accounts:
            table.add_row(
                acc["name"],
                acc["instance"],
                acc["status"],
                acc["created_at"].strftime('%Y-%m-%d %H:%M:%S'),
                acc["updated_at"].strftime('%Y-%m-%d %H:%M:%S'),
                key=acc["name"],
            )
            self.accounts[acc["name"]] = acc
            self.account = self.account or acc["name"]
        table.border_title = f"{len(accounts.accounts)} accounts"
        table.loading = False
        table.focus()


class BackofficeApp(App):
    MODES = {
        "accounts": AccountsListScreen,
    }

    BINDINGS = [
        ("q", "quit()", "Quit"),
        Binding("f12", "take_screenshot()", "Take screenshot", show=False),
    ]

    CSS = """
    DataTable {
    border: solid $accent;
    }
    """

    def on_mount(self) -> None:
        self.title = "Title"
        self.switch_mode("accounts")

    def action_take_screenshot(self) -> None:
        filename = self.save_screenshot()
        self.notify(f"Saved in {filename}", title="Screenshot saved")


app = BackofficeApp()

if __name__ == "__main__":
    app.run()

@max-arnold max-arnold reopened this Apr 13, 2024
@TomJGooding
Copy link
Contributor

That's quite a bit of code to wade through, but I don't understand why you have so many workers and async handlers?

@max-arnold
Copy link
Author

max-arnold commented Apr 14, 2024

I tried with only one worker (the threaded one), but then wrapped the remaining two callbacks into async ones to see if that helps (it didn't). I just updated the code example so it no longer has the extra workers.

UPD: also fixed the formatting issues

@willmcgugan
Copy link
Collaborator

Threaded workers cannot be forced to cancel (a limit of threads). Which means that you need to manually check if the worker is cancelled.

I've made a couple of tweaks to your code, and I can't reproduce that issue now:

import json

from textual import work
from textual.app import App, ComposeResult
from textual.message import Message
from textual.screen import Screen
from textual.widgets import Header, Footer, DataTable, Markdown
from textual.binding import Binding
from textual.worker import get_current_worker

import time, datetime


class AccountsListScreen(Screen[None]):
    BINDINGS = [
        ("ctrl+r", "refresh", "Refresh"),
    ]

    CSS = """
    Markdown {
    margin: 0 0 0 0;
    padding: 1 1 0 1;
    border: solid $panel-lighten-3;
    }
    """
    account: str | None = None
    accounts: dict[str, dict] = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield DataTable(cursor_type="row")
        yield Markdown(id="detail")
        yield Footer()

    def on_mount(self) -> None:
        self.sub_title = "Accounts"
        table = self.query_one(DataTable)
        table.add_columns("Name", "Instance", "Status", "Created at", "Updated at")
        self.refresh_accounts()

    def action_refresh(self) -> None:
        self.refresh_accounts()

    def on_data_table_row_highlighted(self, row: DataTable.RowSelected) -> None:
        self.account = row.row_key.value
        detail = self.query_one("#detail")
        if self.account:
            acc = self.accounts[self.account].copy()
            acc["created_at"] = acc["created_at"].strftime("%Y-%m-%d %H:%M:%S")
            acc["updated_at"] = acc["updated_at"].strftime("%Y-%m-%d %H:%M:%S")
            MD = f"""
* Name: {acc["name"]}
* Instance: {acc["instance"]}
* Status: {acc["status"]}
* Created at: {acc["created_at"]}
* Updated at: {acc["updated_at"]}

### Params
```json
{json.dumps(acc, indent=2)}
```
""".strip()
            detail.update(MD)
            detail.border_title = self.account
        else:
            detail.update("")
            detail.border_title = ""

    class DBReturn(Message):
        def __init__(self, accounts) -> None:
            self.accounts = accounts
            super().__init__()

    # @work(exclusive=True)
    def refresh_accounts(self) -> None:
        table = self.query_one(DataTable)
        # if table.loading:
        #     return
        table.loading = True
        self.get_accounts()

    @work(exclusive=True, thread=True)
    def get_accounts(self) -> None:
        time.sleep(1)  # synchronous DB call
        accounts = [
            {
                "name": f"Test Account {i}",
                "instance": "default",
                "status": "active",
                "created_at": datetime.datetime.now(),
                "updated_at": datetime.datetime.now(),
            }
            for i in range(10)
        ]
        # self.app.call_from_thread(self.on_accounts_list_screen_dbreturn, accounts)
        if not get_current_worker().is_cancelled:
            self.post_message(self.DBReturn(accounts))

    # @work(exclusive=True)
    def on_accounts_list_screen_dbreturn(self, accounts):
        table = self.query_one(DataTable)
        table.clear()
        self.account = None
        self.accounts = {}
        for acc in accounts.accounts:
            table.add_row(
                acc["name"],
                acc["instance"],
                acc["status"],
                acc["created_at"].strftime("%Y-%m-%d %H:%M:%S"),
                acc["updated_at"].strftime("%Y-%m-%d %H:%M:%S"),
                key=acc["name"],
            )
            self.accounts[acc["name"]] = acc
            self.account = self.account or acc["name"]
        table.border_title = f"{len(accounts.accounts)} accounts"
        table.loading = False
        table.focus()


class BackofficeApp(App):
    MODES = {
        "accounts": AccountsListScreen,
    }

    BINDINGS = [
        ("q", "quit()", "Quit"),
        Binding("f12", "take_screenshot()", "Take screenshot", show=False),
    ]

    CSS = """
    DataTable {
    border: solid $accent;
    }
    """

    def on_mount(self) -> None:
        self.title = "Title"
        self.switch_mode("accounts")

    def action_take_screenshot(self) -> None:
        filename = self.save_screenshot()
        self.notify(f"Saved in {filename}", title="Screenshot saved")


app = BackofficeApp()

if __name__ == "__main__":
    app.run()

@max-arnold
Copy link
Author

I tried your version and the Ctrl+R problem seems to be gone. Thanks!

But the Markdown widget is still get blanked out when I repeatedly press UP and DOWN keys:

2024-04-14 17 56 47

Wrapping the on_data_table_row_highlighted into an async worker doesn't help. Is something tricky required to prevent this as well?

@TomJGooding
Copy link
Contributor

Here's a quick MRE for the issue described above when rapidly updating the Markdown widget. Presumably the problem is that parsing the content is quite an expensive operation - is the best solution to await the return from Markdown.update?

from textual import on
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Markdown

EXAMPLE_MARKDOWN = """\
## Try continuously scrolling the table by holding the down key

This is an example of Textual's `Markdown` widget.

Markdown syntax and extensions are supported.

- Typography *emphasis*, **strong**, `inline code` etc.
- Headers
- Lists (bullet and ordered)
- Syntax highlighted code blocks
- Tables!
"""


class ExampleApp(App):
    CSS = """
    DataTable, Markdown {
        height: 50%;
    }
    """

    def compose(self) -> ComposeResult:
        yield DataTable(cursor_type="row")
        yield Markdown()

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_column("Column")
        for i in range(100):
            table.add_row(f"Row #{i}")

    @on(DataTable.RowHighlighted)
    def update_markdown(self, event: DataTable.RowHighlighted) -> None:
        markdown = self.query_one(Markdown)
        markdown.update(f"# Row #{event.cursor_row}\n{EXAMPLE_MARKDOWN}")


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

@max-arnold
Copy link
Author

max-arnold commented Apr 16, 2024

Apparently, the get_current_worker call is not necessary and the main problem is the way I updated the Markdown widget. So far this structure seems to be working (no issues with Ctrl+R and Up/Down):

async def on_data_table_row_highlighted(self, row):
    ...
    await detail.update(MD)

def refresh_accounts(self):
    table = self.query_one(DataTable)
    table.loading = True
    self.get_accounts()

@work(exclusive=True, thread=True)
def get_accounts(self):
    ...
    self.post_message(self.DBReturn(accounts))

async def on_accounts_list_screen_dbreturn(self, accounts):
    table = self.query_one(DataTable)
    table.clear()
    ...
    table.loading = False
    table.focus()

Does it look sane?

But the parallelism model is confusing to me. I thought that plain (non-async) functions (e.g. on_data_table_row_highlighted from my original example) block the event loop and aren't being run in parallel...

Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants