diff --git a/changes/2092.misc.rst b/changes/2092.misc.rst new file mode 100644 index 0000000000..16b6a7f30a --- /dev/null +++ b/changes/2092.misc.rst @@ -0,0 +1 @@ +Dialogs were added to the textual backend. diff --git a/examples/dialogs/dialogs/app.py b/examples/dialogs/dialogs/app.py index f3971bef67..0bf817687f 100644 --- a/examples/dialogs/dialogs/app.py +++ b/examples/dialogs/dialogs/app.py @@ -6,7 +6,7 @@ from toga.style import Pack -class ExampledialogsApp(toga.App): +class ExampleDialogsApp(toga.App): # Button callback functions def do_clear(self, widget, **kwargs): self.label.text = "Ready." @@ -333,7 +333,7 @@ def startup(self): def main(): - return ExampledialogsApp("Dialogs", "org.beeware.widgets.dialogs") + return ExampleDialogsApp("Dialogs", "org.beeware.widgets.dialogs") if __name__ == "__main__": diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 923db033d5..fed7a0bce2 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -6,7 +6,8 @@ class MainWindow(Window): - pass + def textual_close(self): + self.interface.app.on_exit(None) class TogaApp(TextualApp): diff --git a/textual/src/toga_textual/dialogs.py b/textual/src/toga_textual/dialogs.py new file mode 100644 index 0000000000..553379d48c --- /dev/null +++ b/textual/src/toga_textual/dialogs.py @@ -0,0 +1,408 @@ +from abc import ABC +from pathlib import Path + +from toga_textual.window import TitleBar + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Button, DirectoryTree, Input, Label, Static + + +class TextualDialog(ModalScreen[bool]): + def __init__(self, impl): + super().__init__() + self.impl = impl + + def compose(self) -> ComposeResult: + self.title = TitleBar(self.impl.title) + self.impl.compose_content(self) + self.buttons = self.impl.create_buttons() + self.button_box = Horizontal(*self.buttons) + self.container = Vertical( + self.title, + self.content, + self.button_box, + id="dialog", + ) + yield self.container + + def on_mount(self) -> None: + self.styles.align = ("center", "middle") + + self.container.styles.width = 50 + self.container.styles.border = ("solid", "darkgray") + + self.impl.style_content(self) + + self.button_box.styles.align = ("center", "middle") + for button in self.buttons: + button.styles.margin = (0, 1, 0, 1) + + self.buttons[-1].focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(self.impl.return_value(event.button.variant)) + + def on_resize(self, event) -> None: + self.impl.style_content(self) + + +class BaseDialog(ABC): + def __init__(self, interface, title, message, on_result): + self.interface = interface + self.interface._impl = self + self.on_result = on_result + self.title = title + self.message = message + + self.native = TextualDialog(self) + self.interface.app._impl.native.push_screen(self.native, self.on_close) + + def compose_content(self, dialog): + dialog.content = Label(self.message, id="message") + + def style_content(self, dialog): + dialog.content.styles.margin = 1 + dialog.content.styles.height = 5 + + dialog.container.styles.height = 13 + + def return_value(self, variant): + return variant == "primary" + + def textual_close(self): + self.native.dismiss(None) + + def on_close(self, result: bool): + self.on_result(self, result) + self.interface.future.set_result(result) + + +class InfoDialog(BaseDialog): + def create_buttons(self): + return [ + Button("OK", variant="primary"), + ] + + def return_value(self, variant): + return None + + +class QuestionDialog(BaseDialog): + def create_buttons(self): + return [ + Button("Yes", variant="primary"), + Button("No", variant="error"), + ] + + +class ConfirmDialog(BaseDialog): + def create_buttons(self): + return [ + Button("Cancel", variant="error"), + Button("OK", variant="primary"), + ] + + +class ErrorDialog(BaseDialog): + def create_buttons(self): + return [ + Button("OK", variant="primary"), + ] + + def return_value(self, variant): + return None + + +class StackTraceDialog(BaseDialog): + def __init__( + self, + interface, + title, + message, + on_result=None, + retry=False, + content="", + ): + super().__init__( + interface=interface, + title=title, + message=message, + on_result=on_result, + ) + self.retry = retry + self.content = content + + def compose_content(self, dialog): + dialog.label = Label(self.message, id="message") + dialog.scroll = VerticalScroll( + Static(self.content, id="content"), + ) + dialog.content = Vertical( + dialog.label, + dialog.scroll, + ) + + def create_buttons(self): + if self.retry: + return [ + Button("Cancel", variant="error"), + Button("Retry", variant="primary"), + ] + else: + return [ + Button("OK", variant="primary"), + ] + + def style_content(self, dialog): + dialog.content.styles.margin = 1 + dialog.content.styles.height = self.interface.window.size[1] - 18 + + dialog.container.styles.width = "80%" + dialog.container.styles.height = self.interface.window.size[1] - 10 + + dialog.label.styles.margin = (0, 0, 1, 0) + + def return_value(self, variant): + if self.retry: + return variant == "primary" + else: + return None + + +class ParentFolderButton(Button): + DEFAULT_CSS = """ + ParentFolderButton { + border: none; + min-width: 4; + height: 1; + background: white 10%; + } + ParentFolderButton:hover { + background: white 10%; + } + ParentFolderButton:focus { + text-style: bold; + color: white; + } + ParentFolderButton.-active { + border: none; + } + """ + + def __init__(self, dialog): + super().__init__("..") + self.dialog = dialog + + def on_button_pressed(self, event): + self.dialog.native.directory_tree.path = ( + self.dialog.native.directory_tree.path.parent + ) + event.stop() + + +class FilteredDirectoryTree(DirectoryTree): + def __init__(self, dialog): + super().__init__(dialog.initial_directory) + self.dialog = dialog + + def filter_paths(self, paths): + if self.dialog.filter_func: + return [ + path + for path in paths + if (path.is_dir() or self.dialog.filter_func(path)) + ] + else: + return paths + + def on_tree_node_selected(self, event): + self.dialog.on_select_file(event.node.data.path) + + +class SaveFileDialog(BaseDialog): + def __init__( + self, + interface, + title, + filename, + initial_directory, + file_types=None, + on_result=None, + ): + super().__init__( + interface=interface, + title=title, + message=None, + on_result=on_result, + ) + self.initial_filename = filename + self.initial_directory = initial_directory if initial_directory else Path.cwd() + self.file_types = file_types + if self.file_types: + self.filter_func = lambda p: p.suffix[1:] in self.file_types + else: + self.filter_func = None + + def compose_content(self, dialog): + dialog.directory_tree = FilteredDirectoryTree(self) + dialog.parent_button = ParentFolderButton(self) + dialog.scroll = VerticalScroll(dialog.directory_tree) + dialog.filename_label = Label("Filename:") + dialog.filename = Input(self.initial_filename) + dialog.file_specifier = Horizontal( + dialog.filename_label, + dialog.filename, + ) + dialog.content = Vertical( + dialog.parent_button, + dialog.scroll, + dialog.file_specifier, + ) + + def create_buttons(self): + return [ + Button("Cancel", variant="error"), + Button("OK", variant="primary"), + ] + + def style_content(self, dialog): + dialog.content.styles.margin = 1 + dialog.content.styles.height = self.interface.window.size[1] - 18 + + dialog.filename_label.styles.margin = (1, 0) + dialog.scroll.styles.height = self.interface.window.size[1] - 22 + + dialog.container.styles.width = "80%" + dialog.container.styles.height = self.interface.window.size[1] - 10 + + def on_select_file(self, path): + if path.is_file(): + self.native.filename.value = path.name + + def return_value(self, variant): + if variant == "primary": + return ( + self.native.directory_tree.cursor_node.data.path.parent + / self.native.filename.value + ) + else: + return None + + +class OpenFileDialog(BaseDialog): + def __init__( + self, + interface, + title, + initial_directory, + file_types, + multiselect, + on_result=None, + ): + super().__init__( + interface=interface, + title=title, + message=None, + on_result=on_result, + ) + + self.initial_directory = initial_directory if initial_directory else Path.cwd() + self.file_types = file_types + if self.file_types: + self.filter_func = lambda p: p.is_dir() or p.suffix[1:] in self.file_types + else: + self.filter_func = None + + self.multiselect = multiselect + + def compose_content(self, dialog): + dialog.directory_tree = FilteredDirectoryTree(self) + dialog.parent_button = ParentFolderButton(self) + dialog.scroll = VerticalScroll(dialog.directory_tree) + dialog.content = Vertical( + dialog.parent_button, + dialog.scroll, + ) + + def create_buttons(self): + return [ + Button("Cancel", variant="error"), + Button("OK", variant="primary", disabled=True), + ] + + def style_content(self, dialog): + dialog.content.styles.margin = 1 + dialog.content.styles.height = self.interface.window.size[1] - 18 + + dialog.container.styles.width = "80%" + dialog.container.styles.height = self.interface.window.size[1] - 10 + + def on_select_file(self, path): + ok_button = self.native.buttons[-1] + if self.filter_func: + ok_button.disabled = not self.filter_func(path) + else: + ok_button.disabled = not path.is_file() + + def return_value(self, variant): + if variant == "primary": + return self.native.directory_tree.cursor_node.data.path + else: + return None + + +class SelectFolderDialog(BaseDialog): + def __init__( + self, + interface, + title, + initial_directory, + multiselect, + on_result=None, + ): + super().__init__( + interface=interface, + title=title, + message=None, + on_result=on_result, + ) + self.initial_directory = initial_directory if initial_directory else Path.cwd() + self.filter_func = lambda path: path.is_dir() + self.multiselect = multiselect + + def compose_content(self, dialog): + dialog.directory_tree = FilteredDirectoryTree(self) + dialog.parent_button = ParentFolderButton(self) + dialog.scroll = VerticalScroll(dialog.directory_tree) + dialog.content = Vertical( + dialog.parent_button, + dialog.scroll, + ) + + def create_buttons(self): + return [ + Button("Cancel", variant="error"), + Button("OK", variant="primary"), + ] + + def style_content(self, dialog): + dialog.content.styles.margin = 1 + dialog.content.styles.height = self.interface.window.size[1] - 19 + + dialog.container.styles.width = "80%" + dialog.container.styles.height = self.interface.window.size[1] - 10 + + def on_select_file(self, path): + ok_button = self.native.buttons[-1] + if self.filter_func: + ok_button.disabled = not self.filter_func(path) + else: + ok_button.disabled = not path.is_file() + + def return_value(self, variant): + if variant == "primary": + return self.native.directory_tree.cursor_node.data.path + else: + return None diff --git a/textual/src/toga_textual/factory.py b/textual/src/toga_textual/factory.py index 5964350f06..74f345b3dd 100644 --- a/textual/src/toga_textual/factory.py +++ b/textual/src/toga_textual/factory.py @@ -1,4 +1,4 @@ -# from . import dialogs +from . import dialogs from .app import App, DocumentApp, MainWindow # from .command import Command @@ -55,7 +55,7 @@ def not_implemented(feature): "Icon", # "Image", "Paths", - # "dialogs", + "dialogs", # # Widgets # "ActivityIndicator", "Box", diff --git a/textual/src/toga_textual/widgets/base.py b/textual/src/toga_textual/widgets/base.py index da21ee780f..e3026153f9 100644 --- a/textual/src/toga_textual/widgets/base.py +++ b/textual/src/toga_textual/widgets/base.py @@ -3,12 +3,11 @@ from toga.style.pack import ROW -# We assume a terminal is 800x600 pixels, mapping to 80x25 characters; -# then deduct 1 row for the titlebar of the window. +# We assume a terminal is 800x600 pixels, mapping to 80x25 characters. # This results in an uneven scale in the horizontal and vertical directions. class Scalable: HORIZONTAL_SCALE = 800 // 80 - VERTICAL_SCALE = 600 // 24 + VERTICAL_SCALE = 600 // 25 def scale_in_horizontal(self, value): return value // self.HORIZONTAL_SCALE diff --git a/textual/src/toga_textual/widgets/label.py b/textual/src/toga_textual/widgets/label.py index 13759ff5e4..424d12e071 100644 --- a/textual/src/toga_textual/widgets/label.py +++ b/textual/src/toga_textual/widgets/label.py @@ -13,7 +13,7 @@ def get_text(self): return str(self.native.renderable) def set_text(self, value): - self.native.renderable = value + self.native.update(value) def rehint(self): self.interface.intrinsic.width = at_least(len(self.native.renderable)) diff --git a/textual/src/toga_textual/window.py b/textual/src/toga_textual/window.py index 65ddcc38ae..2ce56e954f 100644 --- a/textual/src/toga_textual/window.py +++ b/textual/src/toga_textual/window.py @@ -1,17 +1,113 @@ +from rich.text import Text + +from textual.app import RenderResult +from textual.reactive import Reactive from textual.screen import Screen as TextualScreen -from textual.widgets import Header as TextualHeader +from textual.widget import Widget as TextualWidget +from textual.widgets import Button as TextualButton from .container import Container +class WindowCloseButton(TextualButton): + DEFAULT_CSS = """ + WindowCloseButton { + dock: left; + border: none; + min-width: 3; + height: 1; + background: white 10%; + color: white; + } + WindowCloseButton:hover { + background: black; + border: none; + } + WindowCloseButton:focus { + text-style: bold; + } + WindowCloseButton.-active { + border: none; + } + """ + + def __init__(self): + super().__init__("✕") + + def on_button_pressed(self, event): + self.screen.impl.textual_close() + event.stop() + + +class TitleSpacer(TextualWidget): + DEFAULT_CSS = """ + TitleSpacer { + dock: right; + padding: 0 1; + width: 3; + content-align: right middle; + } + """ + + def render(self) -> RenderResult: + return "" + + +class TitleText(TextualWidget): + DEFAULT_CSS = """ + TitleText { + content-align: center middle; + width: 100%; + } + """ + text: Reactive[str] = Reactive("") + + def __init__(self, text): + super().__init__() + self.text = text + + def render(self) -> RenderResult: + return Text(self.text, no_wrap=True, overflow="ellipsis") + + +class TitleBar(TextualWidget): + DEFAULT_CSS = """ + TitleBar { + dock: top; + width: 100%; + background: $foreground 5%; + color: $text; + height: 1; + } + """ + + def __init__(self, title): + super().__init__() + self.title = TitleText(title) + + @property + def text(self): + return self.title.text + + @text.setter + def text(self, value): + self.title.text = value + + def compose(self): + yield WindowCloseButton() + yield self.title + yield TitleSpacer() + + class TogaWindow(TextualScreen): - def __init__(self, impl): + def __init__(self, impl, title): super().__init__() self.interface = impl.interface self.impl = impl + self.titlebar = TitleBar(title) def on_mount(self) -> None: - self.mount(TextualHeader()) + self.mount(self.titlebar) def on_resize(self, event) -> None: self.interface.content.refresh() @@ -20,9 +116,8 @@ def on_resize(self, event) -> None: class Window: def __init__(self, interface, title, position, size): self.interface = interface - self.native = TogaWindow(self) + self.native = TogaWindow(self, title) self.container = Container(self.native) - self.set_title(title) def create_toolbar(self): pass @@ -36,10 +131,10 @@ def set_content(self, widget): self.native.mount(widget.native) def get_title(self): - return self._title + return self.native.titlebar.text def set_title(self, title): - self._title = title + self.native.titlebar.text = title def get_position(self): return (0, 0) @@ -65,8 +160,11 @@ def hide(self): def get_visible(self): return True + def textual_close(self): + self.interface.on_close(self) + def close(self): - pass + self.native.dismiss(None) def set_full_screen(self, is_full_screen): pass