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

Finalize API for Document-based apps #2666

Merged
merged 61 commits into from
Aug 26, 2024
Merged

Conversation

freakboy3742
Copy link
Member

@freakboy3742 freakboy3742 commented Jun 18, 2024

Derived from #2244.

Finalizes the Document-based app interface.

This unfortunately involves some backwards incompatible changes; I can't see any obvious way to retain compatibility with the old API and also allow for the flexibility needed to support GTK, Windows and "new/save" functionality. The changes aren't that invasive... I hope?

Summary of changes:

  • The DocumentApp class is no longer required; all the functionality of DocumentApp is now contained in App, controlled by being a session-based app that defines a document_type dictionary.
  • The Document base class no longer requires a path at time of construction. The path is now set as a side effect of opening or saving a document. This allows for creating empty "untitled" documents. Document subclasses will generally not need to override the constructor.
  • The document type of a Document must now be declared as a class-level property, rather than being handled as an instance property
  • The close handling of a document now uses a standard on_close handler, rather than a specialised interface. The default on_close handler tracks modification status of the document; a document can be marked as modified using the touch() API.
  • On macOS, if there's no open documents, giving the app focus opens the File Open dialog.
  • On GTK and Windows, starting the app with no documents creates an empty "Untitled" document.
  • Opening an document that is already open gives the existing representation focus.

There's no longer a need for an implementation-level document. The only backend that was using it was macOS; and it turns out we're not actually using any of the functionality that might make that interesting (like CoreData support). AFAICT, we can meet all the same app requirements using Python's file handling, and the standard cocoa command line handling API.

The testbed has been updated to use an automated window cleanup mechanism, rather than requiring each test to explicitly close windows that have been opened. This also ensures that garbage collection is enforced in the GUI thread (this was being done in the Window tests, but not in other examples). This is an extended version of what was included in #2682; this PR also removes the try/finally blocks that were manually implementing window cleanup.

Fixes #2209.

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@freakboy3742 freakboy3742 marked this pull request as ready for review June 29, 2024 07:25
@freakboy3742 freakboy3742 removed the request for review from mhsmith June 29, 2024 07:48
@mhsmith
Copy link
Member

mhsmith commented Jul 12, 2024

This unfortunately involves some backwards incompatible changes; I can't see any obvious way to retain compatibility with the old API and also allow for the flexibility needed to support GTK, Windows and "new/save" functionality.

I agree backwards compatibility isn't a concern – as far I can tell from GitHub and Discord, the old API has never been used outside of Podium.

However, I think using the old API as a starting point has resulted in a design that's more complicated than necessary. In particular, I don't see why Document and DocumentMainWindow are separate classes, when the bulk of the implementation of a Document subclass would be about manipulating the window anyway.

I'd also like to revisit the idea discussed in #2244 of moving the documents API into a DocumentApp subclass which can be tested entirely in the core, with the help of an on_open (or maybe on_open_file) event. As I said before, the main motivation for this is to keep "the document-related code and documentation all in one place, making it easier to get an overview if you want it, and keeping it out of the way if you don't want it."


First let's look at the command-related methods in the App class, which are the main reason for that class's expansion in this PR and #2688. We started off with just one such method (preferences), but there are now six scattered all over the App class documentation, often with names whose meanings can't be guessed by looking at the index (e.g. does App.new create a new app?). And there could be more in the future, e.g. imagine if an image editor wanted to add Cut, Copy and Paste.

So I suggest we abandon this pattern of adding commands by overriding App methods. Instead, we can make it possible to create a Command by passing nothing but one of the standard IDs, e.g.Command(id=Command.NEW), which will return a command configured as appropriate for the platform, but with no action assigned. This would allow standard commands to be added at any time, which complements our intention of allowing them to be removed at any time as well (#2701).

The rest of the API would then look roughly like this:

class App:
    def on_open_file(self, path):
        """Event handler, which only fires on macOS. Overridable in the same way as
        on_exit and on_running.
        """


# The remaining 3 classes can all be documented on the same page:

class DocumentApp(App):

    @property
    def document_types(self) -> dict[DocumentType, type[DocumentWindow]]:
        """The first item in the dictionary is the default type, and the docs should
        explain what the effects of that are.
        """

    def startup(self):
        """
        Add the file management commands. There's no need for them to be connected
        to public methods; they can just be described on the documentation page.

            New: Create and show a DocumentWindow of the correct type, passing no `path`
                argument.

                The command for the default type would be created using Command.NEW as
                discussed above, and the other ones would be copied from it with different
                text and id, and no shortcut. If there's only one document type, its
                command text should be left at the original value of "New".

            Open: Display a dialog, and based on the filename extension that comes back,
                create and show a DocumentWindow of the correct type, passing a `path`
                argument.

                Additionally, if there's an existing DocumentWindow that contains an
                unmodified untitled document, which is the initial condition on
                Windows and GTK, then close that window.

            Save, Save As, Save All: Call the corresponding DocumentWindow methods,
                as in the current PR.

        The code currently in _create_initial_windows would also be moved here. And
        we can set main_window = None, so the subclass doesn't have to.

        Docstring should say that if you override this method in a subclass, you must
        call the base class implementation.
        """

    def on_open_file(self, path):
        """Event handler implementation; does the same thing as the Open command,
        except with no dialog."""


class DocumentType:
    name: str
    extensions: list[str]


class DocumentWindow(MainWindow, ABC):
    """Including 'Main' in the name seems unnecessary."""

    def __init__(..., document_type, path, ...):
        """If a path was provided, call `read`.

        Subclasses should override this method, passing through all arguments
        and adding a `content` argument."""

    @property
    def document_type(self) -> DocumentType:
        """This property is read-only and required in the constructor."""

    @property
    def path(self) -> Path | None:
        """Setting this property sets the window title to `path.stem` or 'Untitled'."""

    def save(self):
        """If `write` is not overridden, do nothing. Later, when we have per-window
        commands, the constructor would omit the save commands in this case.

        Else if `self.path` is None, call `save_as`.
        Otherwise, call `write`."""

    def save_as(self):
        """If `write` is not overridden, do nothing.
        Otherwise, set self.path from a SaveFileDialog, then call `write`.

        The behavior of replacement_filename should be moved into SaveFileDialog,
        if it isn't already implemented by the platform (I think it is on Windows).
        """

    @abstractmethod
    def read(self):
        """Read self.path into the window."""

    @overridable
    def write(self):
        """Write the window content to self.path."""
        pass

    @property
    def modified(self) -> bool:
        """Whether the document has been modified since the last open or save."""
        return False

On macOS:

  • application_openFiles would invoke on_open_file.
  • applicationOpenUntitledFile would invoke Command.OPEN, if it exists.

If we go this way, I think it makes sense to close #2688 and do all the commands in this PR.

@freakboy3742
Copy link
Member Author

However, I think using the old API as a starting point has resulted in a design that's more complicated than necessary. In particular, I don't see why Document and DocumentMainWindow are separate classes, when the bulk of the implementation of a Document subclass would be about manipulating the window anyway.

Primarily because there can be a many-to-one or one-to-many relationship between the windows for a document, and the document as a concept. The simple case is definitely 1-1 (e.g., a text editor has one window per text document); but that's not the only way a document-based app can be organised.

Podium is an example of the many-to-one case - a slide deck is a single document, but there can be multiple views of that slide deck (the slide view, the presenter view, an editing panel for slide content ...) that might be visible at the same time. One of those is nominated as the "main" document window - in the Podium case, this is then set up so that closing the "main" window closes all the other windows related to that document.

There could also be a one-to-many relationship. A VSCode-style IDE could be modelled as one window managing multiple documents. There might be better ways to represent this specific case (e.g., treating an IDE window as single "project" document), but a one-to-many relationship isn't out of the question.

Aside from data modelling, there's also separation of concerns. How a document is displayed doesn't need to be coupled to how it is stored. This sort of conceptual separation is at the core of MVC or MVVC style data modelling. Consider the case where the app wants a new document to be opened into an existing document window - you really want to preserve a single window instance, but make the window display a different document. It's almost certainly possible to manufacture this in a single DocumentWindow setup, but having the objects separate makes the separation of concern a lot clearer.

I'd also like to revisit the idea discussed in #2244 of moving the documents API into a DocumentApp subclass which can be tested entirely in the core, with the help of an on_open (or maybe on_open_file) event.

That doesn't match my understanding of where we ended up with #2244 - in that discussion, the general drive was away from specialist app classes, and towards incorporating the behaviours of the app classes as capabilities of a single App class.

Aside from making testing easier (as we don't need to dance around creating a second App instance for testing purposes), the biggest argument for this was that having a "type" of application was unnecessarily limiting. What if I want a status icon app that is able to manage documents? What if I need an app that does have a true "main" window, but also needs to manage documents. Keeping "document management" as a feature of a single app class rather than a specialised type of app makes it a lot easier to accomodate any type of app behavior the end user may want.

As I said before, the main motivation for this is to keep "the document-related code and documentation all in one place, making it easier to get an overview if you want it, and keeping it out of the way if you don't want it."

First let's look at the command-related methods in the App class, which are the main reason for that class's expansion in this PR and #2688. We started off with just one such method (preferences), but there are now six scattered all over the App class documentation, often with names whose meanings can't be guessed by looking at the index (e.g. does App.new create a new app?). And there could be more in the future, e.g. imagine if an image editor wanted to add Cut, Copy and Paste.

I'm sure the documentation could be improved, and I'm definitely open to suggestions on reorganization. I've tried to keep most of the discussion of document-based behavior in the documentation for Document, with references in DocumentMainWindow and App linking to the Document docs as soon as the topic goes beyond "command line handling" and into document-based specifics.

I'm also open to alternate naming (or a naming conventions) for the "capabilities" that can be user-defined (like preferences, new, open etc). I definitely acknowledge that App.new() isn't inherently intuitively a "new document" call; and the alphabetical ordering of the API docs definitely doesn't help a reader identify that App.new(), App.open() and App.save() are available or related in any way.

Using on_new(), on_open() ... naming would be one option for this; but I'm not sure it follows the existing conventions. The comparison here would be with on_exit() vs exit() - the former is "logic evaluated when the command is issued", the latter is "do the actual work for the command".

Following this pattern, on_new() would be the menu item; new() would be the API you could call to programmatically create a document ... but there isn't really anything to be done in the menu item other than "create a document" - which is what new() does; if a user was to programmatically create a new document, new() would be what they invoke. It makes a little more conceptual sense for on_open() and open() - but again, the heavy lifting is really in the open() side of that equation, because the menu item needs to handle the file dialog, and I'm having a lot of difficulty imagining a "file open" command that isn't functionally "show a file dialog, and if a file is selected, open that file" - which is what the current _open() method (which would be the analog of on_open) implements.

In both these cases, the on_X() side of the functionality is almost always the same; the thing that needs to change is the X() side.

However, as I said, I acknowledge there's some naming overload, so I wouldn't object to renaming these methods - new_file(), open_file(), save_file(), save_as_file(), and save_all_files() would probably make more sense as names (making it clear they're related, and file-specific). document_new(), document_open()... et al would mean they're all lexically sorted in documentation, and would perhaps indicate that they're document-related commands. I'm open to other suggestions.

As for cut/copy/paste - those specific commands aren't going to be simple, because they're going to require interaction with the operating system to ensure that they continue to work for text fields etc. My guess is that copy/paste is more likely to be a widget-level feature that is related to focus handling - so a user-space handlers replaced at the Command level is likely to be implausible.

So I suggest we abandon this pattern of adding commands by overriding App methods. Instead, we can make it possible to create a Command by passing nothing but one of the standard IDs, e.g.Command(id=Command.NEW), which will return a command configured as appropriate for the platform, but with no action assigned. This would allow standard commands to be added at any time, which complements our intention of allowing them to be removed at any time as well (#2701).

If I'm understanding this correctly, you're suggesting that it would be possible to construct a Command without an action; but if the ID was a "known" ID, then you'd get a the default behavior for that ID - and this becomes the preferred way to register new commands, rather than overriding.

One issue I see with this approach is all the other arguments to Command (shortcut, group, section etc). These are subtly different on each platform; so a "Preferences" or "New" command defined in user space would need a way to inherit/obtain these extra arguments. I guess it might be possible to have a similar "default" approach like the action; but if we do this, we're essentially making ID a "magic" argument to the constructor that implies other values.

However, my real problem here is that I don't see what we gain by taking this approach to command management. Specific method naming notwithstanding, I don't see that overriding a method is an especially unintuitive mechanism for accessing (and enabling) customised file handling, especially when on_exit() has just been modified to allow for this sort of override-based implementation. Many of these commands do have meaningful "default" behavior that any user-space implementation would need to override (e.g., "open a file dialog, then open the file that is returned"), so the natural point of customisation isn't the menu command - it's what the menu command is executing.

And, in the "default" case of document handling, there's no need to override anything - declaring a document type enables the default menus for open, save etc; and the default behaviors are all tied to the document, and how the document is read/written.

So - adopting a Command(id=...) registration based approach means a weird process of construction that we need to define, all in aid of allowing an app that hasn't registered document types to enable an open menu with platform-specific shortcuts and placement. And the user will still need to write the actual open handler, so this approach takes more code than a method override, with the only tangible benefit being that installing the command is slightly more explicit (although even then, it involves a bunch of implicit definitions based on command IDs).

The rest of the API would then look roughly like this:

If I'm reading this right, the key points of difference are:

  1. Convolving Document and DocumentWindow
  2. Using the DocumentApp subclass to trigger the installation of default command handlers, with the expectation that users who don't subclass DocumentApp will register their own New/Open/Save commands, in addition to providing the implementation for those commands.
  3. Passing path as part of the constructor to Document/DocumentWindow.

I've addressed (1) and (2) above; (1) doesn't address separation of concerns or N-N entity relationships; (2) requires more code to achieve the same result, and requires an (arguably) artificial distinction between a "document" app and all other apps.

(3) is a fairly minor API difference; it wouldn't be too hard to incorporate into this PR by making path an optional argument to the Document constructor.

class DocumentApp(App):
    def startup(self):

This would also require that users explicitly invoke the super() implementation of startup. That's not something that has been historically required; startup() hasn't had any "default" behaviour - it's purely user-space code.

class DocumentWindow(MainWindow, ABC):
"""Including 'Main' in the name seems unnecessary."""

My thinking here was naming consistency - DocumentMainWindow is a MainWindow because it can have a toolbar and menu. At least in principle, you could have a DocumentWindow that has the document management capabilities, but doesn't allow for a menu or toolbar... but usability would be a bit of mess of GTK and Windows.

@freakboy3742
Copy link
Member Author

Summarising in person discussion between myself and @mhsmith:

The problems:

  • The size of the toga.App surface is a concern, and will only get worse as we add more standard commands
  • It's not necessarily obvious that toga.App is the place where standard commands would be defined.
  • The App class backends include a lot of duplicated logic around which standard commands will be installed; the only differences are (a) commands that are omitted on some platforms (e.g, document commands on Android/iOS), and (b) the title/shortcut/grouping of standard commands.

The solution we came to:

  • Add a new Command.standard() class method that takes one of the known command constants, and constructs an instance of that standard command. This public API also takes the same arguments as the Command constructor, but doesn't require values for those arguments.
  • The implementation of Command.standard() defers to the implementation layer for the specific title/group/shortcut etc for each of the standard commands. This allows a public API that creates platform-specific Command implementations.
  • Command.standard() returns None if the platform doesn't support the command (e.g., document management on Android/iOS), and raises ValueError if the command ID is completely unknown.
  • The startup process of toga.App will include an interface layer _create_standard_commands that uses the Command.standard() API to create the standard command instances. This will allow us to factor out the common logic around the set of document commands should be created. The implementation-level create_standard_commands will be retained (for now) so that commands like the macOS-specific cut/copy/paste and window management commands can be installed. Ultimately, these will be factored out into standard commands.
  • The recently added preferences() API will be removed, in favor of allowing users to install a Preferences standard command (and, eventually, us installing a default preferences handler when we have standardised preferences handling)
  • The user will be able to register toga.Command.NEW to get a default bare "new" command. If an app defines multiple document types, Toga will create multiple instances of the New command, explicitly modifying the ID and label as required to ensure uniqueness.
  • Add support for user-defined New, Save, Save As and Save All commands #2688 will be merged into this PR
  • We will modify the implementation of Open to allow for opening a document into an untitled document window, and opening a document that is already open. Getting these behaviours right may require auditing platform-native behavior.
  • We will confirm that it would be possible to implement a window that contains multiple documents (e.g., an IDE), although we likely won't implement that API for this PR.

As an end-user, there will be no change to the API if the user chooses to use default document types handling, or the user doesn't want any of the default document management commands.

However, an end-user could also add:

class MyApp(toga.App):
    def startup(self):
        ...
        self.commands.add(
            Command.standard(Command.NEW, action=my_new_file_handler),
            Command.standard(Command.SAVE, action=my_new_save_handler),
        )
        self.commands[Command.ABOUT].action = my_about_handler
        del self.commands[Command.VISIT_WEBPAGE]

to add a New and Save handler to their app with default registration, modify the action for the About command, and remove the visit webpage menu item. There may be modifications to error handling required to ensure that missing commands don't cause errors.

@mhsmith
Copy link
Member

mhsmith commented Jul 16, 2024

The implementation-level create_standard_commands will be retained (for now)

It's currently called _create_app_commands, though "standard" would be a clearer name.

We will confirm that it would be possible to implement a window that contains multiple documents (e.g., an IDE), although we likely won't implement that API for this PR.

To support this, it should be possible to assign the doc property (which is currently read-only). For example, a tabbed window would want to change the doc property whenever the selected tab changes, so that its Save command operates on the correct document.

We will modify the implementation of Open to allow for opening a document into an untitled document window

It might be cleaner to close the untitled window and open a new one, to avoid requiring all document windows to accept changes to the doc property.

Either way, to determine whether it's safe to discard an untitled document, we would require a modified API as I mentioned above.

@freakboy3742
Copy link
Member Author

The implementation-level create_standard_commands will be retained (for now)

It's currently called _create_app_commands, though "standard" would be a clearer name.

(It would help if I didn't forget the name of the APIs I've added and confuse the issue with new names....)

I think my intention with create_app_commands was to lean into the fact that there might be "window" commands later; but unless it's a public API, it's a bit of a bike shed.

We will confirm that it would be possible to implement a window that contains multiple documents (e.g., an IDE), although we likely won't implement that API for this PR.

To support this, it should be possible to assign the doc property (which is currently read-only). For example, a tabbed window would want to change the doc property whenever the selected tab changes, so that its Save command operates on the correct document.

I'm not sure if you'd be changing a single doc property for a multiple-doc window. My inclination is that you'd have a window manage a list/set of documents, with a current_document property reflecting the one that is currently selected (which the UI can then tie to however the user switches between documents)

However, I don't think we need to resolve that design decision now; we can defer until we want to add a tabbed document window.

We will modify the implementation of Open to allow for opening a document into an untitled document window

It might be cleaner to close the untitled window and open a new one, to avoid requiring all document windows to accept changes to the doc property.

Possibly; although it might be sufficient to document that create() must create the GUI structure for a window, and open() must populate the window - and be callable multiple times, so that a second call with a different path will re-populate the window.

Either way, to determine whether it's safe to discard an untitled document, we would require a modified API as I mentioned above.

I'm not sure this is necessarily true. My thinking was that this could be tied into on_close handling; the only difference between an modified API and an on_close API would be a dialog that confirms whether the document has modified. I guess the question is whether the convenience of wrapping the common "do you want to save" workflow is worth the cost of an introducing a "modified" API that is specific to document handling.

That said, there's also some differences in behavior between platforms around what will cause a new window to be created (i.e., when does "Open" create a new window, and when does it change the content of the existing window).

On macOS, every "New" and "Open" creates a new window (AFAICT - it definitely does for TextEdit).

GTK's text editor has an Open that always opens in the existing window; but "New" creates a new window (technically, there's "New Window" and "New Document"; new Document creates a second tab in the same window).

Windows Notepad is mostly the same as GTK, but it differentiates between "New" and "New Window". "New" creates a new document in the current window; "New Window" creates a new document in a new window.

On GTK and Windows, "Open" cares if the existing content has been modified; macOS doesn't. This seems consistent with the "you can have no windows open" approach taken by macOS. At the very least, there's some variation in logic required for the "new" and "open" menu items.

core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
core/src/toga/app.py Outdated Show resolved Hide resolved
core/src/toga/app.py Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
docs/reference/api/resources/document.rst Outdated Show resolved Hide resolved
core/src/toga/documents.py Outdated Show resolved Hide resolved
changes/2209.removal.rst Outdated Show resolved Hide resolved
Copy link
Member

@mhsmith mhsmith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, this is the last set of significant design comments, I promise. 🙂

Comment on lines +274 to +276
# CLOSE_ON_LAST_WINDOW is a proxy for the GTK/Windows behavior of loading content
# into the existing window. This is actually implemented by creating a new window
# and disposing of the old one; mark the current window for cleanup
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this means apps will be much less capable on GTK and Windows than on macOS, because they'll only support one document at a time. So I don't think we should hard-code this single-window behavior in the general case – we should leave that up to the Document subclass.

I feel like most cross-platform developers would prefer to stick with the same document-window relationship behavior across all platforms. For example, on both Mac and Windows, LibreOffice always opens one document per window but allows multiple windows, while VS Code always uses a single window per workspace with multiple documents in tabs. And an app that really does intend to support only one document at a time, probably wants that on all platforms as well.

The "replace" behavior should be retained for the special case of replacing an unmodified untitled document, but moved to open so it can also be triggered by other ways of opening a document, e.g. an "Open Recent" menu.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a document app should definitely use (or, at least, be able to use) multiple windows, regardless of platform.

There's an edge case you're missing in your analysis, though - New. On GTK/Windows, File > New always creates a new window; File > Open replaces the existing window.

From my survey of platform behavior when I set this up, on Windows, most apps that I saw (e.g., notepad) have distinct "New" and "New Window" commands, with "New" being a replacing operation by default. That's a little difficult to accomodate cleanly when there are potentially multiple document types, because you end up with 2 New commands for every document type, so your File menu gets busy really quickly. Open is always a replacing operation.

The behaviour is less consistent on GTK (shocking nobody), but most apps (even Terminal) have an app-level "New window" command in addition to an "open" that has replacing behavior; if there's a "new document" option, it often creates a second tab in the same window (which isn't something we're in a place to support anyway).

So - I opted for a single "New" that is effectively "new window" on all platforms. For the "single document window" case, this seems the most consistent without adding an explicit second new command; and in a multiple-document window (such as an IDE), it should be possible to incorporate "open tab rather than replace" behavior with some relatively minor affordances in the Document class for the multi-document app, plus a user-added "New window" command.

It's also possible for a "single document window" app on GTK/Windows to circumvent the replacement behavior by deleting the _replace attribute of the current window before creating the new window in the Document's create() method. This isn't an official API - we'd possibly want to find a cleaner way to formalise this if we want to make it official - but it's at least possible.

The other possible missing piece that GTK and Windows don't currently have is the analog of "drag document onto app" or "double click on icon". On macOS, this opens a new document window in the same app instance; I'm not sure what (if any) mechanisms exist to make this sort of behavior work on GTK and Windows, but in the meantime, you'll get 2 app instances with distinct document windows.

Regarding the larger "meta" point - I'm not sure I agree that cross-platform consistency is as important as platform-native consistency, provided the consequences for the developer aren't invasive. Replace behavior feels alien on macOS; non-replace behavior feels alien on macOS and GTK. Given that platform consistency is one of the key design principles of Toga, and we've got a reasonably clean way of accomodating both in a way that only affects how the user uses the app, not how it is built, it seems reasonable to me to retain the platform consistency.

And - as a note related to your other review comment about merging create and open - it occurs to me that there's possibly an option here for formalising how replace works by making replace an optional method on the Document class that is only invoked if it is defined and the platform supports replacing behaviour, and then pass the replacement request to the creation of the document. I don't think we need to resolve that design decision right now - I think it fits better in a discussion about formally adding multi-document apps - but it's worth noting that this approach wouldn't be possible if create and open were merged.


if TYPE_CHECKING:
from toga.app import App
from toga.window import Window


class Document(ABC):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible simplifications of the Document class:

  • create, open, and read will only be called once, in that order. So what if we merged create and read into a single overridable method that's responsible for both creating the UI and loading a file into it? For consistency with the other classes, the best name for this method would be open. The code currently in open can then move to the constructor, which would once again take a path argument which can be None for a new document.

  • Similarly, the overridable write method would be renamed to save, and the code currently in save can move to DocumentWindow.save.

  • Separately, Document.show seems unnecessary, and also incomplete since there's no Document.hide. Suggest removing this method and saying that whoever creates the window is also responsible for showing it, just like everywhere else in Toga.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible simplifications of the Document class:

I'm not sure these simplifications are actually possible (or, to the extent they're possible, they won't represent an improvement).

open() does more than just read the file; it also does path normalisation, error handling, window title handling, and the initial dirty state management. read() is then the extension point that the user can implement so that they only need to deal with the "get the data from the file and populate the window" portion, and can implement that with known the pre-condition that the file must exist. The only restructure of this that I can see would require either:

  • every user implementation of a unified open() to call super().open() and potentially handle the fact that open might raise an exception or not require opening a file at all; or
  • pulling the current logic of open() into the place where open() is being invoked... which means we've got the same code, just wrapped up into a more complex DocumentWindow.open() implementation.

The same is true of save() and write().

Separating create() from open() is necessary to support untitled documents. Otherwise, we'd need to modify open() to handle path=None (or similar) as a "don't open a file" case. Sure, this is possible, but it's a bit messy IMHO. It also requires the user to convolve "creating the window" logic with "reading the file" logic (which makes testing a little harder), and makes operations like "refresh the content of the current window in response to changes on disk" harder (an edge case, to be sure, but not a completely unreasonable one).

A standalone show() is needed to support deferring making the window visible until after it's fully loaded. Without it, when opening a new document, you'd see an untitled window that is then populated with content (which might take time and result in visible redraws); or, in case of a failed open, a temporary window that then disappears.

I can't think of a good use case for a hide() method; but for the sake of parity, it's not hard to implement.

@mhsmith mhsmith merged commit 37525ec into beeware:main Aug 26, 2024
35 checks passed
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

Successfully merging this pull request may close these issues.

Complete implementation and testing of DocumentApp
2 participants