textual-paint/windows.py
Isaiah Odhner 8ec24a8fdc Prompt to save changes when unloading a document
- Confirm discarding changes for Open, New, or Exit, including for
  exit via Ctrl+C which was previously handled by a built-in binding.
- Await Save As dialog closing, including when Save triggers Save As.
  This is my first time using asynchronous features in Python,
  (as far as I remember,) so it's a bit messy.
- Make DialogWindow callback also for Cancel, which means all
  DialogWindow usage sites care what button is selected.
- Send RequestClose event for Esc key.
2023-04-17 23:09:13 -04:00

159 lines
6.1 KiB
Python

from textual import events
from textual.message import Message
from textual.app import ComposeResult
from textual.containers import Container
from textual.geometry import Offset, Region, Size
from textual.reactive import var, reactive
from textual.widgets import Button, Static
from textual.css.query import NoMatches
class WindowTitleBar(Container):
"""A title bar widget."""
title = var([])
def __init__(self, title: str = "", **kwargs) -> None:
"""Initialize a title bar."""
super().__init__(**kwargs)
self.add_class("window_title_bar")
self.title = title
def compose(self) -> ComposeResult:
"""Add our widgets."""
yield Static(self.title, classes="window_title")
yield Button("🗙", classes="window_close")
# yield Button("🗕", classes="window_minimize")
# yield Button("🗖", classes="window_maximize")
# 🗗 for restore
class Window(Container):
"""A draggable window widget."""
class CloseRequest(Message):
"""Message when the user clicks the close button. Can be prevented."""
def __init__(self, **kwargs) -> None:
"""Initialize a close request."""
super().__init__(**kwargs)
self.prevent_close = False
class Closed(Message):
"""Message when the window is really closed."""
title = var([])
def __init__(self, *children, title: str = "", **kwargs) -> None:
"""Initialize a window."""
super().__init__(*children, **kwargs)
self.add_class("window")
self.mouse_at_drag_start = None
self.offset_at_drag_start = None
self.title_bar = WindowTitleBar(title=title)
self.content = Container(classes="window_content")
# must be after title_bar is defined
self.title = title
def on_mount(self) -> None:
"""Called when the widget is mounted."""
self.mount(self.title_bar)
self.mount(self.content)
# Fix for incorrect layout that would only resolve on mouse over
# (I peaked into mouse over handling and it calls update_styles.)
# This can still briefly show the incorrect layout, since it relies on a timer.
self.set_timer(0.01, lambda: self.app.update_styles(self))
# def compose(self) -> ComposeResult:
# """Add our widgets."""
# self.title_bar = yield WindowTitleBar(title=self.title)
# self.content = yield Container(classes="window_content")
# def compose_add_child(self, widget):
# """When using the context manager compose syntax, we want to attach nodes to the content container."""
# self.content.mount(widget)
def watch_title(self, old_title, new_title: str) -> None:
"""Called when title is changed."""
self.title_bar.title = new_title
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked or activated with the keyboard."""
if event.button.has_class("window_close"):
self.request_close()
def on_mouse_down(self, event: events.MouseDown) -> None:
"""Called when the user presses the mouse button."""
# detect if the mouse is over the title bar,
# and not window content or title bar buttons
if not self.parent:
# I got NoScreen error accessing self.screen once while closing a window,
# so I added this check.
return
widget, _ = self.screen.get_widget_at(*event.screen_offset)
if widget not in [self.title_bar, self.title_bar.query_one(".window_title")]:
return
if event.button != 1:
return
self.mouse_at_drag_start = event.screen_offset
self.offset_at_drag_start = Offset(self.styles.offset.x.value, self.styles.offset.y.value)
self.capture_mouse()
def on_mouse_move(self, event: events.MouseMove) -> None:
"""Called when the user moves the mouse."""
if self.mouse_at_drag_start:
self.styles.offset = (
self.offset_at_drag_start.x + event.screen_x - self.mouse_at_drag_start.x,
self.offset_at_drag_start.y + event.screen_y - self.mouse_at_drag_start.y,
)
def on_mouse_up(self, event: events.MouseUp) -> None:
"""Called when the user releases the mouse button."""
self.mouse_at_drag_start = None
self.offset_at_drag_start = None
self.release_mouse()
def close(self) -> None:
"""Force close the window."""
self.remove()
self.post_message(self.Closed())
def request_close(self) -> None:
"""Request to close the window."""
close_request = self.CloseRequest()
self.post_message(close_request)
if not close_request.prevent_close:
self.close()
class DialogWindow(Window):
"""A window that can be submitted like a form."""
def __init__(self, *children, handle_button, **kwargs) -> None:
"""Initialize a dialog window."""
super().__init__(*children, **kwargs)
self.add_class("dialog_window")
self.handle_button = handle_button
def on_key(self, event: events.Key) -> None:
"""Called when a key is pressed."""
# submit with enter, but not if a button has focus
# (not even if it's a submit button, because that would double submit)
# TODO: Use on_input_submitted instead
if event.key == "enter" and self.app.focused not in self.query("Button").nodes:
try:
submit_button = self.query_one(".submit", Button)
except NoMatches:
return
self.handle_button(submit_button)
elif event.key == "escape":
# Like the title bar close button,
# this doesn't call handle_button...
self.request_close()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked or activated with the keyboard."""
# Make sure the button is in the window content
if event.button in self.content.query("Button").nodes:
self.handle_button(event.button)