Document
Usage
A common requirement for apps is to view or edit a particular type of file. In Toga, you define a toga.Document class to represent each type of content that your app is able to manipulate. This Document class is then registered with your app when the App instance is created.
The toga.Document class describes how your document can be read, displayed, and saved. It also tracks whether the document has been modified. In this example, the code declares an "Example Document" document type, which will create files with the extensions .mydoc and .mydocument; because it is listed first, the .mydoc extension will be the default for documents of this type. The main window for this document type contains a MultilineTextInput. Whenever the content of that widget changes, the document is marked as modified:
import toga
class ExampleDocument(toga.Document):
description = "Example Document"
extensions = [`"mydoc", "mydocument"]
def create(self):
# Create the main window for the document. The window has a single widget;
# when that widget changes, the document is modified.
self.main_window = toga.DocumentMainWindow(
doc=self,
content=toga.MultilineTextInput(on_change=self.touch),
)
def read(self):
# Read the content of the file represented by the document, and populate the
# widgets in the main window with that content.
with self.path.open() as f:
self.main_window.content.value = f.read()
def write(self):
# Save the content currently displayed by the main window.
with self.path.open("w") as f:
f.write(self.main_window.content.value)
The document window uses the modification status to determine whether the window is allowed to close. If a document is modified, the user will be asked if they want to save changes to the document.
Registering document types
A document type is used by registering it with an app instance. The constructor for toga.App allows you to declare the collection of document types that your app supports. The first declared document type is treated as the default document type for your app; this is the type that will be connected to the keyboard shortcut of the NEW command.
After startup() returns, any filenames which were passed to the app by the operating system will be opened using the registered document types. If after this the app still has no windows, then:
- On Windows and GTK, an untitled document of the default type will be opened.
- On macOS, an Open dialog will be shown.
In the following example, the app will be able to manage documents of type ExampleDocument or OtherDocument, with ExampleDocument being the default content type. The app is configured to not have a single "main" window, so the life cycle of the app is not tied to a specific window.
import toga
class ExampleApp(toga.App):
def startup(self):
# The app does not have a single main window
self.main_window = None
app = ExampleApp(
"Document App",
"com.example.documentapp",
document_types=[ExampleDocument, OtherDocument]
)
app.main_loop()
By declaring these document types, the app will automatically have file management commands (New, Open, Save, etc) added.
Reference
Bases: ABC
Source code in core/src/toga/documents.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175 | class Document(ABC):
#: A short description of the type of document (e.g., "Text document"). This is a
#: class variable that subclasses should define.
description: str
#: A list of extensions that documents of this type might use, without leading dots
#: (e.g., `["doc", "txt"]`). The list must have at least one extension; the first
#: is the default extension for documents of this type. This is a class variable
#: that subclasses should define.
extensions: list[str]
def __init__(self, app: App):
"""Create a new Document. Do not call this constructor directly - use
[`DocumentSet.new`][toga.documents.DocumentSet.new],
[`DocumentSet.open`][toga.documents.DocumentSet.open] or
[`DocumentSet.request_open`][toga.documents.DocumentSet.request_open] instead.
:param app: The application the document is associated with.
"""
self._path: Path | None = None
self._app = app
self._main_window: Window | None = None
self.modified = False
# Create the visual representation of the document.
self.create()
# Add the document to the list of managed documents.
self.app.documents._add(self)
######################################################################
# Document properties
######################################################################
@property
def path(self) -> Path:
"""The path where the document is stored (read-only)."""
return self._path
@property
def app(self) -> App:
"""The app that this document is associated with (read-only)."""
return self._app
@property
def main_window(self) -> Window | None:
"""The main window for the document."""
return self._main_window
@main_window.setter
def main_window(self, window: Window) -> None:
self._main_window = window
@property
def title(self) -> str:
"""The title of the document.
This will be used as the default title of a [`toga.DocumentWindow`][] that
contains the document.
"""
return f"{self.description}: {self.path.stem if self.path else 'Untitled'}"
@property
def modified(self) -> bool:
"""Has the document been modified?"""
return self._modified
@modified.setter
def modified(self, value: bool):
self._modified = bool(value)
######################################################################
# Document operations
######################################################################
def focus(self):
"""Give the document focus in the app."""
self.app.current_window = self.main_window
def hide(self) -> None:
"""Hide the visual representation for this document."""
self.main_window.hide()
def open(self, path: str | Path):
"""Open a file as a document.
:param path: The file to open.
"""
self._path = Path(path).absolute()
if self._path.exists():
self.read()
else:
self._path = None
raise FileNotFoundError()
# Set the title of the document window to match the path
self._main_window.title = self._main_window._default_title
# Document is initially unmodified
self.modified = False
def save(self, path: str | Path | None = None):
"""Save the document as a file.
If a path is provided, the path for the document will be updated. Otherwise, the
existing path will be used.
If the [`Document.write()`][toga.Document.write] method has not been
implemented, this method is a no-op.
:param path: If provided, the new file name for the document.
"""
if self._writable():
if path:
self._path = Path(path).absolute()
# Re-set the title of the document with the new path
self._main_window.title = self._main_window._default_title
self.write()
# Clear the modification flag.
self.modified = False
# A document is writable if its class overrides the `write` method.
def _writable(self):
return type(self).write is not Document.write
def show(self) -> None:
"""Show the visual representation for this document."""
self.main_window.show()
def touch(self, *args, **kwargs):
"""Mark the document as modified.
This method accepts `*args` and `**kwargs` so that it can be used as an
`on_change` handler; these arguments are not used.
"""
self.modified = True
######################################################################
# Abstract interface
######################################################################
@abstractmethod
def create(self) -> None:
"""Create the window (or windows) for the document.
This method must, at a minimum, assign the
[`main_window`][toga.Document.main_window] property. It
may also create additional windows or UI elements if desired.
"""
@abstractmethod
def read(self) -> None:
"""Load a representation of the document into memory from
[`Document.path`][toga.Document.path], and populate the document window.
"""
def write(self) -> None: # noqa: B027 (it's intentionally blank)
"""Persist a representation of the current state of the document.
This method is a no-op by default, to allow for read-only document types.
"""
|
app
property
The app that this document is associated with (read-only).
main_window
property
writable
The main window for the document.
modified
property
writable
Has the document been modified?
path
property
The path where the document is stored (read-only).
title
property
The title of the document.
This will be used as the default title of a toga.DocumentWindow that
contains the document.
__init__(app)
Create a new Document. Do not call this constructor directly - use
DocumentSet.new,
DocumentSet.open or
DocumentSet.request_open instead.
:param app: The application the document is associated with.
Source code in core/src/toga/documents.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 | def __init__(self, app: App):
"""Create a new Document. Do not call this constructor directly - use
[`DocumentSet.new`][toga.documents.DocumentSet.new],
[`DocumentSet.open`][toga.documents.DocumentSet.open] or
[`DocumentSet.request_open`][toga.documents.DocumentSet.request_open] instead.
:param app: The application the document is associated with.
"""
self._path: Path | None = None
self._app = app
self._main_window: Window | None = None
self.modified = False
# Create the visual representation of the document.
self.create()
# Add the document to the list of managed documents.
self.app.documents._add(self)
|
create()
abstractmethod
Create the window (or windows) for the document.
This method must, at a minimum, assign the
main_window property. It
may also create additional windows or UI elements if desired.
Source code in core/src/toga/documents.py
156
157
158
159
160
161
162
163 | @abstractmethod
def create(self) -> None:
"""Create the window (or windows) for the document.
This method must, at a minimum, assign the
[`main_window`][toga.Document.main_window] property. It
may also create additional windows or UI elements if desired.
"""
|
focus()
Give the document focus in the app.
Source code in core/src/toga/documents.py
| def focus(self):
"""Give the document focus in the app."""
self.app.current_window = self.main_window
|
hide()
Hide the visual representation for this document.
Source code in core/src/toga/documents.py
| def hide(self) -> None:
"""Hide the visual representation for this document."""
self.main_window.hide()
|
open(path)
Open a file as a document.
:param path: The file to open.
Source code in core/src/toga/documents.py
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114 | def open(self, path: str | Path):
"""Open a file as a document.
:param path: The file to open.
"""
self._path = Path(path).absolute()
if self._path.exists():
self.read()
else:
self._path = None
raise FileNotFoundError()
# Set the title of the document window to match the path
self._main_window.title = self._main_window._default_title
# Document is initially unmodified
self.modified = False
|
read()
abstractmethod
Load a representation of the document into memory from
Document.path, and populate the document window.
Source code in core/src/toga/documents.py
| @abstractmethod
def read(self) -> None:
"""Load a representation of the document into memory from
[`Document.path`][toga.Document.path], and populate the document window.
"""
|
save(path=None)
Save the document as a file.
If a path is provided, the path for the document will be updated. Otherwise, the
existing path will be used.
If the Document.write() method has not been
implemented, this method is a no-op.
:param path: If provided, the new file name for the document.
Source code in core/src/toga/documents.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134 | def save(self, path: str | Path | None = None):
"""Save the document as a file.
If a path is provided, the path for the document will be updated. Otherwise, the
existing path will be used.
If the [`Document.write()`][toga.Document.write] method has not been
implemented, this method is a no-op.
:param path: If provided, the new file name for the document.
"""
if self._writable():
if path:
self._path = Path(path).absolute()
# Re-set the title of the document with the new path
self._main_window.title = self._main_window._default_title
self.write()
# Clear the modification flag.
self.modified = False
|
show()
Show the visual representation for this document.
Source code in core/src/toga/documents.py
| def show(self) -> None:
"""Show the visual representation for this document."""
self.main_window.show()
|
touch(*args, **kwargs)
Mark the document as modified.
This method accepts *args and **kwargs so that it can be used as an
on_change handler; these arguments are not used.
Source code in core/src/toga/documents.py
144
145
146
147
148
149
150 | def touch(self, *args, **kwargs):
"""Mark the document as modified.
This method accepts `*args` and `**kwargs` so that it can be used as an
`on_change` handler; these arguments are not used.
"""
self.modified = True
|
write()
Persist a representation of the current state of the document.
This method is a no-op by default, to allow for read-only document types.
Source code in core/src/toga/documents.py
| def write(self) -> None: # noqa: B027 (it's intentionally blank)
"""Persist a representation of the current state of the document.
This method is a no-op by default, to allow for read-only document types.
"""
|
Bases: Sequence[Document], Mapping[Path, Document]
Source code in core/src/toga/documents.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387 | class DocumentSet(Sequence[Document], Mapping[Path, Document]):
def __init__(self, app: App, types: list[type[Document]]):
"""A collection of documents managed by an app.
A document is automatically added to the app when it is created, and removed
when it is closed. The document collection will be stored in the order that
documents were created.
:param app: The app that this instance is bound to.
:param types: The document types managed by this app.
"""
self.app = app
for doc_type in types:
if not hasattr(doc_type, "description"):
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define a 'descriptions' attribute"
)
if not hasattr(doc_type, "extensions"):
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define an 'extensions' attribute"
)
if len(doc_type.extensions) == 0:
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define at least one extension"
)
self._types = types
self.elements: list[Document] = []
@property
def types(self) -> list[type[Document]]:
"""The list of document types the app can manage.
The first document type in the list is the app's default document type.
"""
return self._types
def __iter__(self) -> Iterator[Document]:
return iter(self.elements)
def __contains__(self, value: object) -> bool:
return value in self.elements
def __len__(self) -> int:
return len(self.elements)
def __getitem__(self, path_or_index):
# Look up by index
if isinstance(path_or_index, int):
return self.elements[path_or_index]
# Look up by path
path = Path(path_or_index).resolve()
for item in self.elements:
if item.path == path:
return item
# No match found
raise KeyError(path_or_index)
def _add(self, document: Path):
if document in self:
raise ValueError("Document is already being managed.")
self.elements.append(document)
def _remove(self, document: Path):
if document not in self:
raise ValueError("Document is not being managed.")
self.elements.remove(document)
def new(self, document_type: type[Document]) -> Document:
"""Create a new document of the given type, and show the document window.
:param document_type: The document type that has been requested.
:returns: The newly created document.
"""
document = document_type(app=self.app)
document.show()
return document
async def request_open(self) -> Document:
"""Present a dialog asking the user for a document to open, and pass the
selected path to [`DocumentSet.open()`][toga.documents.DocumentSet.open].
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
"""
# A safety catch: if app modal dialogs aren't actually modal (eg, macOS) prevent
# a second open dialog from being opened when one is already active. Attach the
# dialog instance as a private attribute; delete as soon as the future is
# complete.
if hasattr(self, "_open_dialog"):
return
# 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
current_window = self.app.current_window
if self.app._impl.CLOSE_ON_LAST_WINDOW:
if hasattr(self.app.current_window, "_commit"):
if await self.app.current_window._commit():
current_window._replace = True
else:
# The changes in the current document window couldn't be committed
# (e.g., a save was requested, but then cancelled), so we can't
# proceed with opening a new document.
return
self._open_dialog = dialogs.OpenFileDialog(
self.app.formal_name,
file_types=(
list(itertools.chain(*(doc_type.extensions for doc_type in self.types)))
if self.types
else None
),
)
path = await self.app.dialog(self._open_dialog)
del self._open_dialog
try:
if path:
return self.open(path)
finally:
# Remove the replacement marker
if hasattr(current_window, "_replace"):
del current_window._replace
def open(self, path: Path | str) -> Document:
"""Open a document in the app, and show the document window.
If the provided path is already an open document, the existing representation
for the document will be given focus.
:param path: The path to the document to be opened.
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
"""
path = Path(path).resolve()
try:
document = self.app.documents[path]
document.focus()
return document
except KeyError:
# No existing representation for the document.
try:
DocType = {
extension: doc_type
for doc_type in self.types
for extension in doc_type.extensions
}[path.suffix[1:]]
except KeyError as exc:
raise ValueError(
f"Don't know how to open documents with extension {path.suffix}"
) from exc
else:
prev_window = self.app.current_window
document = DocType(app=self.app)
try:
document.open(path)
# If the previous window is marked for replacement, close it; but
# put the new document window in the same position as the previous
# one.
if getattr(prev_window, "_replace", False):
document.main_window.position = prev_window.position
prev_window.close()
document.show()
return document
except Exception:
# Open failed; ensure any windows opened by the document are closed.
document.main_window.close()
raise
async def save(self):
"""Save the current content of an app.
If there isn't a current window, or current window doesn't define a `save()`
method, the save request will be ignored.
"""
if hasattr(self.app.current_window, "save"):
await self.app.current_window.save()
async def save_as(self):
"""Save the current content of an app under a different filename.
If there isn't a current window, or the current window hasn't defined a
`save_as()` method, the save-as request will be ignored.
"""
if hasattr(self.app.current_window, "save_as"):
await self.app.current_window.save_as()
async def save_all(self):
"""Save the state of all content in the app.
This method will attempt to call `save()` on every window associated with the
app. Any windows that do not provide a `save()` method will be ignored.
"""
for window in self.app.windows:
if hasattr(window, "save"):
await window.save()
|
types
property
The list of document types the app can manage.
The first document type in the list is the app's default document type.
__init__(app, types)
A collection of documents managed by an app.
A document is automatically added to the app when it is created, and removed
when it is closed. The document collection will be stored in the order that
documents were created.
:param app: The app that this instance is bound to.
:param types: The document types managed by this app.
Source code in core/src/toga/documents.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209 | def __init__(self, app: App, types: list[type[Document]]):
"""A collection of documents managed by an app.
A document is automatically added to the app when it is created, and removed
when it is closed. The document collection will be stored in the order that
documents were created.
:param app: The app that this instance is bound to.
:param types: The document types managed by this app.
"""
self.app = app
for doc_type in types:
if not hasattr(doc_type, "description"):
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define a 'descriptions' attribute"
)
if not hasattr(doc_type, "extensions"):
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define an 'extensions' attribute"
)
if len(doc_type.extensions) == 0:
raise ValueError(
f"Document type {doc_type.__name__!r} "
"doesn't define at least one extension"
)
self._types = types
self.elements: list[Document] = []
|
new(document_type)
Create a new document of the given type, and show the document window.
:param document_type: The document type that has been requested.
:returns: The newly created document.
Source code in core/src/toga/documents.py
254
255
256
257
258
259
260
261
262 | def new(self, document_type: type[Document]) -> Document:
"""Create a new document of the given type, and show the document window.
:param document_type: The document type that has been requested.
:returns: The newly created document.
"""
document = document_type(app=self.app)
document.show()
return document
|
open(path)
Open a document in the app, and show the document window.
If the provided path is already an open document, the existing representation
for the document will be given focus.
:param path: The path to the document to be opened.
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
Source code in core/src/toga/documents.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359 | def open(self, path: Path | str) -> Document:
"""Open a document in the app, and show the document window.
If the provided path is already an open document, the existing representation
for the document will be given focus.
:param path: The path to the document to be opened.
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
"""
path = Path(path).resolve()
try:
document = self.app.documents[path]
document.focus()
return document
except KeyError:
# No existing representation for the document.
try:
DocType = {
extension: doc_type
for doc_type in self.types
for extension in doc_type.extensions
}[path.suffix[1:]]
except KeyError as exc:
raise ValueError(
f"Don't know how to open documents with extension {path.suffix}"
) from exc
else:
prev_window = self.app.current_window
document = DocType(app=self.app)
try:
document.open(path)
# If the previous window is marked for replacement, close it; but
# put the new document window in the same position as the previous
# one.
if getattr(prev_window, "_replace", False):
document.main_window.position = prev_window.position
prev_window.close()
document.show()
return document
except Exception:
# Open failed; ensure any windows opened by the document are closed.
document.main_window.close()
raise
|
request_open()
async
Present a dialog asking the user for a document to open, and pass the
selected path to DocumentSet.open().
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
Source code in core/src/toga/documents.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310 | async def request_open(self) -> Document:
"""Present a dialog asking the user for a document to open, and pass the
selected path to [`DocumentSet.open()`][toga.documents.DocumentSet.open].
:returns: The document that was opened.
:raises ValueError: If the path describes a file that is of a type that doesn't
match a registered document type.
"""
# A safety catch: if app modal dialogs aren't actually modal (eg, macOS) prevent
# a second open dialog from being opened when one is already active. Attach the
# dialog instance as a private attribute; delete as soon as the future is
# complete.
if hasattr(self, "_open_dialog"):
return
# 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
current_window = self.app.current_window
if self.app._impl.CLOSE_ON_LAST_WINDOW:
if hasattr(self.app.current_window, "_commit"):
if await self.app.current_window._commit():
current_window._replace = True
else:
# The changes in the current document window couldn't be committed
# (e.g., a save was requested, but then cancelled), so we can't
# proceed with opening a new document.
return
self._open_dialog = dialogs.OpenFileDialog(
self.app.formal_name,
file_types=(
list(itertools.chain(*(doc_type.extensions for doc_type in self.types)))
if self.types
else None
),
)
path = await self.app.dialog(self._open_dialog)
del self._open_dialog
try:
if path:
return self.open(path)
finally:
# Remove the replacement marker
if hasattr(current_window, "_replace"):
del current_window._replace
|
save()
async
Save the current content of an app.
If there isn't a current window, or current window doesn't define a save()
method, the save request will be ignored.
Source code in core/src/toga/documents.py
361
362
363
364
365
366
367
368 | async def save(self):
"""Save the current content of an app.
If there isn't a current window, or current window doesn't define a `save()`
method, the save request will be ignored.
"""
if hasattr(self.app.current_window, "save"):
await self.app.current_window.save()
|
save_all()
async
Save the state of all content in the app.
This method will attempt to call save() on every window associated with the
app. Any windows that do not provide a save() method will be ignored.
Source code in core/src/toga/documents.py
379
380
381
382
383
384
385
386
387 | async def save_all(self):
"""Save the state of all content in the app.
This method will attempt to call `save()` on every window associated with the
app. Any windows that do not provide a `save()` method will be ignored.
"""
for window in self.app.windows:
if hasattr(window, "save"):
await window.save()
|
save_as()
async
Save the current content of an app under a different filename.
If there isn't a current window, or the current window hasn't defined a
save_as() method, the save-as request will be ignored.
Source code in core/src/toga/documents.py
370
371
372
373
374
375
376
377 | async def save_as(self):
"""Save the current content of an app under a different filename.
If there isn't a current window, or the current window hasn't defined a
`save_as()` method, the save-as request will be ignored.
"""
if hasattr(self.app.current_window, "save_as"):
await self.app.current_window.save_as()
|