Add confirmation dialog when discarding an unsaved file

This commit is contained in:
Rebecca Breu 2024-05-20 19:31:06 +02:00
parent a89027f0ba
commit aac2d0edfc
13 changed files with 270 additions and 36 deletions

View file

@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"] python-version: ["3.9", "3.10", "3.11", "3.12"]
pyqt-version: ["6.5.3", "6.7.0"] pyqt-version: ["6.7.0"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python

View file

@ -10,6 +10,9 @@ Added
QT_IMAGEIO_MAXALLOC QT_IMAGEIO_MAXALLOC
* Display error messages when images can't be loaded from bee files * Display error messages when images can't be loaded from bee files
* Added option to export all images from scene (File -> Export Images) * Added option to export all images from scene (File -> Export Images)
* Added a confirmation dialog when attempting to close unsaved files.
The confirmation dialog can be disalbed in:
Settings -> Miscellaneous -> Confirm when closing an unsaved file
Fixed Fixed

View file

@ -312,7 +312,7 @@ actions = ActionList([
id='new_scene', id='new_scene',
text='&New Scene', text='&New Scene',
shortcuts=['Ctrl+N'], shortcuts=['Ctrl+N'],
callback='clear_scene', callback='on_action_new_scene',
), ),
Action( Action(
id='fit_scene', id='fit_scene',

View file

@ -123,7 +123,7 @@ class ActionsMixin:
qaction = QtGui.QAction(os.path.basename(filename), self) qaction = QtGui.QAction(os.path.basename(filename), self)
qaction.setShortcuts(action.get_shortcuts()) qaction.setShortcuts(action.get_shortcuts())
qaction.triggered.connect( qaction.triggered.connect(
partial(self.open_from_file, filename)) partial(self.on_action_open_recent_file, filename))
self.addAction(qaction) self.addAction(qaction)
action.qaction = qaction action.qaction = qaction
self._recent_files_submenu.addAction(qaction) self._recent_files_submenu.addAction(qaction)

View file

@ -109,6 +109,10 @@ settings_events = BeeSettingsEvents()
class BeeSettings(QtCore.QSettings): class BeeSettings(QtCore.QSettings):
FIELDS = { FIELDS = {
'Save/confirm_close_unsaved': {
'default': True,
'cast': bool,
},
'Items/image_storage_format': { 'Items/image_storage_format': {
'default': 'best', 'default': 'best',
'validate': lambda x: x in ('png', 'jpg', 'best'), 'validate': lambda x: x in ('png', 'jpg', 'best'),

View file

@ -16,7 +16,7 @@
IMG_LOADING_ERROR_MSG = ( IMG_LOADING_ERROR_MSG = (
'Unknown format or too big?\n' 'Unknown format or too big?\n'
'Check Settings -> Miscellaneous -> Maximum Image Size') 'Check Settings -> Images & Items -> Maximum Image Size')
class BeeFileIOError(Exception): class BeeFileIOError(Exception):

View file

@ -200,6 +200,26 @@ class BeeGraphicsView(MainControlsMixin,
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
logger.trace('Fit view done') logger.trace('Fit view done')
def get_confirmation_unsaved_changes(self, msg):
confirm = self.settings.valueOrDefault('Save/confirm_close_unsaved')
if confirm and not self.undo_stack.isClean():
answer = QtWidgets.QMessageBox.question(
self,
'Discard unsaved changes?',
msg,
QtWidgets.QMessageBox.StandardButton.Yes |
QtWidgets.QMessageBox.StandardButton.Cancel)
return answer == QtWidgets.QMessageBox.StandardButton.Yes
return True
def on_action_new_scene(self):
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if confirm:
self.clear_scene()
def on_action_fit_scene(self): def on_action_fit_scene(self):
self.fit_rect(self.scene.itemsBoundingRect()) self.fit_rect(self.scene.itemsBoundingRect())
@ -388,6 +408,13 @@ class BeeGraphicsView(MainControlsMixin,
self.scene.add_queued_items() self.scene.add_queued_items()
self.on_action_fit_scene() self.on_action_fit_scene()
def on_action_open_recent_file(self, filename):
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if confirm:
self.open_from_file(filename)
def open_from_file(self, filename): def open_from_file(self, filename):
logger.info(f'Opening file {filename}') logger.info(f'Opening file {filename}')
self.clear_scene() self.clear_scene()
@ -402,6 +429,12 @@ class BeeGraphicsView(MainControlsMixin,
self.worker.start() self.worker.start()
def on_action_open(self): def on_action_open(self):
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if not confirm:
return
self.cancel_active_modes() self.cancel_active_modes()
filename, f = QtWidgets.QFileDialog.getOpenFileName( filename, f = QtWidgets.QFileDialog.getOpenFileName(
parent=self, parent=self,
@ -528,8 +561,11 @@ class BeeGraphicsView(MainControlsMixin,
self.worker.start() self.worker.start()
def on_action_quit(self): def on_action_quit(self):
logger.info('User quit. Exiting...') confirm = self.get_confirmation_unsaved_changes(
self.app.quit() 'There are unsaved changes. Are you sure you want to quit?')
if confirm:
logger.info('User quit. Exiting...')
self.app.quit()
def on_action_settings(self): def on_action_settings(self):
widgets.settings.SettingsDialog(self) widgets.settings.SettingsDialog(self)

View file

@ -17,6 +17,7 @@ from functools import partial
import logging import logging
from PyQt6 import QtWidgets from PyQt6 import QtWidgets
from PyQt6.QtCore import Qt
from beeref import constants from beeref import constants
from beeref.config import BeeSettings, settings_events from beeref.config import BeeSettings, settings_events
@ -26,6 +27,9 @@ logger = logging.getLogger(__name__)
class GroupBase(QtWidgets.QGroupBox): class GroupBase(QtWidgets.QGroupBox):
TITLE = None
HELPTEXT = None
KEY = None
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -50,16 +54,24 @@ class GroupBase(QtWidgets.QGroupBox):
if self.ignore_value_changed: if self.ignore_value_changed:
return return
value = self.convert_value_from_qt(value)
if value != self.settings.valueOrDefault(self.KEY): if value != self.settings.valueOrDefault(self.KEY):
logger.debug(f'Setting {self.KEY} changed to: {value}') logger.debug(f'Setting {self.KEY} changed to: {value}')
self.settings.setValue(self.KEY, value) self.settings.setValue(self.KEY, value)
self.update_title() self.update_title()
def convert_value_from_qt(self, value):
return value
def on_restore_defaults(self):
new_value = self.settings.valueOrDefault(self.KEY)
self.ignore_value_changed = True
self.set_value(new_value)
self.ignore_value_changed = False
self.update_title()
class RadioGroup(GroupBase): class RadioGroup(GroupBase):
TITLE = None
HELPTEXT = None
KEY = None
OPTIONS = None OPTIONS = None
def __init__(self): def __init__(self):
@ -79,19 +91,12 @@ class RadioGroup(GroupBase):
self.ignore_value_changed = False self.ignore_value_changed = False
self.layout.addStretch(100) self.layout.addStretch(100)
def on_restore_defaults(self): def set_value(self, value):
new_value = self.settings.valueOrDefault(self.KEY) for old_value, btn in self.buttons.items():
self.ignore_value_changed = True btn.setChecked(old_value == value)
for value, btn in self.buttons.items():
btn.setChecked(value == new_value)
self.ignore_value_changed = False
self.update_title()
class IntegerGroup(GroupBase): class IntegerGroup(GroupBase):
TITLE = None
HELPTEXT = None
KEY = None
MIN = None MIN = None
MAX = None MAX = None
@ -99,18 +104,33 @@ class IntegerGroup(GroupBase):
super().__init__() super().__init__()
self.input = QtWidgets.QSpinBox() self.input = QtWidgets.QSpinBox()
self.input.setRange(self.MIN, self.MAX) self.input.setRange(self.MIN, self.MAX)
self.input.setValue(self.settings.valueOrDefault(self.KEY)) self.set_value(self.settings.valueOrDefault(self.KEY))
self.input.valueChanged.connect(self.on_value_changed) self.input.valueChanged.connect(self.on_value_changed)
self.layout.addWidget(self.input) self.layout.addWidget(self.input)
self.layout.addStretch(100) self.layout.addStretch(100)
self.ignore_value_changed = False self.ignore_value_changed = False
def on_restore_defaults(self): def set_value(self, value):
new_value = self.settings.valueOrDefault(self.KEY) self.input.setValue(value)
self.ignore_value_changed = True
self.input.setValue(new_value)
class SingleCheckboxGroup(GroupBase):
LABEL = None
def __init__(self):
super().__init__()
self.input = QtWidgets.QCheckBox(self.LABEL)
self.set_value(self.settings.valueOrDefault(self.KEY))
self.input.checkStateChanged.connect(self.on_value_changed)
self.layout.addWidget(self.input)
self.layout.addStretch(100)
self.ignore_value_changed = False self.ignore_value_changed = False
self.update_title()
def set_value(self, value):
self.input.setChecked(value)
def convert_value_from_qt(self, value):
return value == Qt.CheckState.Checked
class ImageStorageFormatWidget(RadioGroup): class ImageStorageFormatWidget(RadioGroup):
@ -144,6 +164,15 @@ class AllocationLimitWidget(IntegerGroup):
MAX = 10000 MAX = 10000
class ConfirmCloseUnsavedWidget(SingleCheckboxGroup):
TITLE = 'Confirm when closing an unsaved file:'
HELPTEXT = (
'When about to close an unsaved file, should BeeRef ask for '
'confirmation?')
LABEL = 'Confirm when closing'
KEY = 'Save/confirm_close_unsaved'
class SettingsDialog(QtWidgets.QDialog): class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent): def __init__(self, parent):
super().__init__(parent) super().__init__(parent)
@ -154,11 +183,18 @@ class SettingsDialog(QtWidgets.QDialog):
misc = QtWidgets.QWidget() misc = QtWidgets.QWidget()
misc_layout = QtWidgets.QGridLayout() misc_layout = QtWidgets.QGridLayout()
misc.setLayout(misc_layout) misc.setLayout(misc_layout)
misc_layout.addWidget(ImageStorageFormatWidget(), 0, 0) misc_layout.addWidget(ConfirmCloseUnsavedWidget(), 0, 0)
misc_layout.addWidget(ArrangeGapWidget(), 0, 1)
misc_layout.addWidget(AllocationLimitWidget(), 1, 0)
tabs.addTab(misc, '&Miscellaneous') tabs.addTab(misc, '&Miscellaneous')
# Images & Items
items = QtWidgets.QWidget()
items_layout = QtWidgets.QGridLayout()
items.setLayout(items_layout)
items_layout.addWidget(ImageStorageFormatWidget(), 0, 0)
items_layout.addWidget(ArrangeGapWidget(), 0, 1)
items_layout.addWidget(AllocationLimitWidget(), 1, 0)
tabs.addTab(items, '&Images && Items')
layout = QtWidgets.QVBoxLayout() layout = QtWidgets.QVBoxLayout()
self.setLayout(layout) self.setLayout(layout)
layout.addWidget(tabs) layout.addWidget(tabs)

View file

@ -21,8 +21,8 @@ requires-python = ">=3.9,<3.13"
dependencies = [ dependencies = [
"exif>=1.3.5,<=1.6.0", "exif>=1.3.5,<=1.6.0",
"lxml==5.1.0", "lxml==5.1.0",
"pyQt6-Qt6>=6.5.3,<=6.7.0", "pyQt6-Qt6>=6.7.0,<=6.7.0",
"pyQt6>=6.5.0,<=6.7.0", "pyQt6>=6.7.0,<=6.7.0",
"rectangle-packer>=2.0.1,<=2.0.2", "rectangle-packer>=2.0.1,<=2.0.2",
] ]

3
setup.py Normal file
View file

@ -0,0 +1,3 @@
from setuptools import setup
setup()

View file

@ -20,7 +20,7 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin):
def on_bar(self): def on_bar(self):
pass pass
def open_from_file(self): def on_action_open_recent_file(self, filename):
pass pass

View file

@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch, mock_open
from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from beeref import widgets from beeref import commands, widgets
from beeref.actions import actions from beeref.actions import actions
from beeref.config import logfile_name from beeref.config import logfile_name
from beeref.items import BeePixmapItem, BeeTextItem from beeref.items import BeePixmapItem, BeeTextItem
@ -153,6 +153,88 @@ def test_fit_rect_toggle_when_previous(center_mock, fit_mock, view):
assert view.get_scale() == 2 assert view.get_scale() == 2
@patch('PyQt6.QtWidgets.QMessageBox.question')
def test_get_confirmation_unsaved_changes_when_no_changes(
dlg_mock, settings, view, item):
view.scene.addItem(item)
assert view.undo_stack.isClean()
assert view.get_confirmation_unsaved_changes('foo') is True
dlg_mock.assert_not_called()
@patch('PyQt6.QtWidgets.QMessageBox.question')
def test_get_confirmation_unsaved_changes_when_changes_confirmation_disabled(
dlg_mock, settings, view, item):
settings.setValue('Save/confirm_close_unsaved', False)
view.undo_stack.push(
commands.InsertItems(view.scene, [item], QtCore.QPointF(0, 0)))
assert view.undo_stack.isClean() is False
assert view.get_confirmation_unsaved_changes('foo') is True
dlg_mock.assert_not_called()
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes)
def test_get_confirmation_unsaved_changes_when_changes_confirmed(
dlg_mock, settings, view, item):
view.undo_stack.push(
commands.InsertItems(view.scene, [item], QtCore.QPointF(0, 0)))
assert view.undo_stack.isClean() is False
assert view.get_confirmation_unsaved_changes('foo') is True
dlg_mock.assert_called_once()
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Cancel)
def test_get_confirmation_unsaved_changes_when_changes_not_confirmed(
dlg_mock, settings, view, item):
view.undo_stack.push(
commands.InsertItems(view.scene, [item], QtCore.QPointF(0, 0)))
assert view.undo_stack.isClean() is False
assert view.get_confirmation_unsaved_changes('foo') is False
dlg_mock.assert_called_once()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=False)
def test_on_action_new_scene_when_unsaved_changes_not_confirmed(
confirm_mock, view):
view.clear_scene = MagicMock()
view.on_action_new_scene()
confirm_mock.assert_called_once()
view.clear_scene.assert_not_called()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=True)
def test_on_action_new_scene_when_unsaved_changes_confirmed(
confirm_mock, view):
view.clear_scene = MagicMock()
view.on_action_new_scene()
confirm_mock.assert_called_once()
view.clear_scene.assert_called_once_with()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=False)
def test_on_action_open_recent_file_when_unsaved_changes_not_confirmed(
confirm_mock, view):
view.open_from_file = MagicMock()
view.on_action_open_recent_file('foo.bee')
confirm_mock.assert_called_once()
view.open_from_file.assert_not_called()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=True)
def test_on_action_open_recent_file_when_unsaved_changes_confirmed(
confirm_mock, view):
view.open_from_file = MagicMock()
view.on_action_open_recent_file('foo.bee')
confirm_mock.assert_called_once()
view.open_from_file.assert_called_once_with('foo.bee')
@patch('beeref.view.BeeGraphicsView.clear_scene') @patch('beeref.view.BeeGraphicsView.clear_scene')
def test_open_from_file(clear_mock, view, qtbot): def test_open_from_file(clear_mock, view, qtbot):
root = os.path.dirname(__file__) root = os.path.dirname(__file__)
@ -199,6 +281,19 @@ def test_on_action_open(dialog_mock, view, qtbot):
view.cancel_active_modes.assert_called_with() view.cancel_active_modes.assert_called_with()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=False)
@patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName')
def test_on_action_open_when_unsaved_changes_not_confirmed(
dialog_mock, confirm_mock, view):
view.cancel_active_modes = MagicMock()
view.open_from_file = MagicMock()
view.on_action_open()
view.cancel_active_modes.assert_not_called()
dialog_mock.assert_not_called()
view.open_from_file.assert_not_called()
@patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName') @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName')
@patch('beeref.view.BeeGraphicsView.open_from_file') @patch('beeref.view.BeeGraphicsView.open_from_file')
def test_on_action_open_when_no_filename(open_mock, dialog_mock, view): def test_on_action_open_when_no_filename(open_mock, dialog_mock, view):
@ -464,6 +559,26 @@ def test_on_action_export_images_file_exists_canceled(
imgfilename.read_text() == 'foo' imgfilename.read_text() == 'foo'
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=False)
@patch('beeref.__main__.BeeRefApplication.quit')
def test_on_action_quit_when_unsaved_changes_not_confirmed(
quit_mock, confirm_mock, view):
view.on_action_quit()
confirm_mock.assert_called_once()
quit_mock.assert_not_called()
@patch('beeref.view.BeeGraphicsView.get_confirmation_unsaved_changes',
return_value=True)
@patch('beeref.__main__.BeeRefApplication.quit')
def test_on_action_quit_when_unsaved_changes_confirmed(
quit_mock, confirm_mock, view):
view.on_action_quit()
confirm_mock.assert_called_once()
quit_mock.assert_called_once_with()
@patch('beeref.widgets.settings.SettingsDialog.show') @patch('beeref.widgets.settings.SettingsDialog.show')
def test_on_action_settings(show_mock, view): def test_on_action_settings(show_mock, view):
view.on_action_settings() view.on_action_settings()

View file

@ -4,6 +4,7 @@ from PyQt6 import QtWidgets
from beeref.widgets.settings import ( from beeref.widgets.settings import (
ArrangeGapWidget, ArrangeGapWidget,
ConfirmCloseUnsavedWidget,
ImageStorageFormatWidget, ImageStorageFormatWidget,
SettingsDialog, SettingsDialog,
) )
@ -31,7 +32,7 @@ def test_image_storage_format_selects_radiobox(settings, view):
def test_image_storage_format_saves_change(settings, view): def test_image_storage_format_saves_change(settings, view):
settings.setValue('Items/image_storage_format', 'best') settings.setValue('Items/image_storage_format', 'best')
widget = ImageStorageFormatWidget() widget = ImageStorageFormatWidget()
widget.buttons['jpg'].setChecked(True) widget.set_value('jpg')
assert widget.buttons['best'].isChecked() is False assert widget.buttons['best'].isChecked() is False
assert widget.buttons['png'].isChecked() is False assert widget.buttons['png'].isChecked() is False
assert widget.buttons['jpg'].isChecked() is True assert widget.buttons['jpg'].isChecked() is True
@ -41,7 +42,7 @@ def test_image_storage_format_saves_change(settings, view):
def test_image_storage_format_on_restore_defaults(settings, view): def test_image_storage_format_on_restore_defaults(settings, view):
widget = ImageStorageFormatWidget() widget = ImageStorageFormatWidget()
widget.buttons['jpg'].setChecked(True) widget.set_value('jpg')
settings.setValue('Items/image_storage_format', 'best') settings.setValue('Items/image_storage_format', 'best')
widget.on_restore_defaults() widget.on_restore_defaults()
assert widget.buttons['best'].isChecked() is True assert widget.buttons['best'].isChecked() is True
@ -70,27 +71,63 @@ def test_arrange_gap_sets_title_when_edited(settings, view):
def test_arrange_gap_saves_change(settings, view): def test_arrange_gap_saves_change(settings, view):
settings.setValue('Items/arrange_gap', 6) settings.setValue('Items/arrange_gap', 6)
widget = ArrangeGapWidget() widget = ArrangeGapWidget()
widget.input.setValue(8) widget.set_value(8)
assert settings.valueOrDefault('Items/arrange_gap') == 8 assert settings.valueOrDefault('Items/arrange_gap') == 8
assert widget.title() == 'Arrange Gap: ✎' assert widget.title() == 'Arrange Gap: ✎'
def test_arrange_gap_on_restore_defaults(settings, view): def test_arrange_gap_on_restore_defaults(settings, view):
widget = ArrangeGapWidget() widget = ArrangeGapWidget()
widget.input.setValue(7) widget.set_value(7)
settings.setValue('Items/arrange_gap', 0) settings.setValue('Items/arrange_gap', 0)
widget.on_restore_defaults() widget.on_restore_defaults()
assert widget.input.value() == 0 assert widget.input.value() == 0
assert widget.title() == 'Arrange Gap:' assert widget.title() == 'Arrange Gap:'
def test_confirm_closed_initialises_input_from_settings(settings, view):
settings.setValue('Save/confirm_close_unsaved', False)
widget = ConfirmCloseUnsavedWidget()
assert widget.input.isChecked() is False
def test_confirm_closed_sets_title_when_not_edited(settings, view):
widget = ConfirmCloseUnsavedWidget()
assert widget.title() == 'Confirm when closing an unsaved file:'
def test_confirm_closed_sets_title_when_edited(settings, view):
settings.setValue('Save/confirm_close_unsaved', False)
widget = ConfirmCloseUnsavedWidget()
assert widget.title() == 'Confirm when closing an unsaved file: ✎'
def test_confirm_closed_saves_change(settings, view):
settings.setValue('Save/confirm_close_unsaved', True)
widget = ConfirmCloseUnsavedWidget()
widget.set_value(False)
assert settings.valueOrDefault('Save/confirm_close_unsaved') is False
assert widget.title() == 'Confirm when closing an unsaved file: ✎'
def test_confirm_closed_on_restore_defaults(settings, view):
widget = ConfirmCloseUnsavedWidget()
widget.set_value(False)
settings.setValue('Save/confirm_close_unsaved', True)
widget.on_restore_defaults()
assert widget.input.isChecked() is True
assert widget.title() == 'Confirm when closing an unsaved file:'
@patch('PyQt6.QtWidgets.QMessageBox.question', @patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes) return_value=QtWidgets.QMessageBox.StandardButton.Yes)
def test_settings_dialog_on_restore_defaults(msg_mock, settings, view): def test_settings_dialog_on_restore_defaults(msg_mock, settings, view):
dialog = SettingsDialog(view) dialog = SettingsDialog(view)
settings.setValue('Items/image_storage_format', 'jpg') settings.setValue('Items/image_storage_format', 'jpg')
settings.setValue('Items/arrange_gap', 10) settings.setValue('Items/arrange_gap', 10)
settings.setValue('Save/confirm_close_unsaved', False)
dialog.on_restore_defaults() dialog.on_restore_defaults()
msg_mock.assert_called_once() msg_mock.assert_called_once()
assert settings.valueOrDefault('Items/image_storage_format') == 'best' assert settings.valueOrDefault('Items/image_storage_format') == 'best'
assert settings.valueOrDefault('Items/arrange_gap') == 0 assert settings.valueOrDefault('Items/arrange_gap') == 0
assert settings.valueOrDefault('Save/confirm_close_unsaved') is True