From 9b0dfd1bf44b3dfa5f4d8ff22ec3e72c51394b04 Mon Sep 17 00:00:00 2001 From: Julien Hery Date: Fri, 16 Oct 2026 02:10:45 -0400 Subject: [PATCH] Add layout_io plugin to import and export interface layouts Adds File > Import > Layouts... and File > Export > Layouts... so the layouts shown in the layout bar can be saved to a .json file and loaded again in another session. The QML dialog talks to the layouts model. Python handles the file IO and validation. Imported layouts are appended to the existing list, and a name that clashes with an existing layout gets a numeric suffix. Panels that are not registered in this session are flagged rather than dropped. Docs, changelog and release notes are updated to match. Signed-off-by: Julien Hery --- CHANGELOG.md | 1 + docs/user_docs/interface/layouts.rst | 11 + docs/user_docs/release_notes/index.rst | 1 + src/plugin/python_plugins/CMakeLists.txt | 1 + .../python_plugins/layout_io/__init__.py | 2 + .../python_plugins/layout_io/layout_io.py | 249 +++++++++ .../qml/LayoutIO.1/LayoutIODialog.qml | 521 ++++++++++++++++++ 7 files changed, 786 insertions(+) create mode 100644 src/plugin/python_plugins/layout_io/__init__.py create mode 100644 src/plugin/python_plugins/layout_io/layout_io.py create mode 100644 src/plugin/python_plugins/layout_io/qml/LayoutIO.1/LayoutIODialog.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index ed655e7cc..08e09539c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Covers changes after commit `eb3d235e`. - Escape hotkey acts as a workflow reset: it resets the viewport and exits fullscreen. - Media panel scroll features and additional hotkeys. - Filesystem browser feature additions and improvements. +- Interface layouts can be exported to and imported from a file (File > Export > Layouts ... and File > Import > Layouts ...), provided by the new `layout_io` Python plugin. - macOS: "Open With" support in the Finder context menu. - Apple modifier key names are displayed in the UI. - Portable Windows packaging target and a Windows build automation script (`scripts/build_windows.ps1`). diff --git a/docs/user_docs/interface/layouts.rst b/docs/user_docs/interface/layouts.rst index 78b2295b6..5c7e69bd9 100644 --- a/docs/user_docs/interface/layouts.rst +++ b/docs/user_docs/interface/layouts.rst @@ -40,6 +40,17 @@ Tip: Hit the 'Tab' key to toggle between the 'Present' layout and an alternative .. note:: All aspects of your Layout and UI customisations are automatically saved so that they persist between future xSTUDIO session. +Sharing Layouts +--------------- + +Layouts can be saved to a file and loaded into another xSTUDIO, for example to share a layout with a colleague or to carry it to another machine. + +To export, choose *File->Export->Layouts ...*, tick the layouts you want and press *Export...* to pick a file name. The layouts are saved to a single .json file. + +To import, choose *File->Import->Layouts ...* and select a file that was exported this way. The layouts it contains are listed so that you can choose which ones to add. Imported layouts are added to the end of the layout list. A layout whose name is already in use is added with a number after its name, so existing layouts are never replaced. + +If a layout uses a panel that is not available in your xSTUDIO (for example one that is provided by a plugin you don't have) the dialog warns you, and that panel will be blank in the imported layout. + Customising the Interface Walkthrough ------------------------------------- diff --git a/docs/user_docs/release_notes/index.rst b/docs/user_docs/release_notes/index.rst index 4e57abc24..a5d6bf627 100644 --- a/docs/user_docs/release_notes/index.rst +++ b/docs/user_docs/release_notes/index.rst @@ -21,6 +21,7 @@ v1.4.x - When comparing multiple sources with A/B, Grid mode etc. you can manually set the frame offset for each source to achieve alignment, which will then persist with the session. Auto alignment (with or without trim) based on timecode is available as before. - Split timeline clips at the playhead with a hotkey, and import OpenTimelineIO image-sequence trims in media-frame space. - Improved filesystem browser navigation and thumbnail handling. +- Export your interface layouts to a file and import them into another xSTUDIO, from the File->Export and File->Import menus. - Expanded the Python API for viewport control, playhead and timeline access, and plugin event handling. - Improved annotation rendering and export workflows, plus color-management behavior for offscreen renders and thumbnails. - Improved media compatibility, including Blender multilayer EXR decoding and PDF playback fixes. diff --git a/src/plugin/python_plugins/CMakeLists.txt b/src/plugin/python_plugins/CMakeLists.txt index 5cc0206c8..23a3375d6 100644 --- a/src/plugin/python_plugins/CMakeLists.txt +++ b/src/plugin/python_plugins/CMakeLists.txt @@ -4,4 +4,5 @@ add_python_plugin(on_screen_version_name) add_python_plugin(viewport_field_chart) add_python_plugin(annotations_exporter) add_python_plugin(basic_viewport_mask) +add_python_plugin(layout_io) add_python_plugin(utility) \ No newline at end of file diff --git a/src/plugin/python_plugins/layout_io/__init__.py b/src/plugin/python_plugins/layout_io/__init__.py new file mode 100644 index 000000000..80a0ddf61 --- /dev/null +++ b/src/plugin/python_plugins/layout_io/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +from .layout_io import create_plugin_instance diff --git a/src/plugin/python_plugins/layout_io/layout_io.py b/src/plugin/python_plugins/layout_io/layout_io.py new file mode 100644 index 000000000..49e70bca4 --- /dev/null +++ b/src/plugin/python_plugins/layout_io/layout_io.py @@ -0,0 +1,249 @@ +#!/bin/env python +# SPDX-License-Identifier: Apache-2.0 +"""Import / export of xSTUDIO panel layouts. + +A "layout" is an entry in the layout bar at the top right of the main window: +the named tab you manage with New / Rename / Duplicate Layout. One layout is +the whole panel arrangement behind that tab - the splitters and their divider +positions, each panel's tab group, and the view sitting in every tab. + +This plugin adds "Layouts ..." to the File > Import and File > Export menus. + +The work is split between Python and QML: + +* The layouts live in the "panels model" owned by the C++ GlobalUIModelData + actor. The messages that change it are not bound to Python, so QML does all + of the model access (a QML-side XsPanelsLayoutModel joins the same model as + another client, and its edits are broadcast to the session window and saved + to the preferences in the normal way). +* This module does the file IO and the validation of the file's contents. + +File format:: + + { + "format": "xstudio_layouts", + "version": 1, + "exported_at": "2026-08-07T11:22:33", + "source": {"xstudio": "1.4.0"}, + "layouts": [ + {"layout_name": "Review", "enabled": true, "children": [...]} + ] + } + +Every entry must be a COMPLETE layout node: importing replaces the new row +with the node (it does not merge into it), so an entry without a +``layout_name``, ``enabled`` or ``children`` would give an unnamed, empty tab. +""" + +import datetime +import json +import os +from urllib.parse import urlsplit +from urllib.request import url2pathname + +from xstudio.plugin import PluginBase + +# path to QML resources relative to this .py file +qml_folder_name = "qml/LayoutIO.1" + +# The mode is baked into the QML snippet, so each menu entry gets its own +# dialog item. The item is created once and shown again on later menu clicks. +import_dialog_qml = """ +LayoutIODialog { + mode: "import" +} +""" + +export_dialog_qml = """ +LayoutIODialog { + mode: "export" +} +""" + +EXPORT_FORMAT = "xstudio_layouts" +EXPORT_VERSION = 1 + + +def _xstudio_version(): + """The xSTUDIO version string, or "" if the version module is missing.""" + try: + from xstudio.version import __version__ + + return str(__version__) + except Exception: + return "" + + +def _path_from_url(url): + """Local filesystem path for a ``file://`` URL as sent by a QML FileDialog. + + Handles percent-escapes (spaces etc.) and Windows drive letters. + """ + return url2pathname(urlsplit(url).path) + + +def _collect_tab_views(node, out): + """Gather every ``tab_view`` name referenced below a layout node.""" + if isinstance(node, dict): + view = node.get("tab_view") + if isinstance(view, str) and view: + out.add(view) + children = node.get("children") + if isinstance(children, list): + for child in children: + _collect_tab_views(child, out) + + +def _is_layout_node(node): + """A layout node needs a name and at least one panel below it.""" + if not isinstance(node, dict): + return False + name = node.get("layout_name") + children = node.get("children") + return ( + isinstance(name, str) + and bool(name.strip()) + and isinstance(children, list) + and bool(children) + ) + + +class LayoutIO(PluginBase): + def __init__(self, connection): + PluginBase.__init__( + self, connection, name="LayoutIO", qml_folder=qml_folder_name + ) + + # The stock File > Import / File > Export submenus are created by + # xSTUDIO's own menus, positions are relative to the items they hold. + self.import_menu_id = self.insert_menu_item( + "main menu bar", + "Layouts ...", + "File|Import", + 3.2, + callback=self.import_menu_callback, + ) + self.export_menu_id = self.insert_menu_item( + "main menu bar", + "Layouts ...", + "File|Export", + 6.0, + callback=self.export_menu_callback, + ) + + self.connect_to_ui() + + def import_menu_callback(self): + self.create_qml_item(import_dialog_qml) + + def export_menu_callback(self): + self.create_qml_item(export_dialog_qml) + + # The two methods below are called from the dialog's QML through + # helpers.pythonAsyncCallback. + + def read_layouts(self, url, existing_names=None, view_names=None): + """Validate a layout file and describe its contents for the dialog. + + existing_names: the layout names in use now, to flag name clashes. + view_names: the names of the registered views, to flag panels that + would show up blank here. Empty skips that check. + + Returns a JSON string, {"ok": true, ...} or {"ok": false, "error": ..}. + """ + path = _path_from_url(url) + try: + with open(path, "r", encoding="utf-8") as handle: + doc = json.load(handle) + except (OSError, ValueError) as err: + return json.dumps({"ok": False, "error": "Could not read the file:\n{}".format(err)}) + + if ( + not isinstance(doc, dict) + or doc.get("format") != EXPORT_FORMAT + or not isinstance(doc.get("layouts"), list) + ): + return json.dumps( + {"ok": False, "error": "The selected file is not an xSTUDIO layout export."} + ) + + taken = {n.strip().lower() for n in (existing_names or []) if isinstance(n, str)} + known = {n for n in (view_names or []) if isinstance(n, str)} + + rows = [] + layouts = [] + for node in doc["layouts"]: + if not _is_layout_node(node): + continue + views = set() + _collect_tab_views(node, views) + rows.append( + { + "key": str(len(layouts)), + "name": node["layout_name"], + "duplicate": node["layout_name"].strip().lower() in taken, + # A panel that is not registered here renders as a blank + # tab without any message, so warn about it up front. + "missing": ", ".join(sorted(views - known)) if known else "", + } + ) + layouts.append(node) + + if not rows: + return json.dumps({"ok": False, "error": "The file does not contain any usable layouts."}) + + # The layout JSON is not versioned by xSTUDIO itself, so a different + # version is worth a warning but not a refusal. + theirs = (doc.get("source") or {}).get("xstudio") or "" + ours = _xstudio_version() + warning = "" + if theirs and ours and theirs != ours: + warning = ( + "Exported from xSTUDIO {} (this is {}), the layouts may not " + "translate cleanly.".format(theirs, ours) + ) + + return json.dumps( + {"ok": True, "rows": rows, "layouts": layouts, "version_warning": warning} + ) + + def write_layouts(self, url, layouts): + """Write the selected layouts, already read out of the model by QML. + + Returns [True, message] on success, otherwise an error string. + """ + entries = [node for node in (layouts or []) if _is_layout_node(node)] + if not entries: + return "There are no valid layouts to export." + + path = _path_from_url(url) + if not path.lower().endswith(".json"): + path += ".json" + + doc = { + "format": EXPORT_FORMAT, + "version": EXPORT_VERSION, + "exported_at": datetime.datetime.now().isoformat(timespec="seconds"), + "source": {"xstudio": _xstudio_version()}, + "layouts": entries, + } + + # Write beside the target and rename, so a failed write cannot leave a + # half-written file in place of an existing one. + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as handle: + json.dump(doc, handle, indent=2) + os.replace(tmp_path, path) + except OSError as err: + try: + os.remove(tmp_path) + except OSError: + pass + return "Could not write the file:\n{}".format(err) + + return [True, "Exported {} layout(s) to {}".format(len(entries), os.path.basename(path))] + + +def create_plugin_instance(connection): + return LayoutIO(connection) diff --git a/src/plugin/python_plugins/layout_io/qml/LayoutIO.1/LayoutIODialog.qml b/src/plugin/python_plugins/layout_io/qml/LayoutIO.1/LayoutIODialog.qml new file mode 100644 index 000000000..62f514a17 --- /dev/null +++ b/src/plugin/python_plugins/layout_io/qml/LayoutIO.1/LayoutIODialog.qml @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Import / Export dialog for the panel layouts in the layout bar at the top +// right of the main window (New / Rename / Duplicate Layout). One layout +// carries its whole panel arrangement: splitters, divider positions, tab +// groups, and which view sits in each tab. +// +// One component serves both flows, chosen by the ``mode`` property that the +// plugin sets in the QML snippet ("export" or "import"). The item is created +// once and only hidden when closed, so everything is reset in start() each +// time the window is shown. +// +// This dialog does all of the access to the layouts model, Python only does +// the file IO and the validation of the file (see layout_io.py for why). A +// locally declared XsPanelsLayoutModel registers as another client of the +// same backend model, so what is written here is broadcast to the session +// window and saved to the preferences in the usual way. +// +// export: show the layouts read from the model -> save dialog -> +// write_layouts() in Python. +// import: open dialog -> read_layouts() in Python validates and flags clashes +// and unknown panels -> list the layouts -> Import applies them to +// the model. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QuickFuture 1.0 +import QuickPromise 1.0 + +import xStudio 1.0 +import xstudio.qml.models 1.0 +import "." + +XsWindow { + + id: dialog + width: 560 + height: 520 + minimumWidth: 420 + minimumHeight: 320 + + flags: Qt.platform.os === "windows" ? Qt.Window : Qt.Dialog + + // "export" or "import", set by the plugin's QML snippet. + property string mode: "export" + readonly property bool isExport: mode === "export" + + title: isExport ? "Export Layouts" : "Import Layouts" + + property int checkedCount: 0 + property int duplicateCount: 0 + property int missingCount: 0 + property string versionWarning: "" + // Full layout nodes, index-aligned with the "key" role of each row. + property var sourceNodes: [] + + // Further clients of the shared "panels model" and "view widgets" models. + XsPanelsLayoutModel { id: layoutsModel } + XsViewsModel { id: registeredViews } + + ListModel { id: rowModel } + + // ---- model reading ----------------------------------------------------- + + function childrenOf(node) { + if (!node) return [] + if (Array.isArray(node)) return node + if (node.children && Array.isArray(node.children)) return node.children + return [] + } + + // The layouts are the children of the "main_window" node. The same + // top-level array also holds the geometry of floating windows, which are + // not layouts. + function mainWindowNode() { + var windows = childrenOf(layoutsModel.modelData) + for (var i = 0; i < windows.length; i++) { + if (windows[i] && windows[i].window_name === "main_window") return windows[i] + } + return null + } + + function existingLayoutNames() { + var names = [] + var nodes = childrenOf(mainWindowNode()) + for (var i = 0; i < nodes.length; i++) { + if (nodes[i] && nodes[i].layout_name) names.push(nodes[i].layout_name) + } + return names + } + + function registeredViewNames() { + var names = [] + var nodes = childrenOf(registeredViews.modelData) + for (var i = 0; i < nodes.length; i++) { + var name = nodes[i] ? nodes[i].view_name : "" + if (name && name !== "divider" && names.indexOf(name) === -1) names.push(name) + } + return names + } + + function collectViews(node, out) { + if (!node) return + if (typeof node.tab_view === "string" && node.tab_view.length && out.indexOf(node.tab_view) === -1) { + out.push(node.tab_view) + } + var kids = childrenOf(node) + for (var i = 0; i < kids.length; i++) collectViews(kids[i], out) + } + + function describeLayout(node) { + var views = [] + collectViews(node, views) + return views.join(", ") + } + + // ---- row helpers ------------------------------------------------------- + + function recount() { + var n = 0 + for (var i = 0; i < rowRepeater.count; i++) { + var it = rowRepeater.itemAt(i) + if (it && it.rowChecked) n++ + } + checkedCount = n + } + + function setAllChecked(v) { + for (var i = 0; i < rowRepeater.count; i++) { + var it = rowRepeater.itemAt(i) + if (it) it.rowChecked = v + } + recount() + } + + function selectedNodes() { + var out = [] + for (var i = 0; i < rowRepeater.count; i++) { + var it = rowRepeater.itemAt(i) + if (!it || !it.rowChecked) continue + var node = sourceNodes[parseInt(it.rowKey)] + if (node) out.push(JSON.parse(JSON.stringify(node))) + } + return out + } + + // ---- start ------------------------------------------------------------- + + function start() { + rowModel.clear() + sourceNodes = [] + duplicateCount = 0 + missingCount = 0 + versionWarning = "" + Qt.callLater(recount) + + if (isExport) { + populateExport() + } else { + chooseImportFile() + } + } + + onVisibleChanged: { + if (visible) start() + } + + // ---- export ------------------------------------------------------------ + + function populateExport() { + var nodes = childrenOf(mainWindowNode()) + sourceNodes = nodes + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i] + if (!node || !node.layout_name) continue + rowModel.append({ + key: String(i), + name: node.layout_name, + detail: describeLayout(node), + duplicate: false, + missing: "" + }) + } + Qt.callLater(recount) + } + + function chooseExportFile() { + dialogHelpers.showFileDialog( + doExport, + "", + "Export Layouts", + "json", + ["JSON (*.json)"], + false, + false) + } + + function doExport(path) { + + if (path === false) return // cancelled + + var py_args = { + "url": "" + path, + "layouts": selectedNodes() + } + + Future.promise( + helpers.pythonAsyncCallback("LayoutIO", "write_layouts", py_args) + ).then(function(return_val) { + + if (Array.isArray(return_val) && return_val[0] === true) { + dialog.hide() + } else { + dialogHelpers.errorDialogFunc("Export Layouts", "" + return_val) + } + + }, + function(err) { + dialogHelpers.errorDialogFunc("Export Layouts", "" + err) + }) + } + + // ---- import ------------------------------------------------------------ + + function chooseImportFile() { + dialogHelpers.showFileDialog( + doReadImportFile, + "", + "Import Layouts", + "json", + ["JSON (*.json)", "All files (*)"], + true, + false) + } + + function doReadImportFile(path) { + + // Cancelled: the window stays up so "Choose file..." can try again. + if (path === false) return + + var py_args = { + "url": "" + path, + "existing_names": existingLayoutNames(), + "view_names": registeredViewNames() + } + + Future.promise( + helpers.pythonAsyncCallback("LayoutIO", "read_layouts", py_args) + ).then(function(return_val) { + showImportRows(return_val) + }, + function(err) { + dialogHelpers.errorDialogFunc("Import Layouts", "" + err) + }) + } + + function showImportRows(raw) { + + var result + try { result = JSON.parse(raw) } catch (e) { result = undefined } + + if (!result) { + dialogHelpers.errorDialogFunc("Import Layouts", "The file could not be read.") + return + } + if (!result.ok) { + dialogHelpers.errorDialogFunc("Import Layouts", result.error) + return + } + + sourceNodes = result.layouts || [] + versionWarning = result.version_warning || "" + + rowModel.clear() + var dups = 0 + var missing = 0 + var rows = result.rows || [] + for (var i = 0; i < rows.length; i++) { + var row = rows[i] + rowModel.append({ + key: row.key, + name: row.name, + detail: describeLayout(sourceNodes[parseInt(row.key)]), + duplicate: row.duplicate === true, + missing: row.missing || "" + }) + if (row.duplicate === true) dups++ + if (row.missing) missing++ + } + duplicateCount = dups + missingCount = missing + Qt.callLater(recount) + } + + // Names that clash get a suffix: nothing enforces unique layout names, and + // two tabs with the same label cannot be told apart. + function uniqueName(name, takenLower) { + var base = (name || "Layout").trim() + if (takenLower.indexOf(base.toLowerCase()) === -1) return base + for (var n = 2; n < 1000; n++) { + var candidate = base + " (" + n + ")" + if (takenLower.indexOf(candidate.toLowerCase()) === -1) return candidate + } + return base + " (" + Date.now() + ")" + } + + function applyImport() { + + var taken = existingLayoutNames().map(function (s) { return s.toLowerCase() }) + + var nodes = selectedNodes() + for (var i = 0; i < nodes.length; i++) { + + var copy = nodes[i] + copy.layout_name = uniqueName(copy.layout_name, taken) + copy.enabled = true + taken.push(copy.layout_name.toLowerCase()) + + // Look the window row up again each time, the rows below it change. + var winIdx = layoutsModel.searchRecursive("main_window", "window_name") + if (!winIdx.valid) { + console.log("LayoutIO: main_window node not found, aborting import") + break + } + + // Same as PanelsModel::duplicate_layout: append an empty row then + // replace it with the whole node. Setting "jsonRole" replaces the + // node rather than merging into it, so each layout must be a + // complete node. + var rc = layoutsModel.rowCount(winIdx) + layoutsModel.insertRowsSync(rc, 1, winIdx) + layoutsModel.set(layoutsModel.index(rc, 0, winIdx), JSON.stringify(copy), "jsonRole") + } + } + + // ---- layout ------------------------------------------------------------ + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + XsText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + font.bold: true + text: isExport ? "Select layouts to export" : "Select layouts to import" + } + + XsText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + color: XsStyleSheet.hintColor + wrapMode: Text.WordWrap + visible: rowModel.count > 0 && text !== "" + text: { + if (isExport) return "Each layout carries its whole panel arrangement." + var parts = [] + if (duplicateCount > 0) + parts.push(duplicateCount + " name(s) already in use, those are imported with a suffix.") + if (missingCount > 0) + parts.push(missingCount + " layout(s) use panels that are not available here, those tabs will be blank.") + if (versionWarning) parts.push(versionWarning) + return parts.join("\n") + } + } + + RowLayout { + Layout.fillWidth: true + visible: rowModel.count > 0 + spacing: 8 + + XsPrimaryButton { + text: "Select all" + Layout.preferredWidth: 90 + onClicked: setAllChecked(true) + } + XsPrimaryButton { + text: "Select none" + Layout.preferredWidth: 90 + onClicked: setAllChecked(false) + } + Item { Layout.fillWidth: true } + XsText { + color: XsStyleSheet.hintColor + text: checkedCount + " / " + rowModel.count + " selected" + } + } + + // A Repeater rather than a ListView so that every delegate exists and + // the checkboxes can be read back reliably. A layout list is short. + Flickable { + id: flick + Layout.fillWidth: true + Layout.fillHeight: true + visible: rowModel.count > 0 + clip: true + contentWidth: width + contentHeight: rowColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: XsScrollBar { + visible: rowColumn.implicitHeight > flick.height + } + + Column { + id: rowColumn + width: parent.width + spacing: 6 + + Repeater { + id: rowRepeater + model: rowModel + + delegate: Column { + id: rowDelegate + width: rowColumn.width + spacing: 1 + + property alias rowChecked: rowCheck.checked + property string rowKey: model.key + + RowLayout { + width: parent.width + spacing: 6 + + XsCheckBox { + id: rowCheck + checked: true + onToggled: recount() + } + XsText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + text: model.name + } + XsText { + visible: model.duplicate === true + color: XsStyleSheet.accentColor + font.bold: true + text: "name in use" + } + } + + XsText { + visible: model.detail !== "" + width: parent.width + leftPadding: 26 + horizontalAlignment: Text.AlignLeft + color: XsStyleSheet.hintColor + elide: Text.ElideRight + text: model.detail + } + + XsText { + visible: model.missing !== "" + width: parent.width + leftPadding: 26 + horizontalAlignment: Text.AlignLeft + color: XsStyleSheet.accentColor + wrapMode: Text.WordWrap + text: "missing here: " + model.missing + } + } + } + } + } + + // Nothing listed yet. + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: rowModel.count === 0 + spacing: 10 + + Item { Layout.fillHeight: true } + XsText { + Layout.alignment: Qt.AlignHCenter + color: XsStyleSheet.hintColor + text: isExport ? "No layouts to export." : "Choose a file to import." + } + XsPrimaryButton { + visible: !isExport + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 120 + text: "Choose file..." + onClicked: chooseImportFile() + } + Item { Layout.fillHeight: true } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + XsPrimaryButton { + text: "Cancel" + Layout.preferredWidth: 90 + onClicked: dialog.hide() + } + XsPrimaryButton { + text: isExport ? "Export..." : "Import" + Layout.preferredWidth: 90 + isUnClickable: checkedCount === 0 + onClicked: { + if (checkedCount === 0) return + if (isExport) { + chooseExportFile() + } else { + applyImport() + dialog.hide() + } + } + } + } + } +}