diff --git a/.gitignore b/.gitignore index 6dc595c7..8cb8dfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ node_modules/ *.pyproject.user *.pyproject.user.* CMakeLists.txt.user* +*settings.ini # PyCharm .idea/ diff --git a/src/EasyApplication/Gui/Components/ListView.qml b/src/EasyApplication/Gui/Components/ListView.qml new file mode 100644 index 00000000..e4b4ec2d --- /dev/null +++ b/src/EasyApplication/Gui/Components/ListView.qml @@ -0,0 +1,289 @@ +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Globals as EaGlobals +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Animations as EaAnimations +import EasyApplication.Gui.Elements as EaElements +import EasyApplication.Gui.Components as EaComponents + +ListView { + id: listView + + // ── Public API ────────────────────────────────────────────────────── + // Properties and functions for consumers instantiating this component. + + width: EaStyle.Sizes.sideBarContentWidth + + // When true, rows use 1.5x height. + property bool tallRows: false + + // Max visible rows before scrolling kicks in. + property int maxRowCountShow: EaStyle.Sizes.tableMaxRowCountShow + + // Text shown when ListView model is empty. + property alias defaultInfoText: defaultInfoLabel.text + + // ScrollBar.AsNeeded / ScrollBar.AlwaysOff / ScrollBar.AlwaysOn + property int scrollBarPolicy: ScrollBar.AsNeeded + + // false = indicator style: thin, non-draggable, shows only while scrolling + property bool scrollBarInteractive: true + + // When false, clicks never modify the selection model. Use for lists + // with inline editors but no row-level selection concept. + property bool selectable: true + + // Allow ctrl/shift multi-select. + property bool multiSelection: true + + // When false, clicking a cell editor (TextInput) does not select the row. + // Editing and selection remain orthogonal. + property bool selectOnEdit: false + + // Claim the enclosing FocusScope's default focus target. + focus: true + + // Whether the row highlight stays lit. Default true (always). Bind to a + // focus expression (e.g. `myScope.activeFocus`) to dim when focus leaves + // that scope. + property bool selectionActive: true + + // Column widths definition. Each entry is one of: + // positive int — fixed width in pixels. + // EaStyle.Sizes.tableColumnFlex (-1) — fill remaining row width. + // Multiple flex columns split the leftover space evenly. + // EaStyle.Sizes.tableColumnAuto (0) — fit the header label's implicit + // text width plus autoColumnPadding. Requires a header + // delegate (ListViewHeader) so the resolver has a label + // to measure; falls back to 0 if header not yet ready. + // Example: columnWidths: [40, EaStyle.Sizes.tableColumnFlex, EaStyle.Sizes.tableColumnAuto, 100] + property var columnWidths: [] + + // Padding added to each tableColumnAuto column on top of the measured + // header text width. Defaults to 2x tableColumnSpacing — same breathing + // room as a typical hand-tuned column. + property real autoColumnPadding: EaStyle.Sizes.tableColumnSpacing * 2 + + // Horizontal padding inside each row (header + delegate). Subtracted from + // flex-column budget so flex columns don't overflow the row. + property real rowPadding: EaStyle.Sizes.tableColumnSpacing + + // Clear all selection and reset anchor. + function clearSelection() { + selectionModel.clearSelection() + anchorRow = -1 + } + + // Release any cell editor that currently owns focus inside the list. + // Forces focus to a non-FocusScope sibling so the editScope's inner + // focus chain is broken at the listView level — `forceActiveFocus()` + // on listView itself would re-confirm the existing chain instead. + function endEditing() { + focusSink.forceActiveFocus() + } + + // ── Companion API ─────────────────────────────────────────────────── + // Used by ListViewHeader and ListViewDelegate. Not intended for direct consumer use. + + // Anchor row index for shift-selection range tracking. + // Used by: ListViewDelegate (anchor indicator when row is not selected) + property int anchorRow: -1 + onCountChanged: if (anchorRow >= count) anchorRow = -1 + + // Row height in px, derived from tallRows. + // Used by: ListViewDelegate (implicitHeight), ListViewHeader (own height) + property int tableRowHeight: tallRows ? + 1.5 * EaStyle.Sizes.tableRowHeight : + EaStyle.Sizes.tableRowHeight + + // Current selection state. + // Used by: ListViewDelegate (binding dependency for row color) + readonly property var selectedIndexes: selectionModel.selectedIndexes + + // Computed px widths from columnWidths. + // Used by: ListViewHeader + ListViewDelegate (subscribe via onResolvedColumnWidthsChanged) + // + // Two-pass resolution: + // 1. tableColumnAuto (0) → header child implicitWidth + autoColumnPadding. + // 2. tableColumnFlex (-1) → split leftover row width evenly. + readonly property var resolvedColumnWidths: { + if (!columnWidths.length) return [] + + // Pass 1: resolve auto columns from header implicit widths. + const headerWidths = (headerItem && headerItem.implicitColumnWidths) || [] + let widths = columnWidths.map((w, i) => { + if (w === EaStyle.Sizes.tableColumnAuto && i < headerWidths.length) + return headerWidths[i] + autoColumnPadding + return w + }) + + // Pass 2: distribute remainder among flex columns. + let fixed = 0, flexCount = 0 + for (let w of widths) { + if (w > 0) fixed += w + else flexCount++ + } + const spacing = EaStyle.Sizes.tableColumnSpacing * (widths.length - 1) + const border = EaStyle.Sizes.borderThickness * 2 + const fill = flexCount > 0 ? Math.max(0, (width - fixed - spacing - border - rowPadding * 2) / flexCount) : 0 + return widths.map(w => w > 0 ? w : fill) + } + + // Apply resolvedColumnWidths to children of a Row item. + // Used by: ListViewHeader + ListViewDelegate (onCompleted + onResolvedColumnWidthsChanged) + function applyWidths(row) { + for (let i = 0; i < row.children.length && i < resolvedColumnWidths.length; i++) + row.children[i].width = resolvedColumnWidths[i] + } + + // Check if given row index is selected. + // Used by: ListViewDelegate (row background color) + function isSelected(row) { + let idx = _index(row) + return idx && idx.valid ? selectionModel.isSelected(idx) : false + } + + // Select row with ctrl/shift modifier logic. + // Used by: ListViewDelegate (MouseArea.onClicked) + function selectWithModifiers(row, modifiers) { + if (!selectable) return + let idx = _index(row) + if (!idx) return + + // SHIFT: range selection + if (listView.multiSelection && modifiers & Qt.ShiftModifier) { + if (anchorRow < 0) { + anchorRow = row + } + + let savedAnchor = anchorRow + let from = Math.min(anchorRow, row) + let to = Math.max(anchorRow, row) + + if (!(modifiers & Qt.ControlModifier)) { + selectionModel.clearSelection() + } + + for (let i = from; i <= to; i++) { + let rIdx = _index(i) + if (rIdx) { + selectionModel.select( + rIdx, + ItemSelectionModel.Select | ItemSelectionModel.Rows + ) + } + } + + anchorRow = savedAnchor + return + } + + // CTRL: toggle. Multi mode: add/remove from existing selection. + // Single mode: deselect same row, or replace selection with new row. + if (modifiers & Qt.ControlModifier) { + if (listView.multiSelection) { + selectionModel.select(idx, ItemSelectionModel.Toggle | ItemSelectionModel.Rows) + anchorRow = row + return + } + if (selectionModel.isSelected(idx)) { + selectionModel.clearSelection() + anchorRow = -1 + } else { + selectionModel.select(idx, ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows) + anchorRow = row + } + return + } + + // DEFAULT: single selection + selectionModel.select( + idx, + ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows + ) + anchorRow = row + } + + // ── Internals ─────────────────────────────────────────────────────── + + // Convert row int to QModelIndex for selectionModel. + function _index(row) { + if (!selectionModel.model || row < 0 || row >= count) + return null + return selectionModel.model.index(row, 0) + } + + // Fixes clicks not registering right after scroll. + pressDelay: 10 + + property bool hasMoreRows: count > maxRowCountShow + property real visibleRowCount: hasMoreRows ? maxRowCountShow + 0.5 : count + // headerItem is non-null when a header delegate is set (e.g. ListViewHeader). + // Uses actual headerItem.height so custom headers with different heights work. + property real _headerHeight: headerItem ? headerItem.height : 0 + height: count === 0 + ? 2 * EaStyle.Sizes.tableRowHeight + : tableRowHeight * visibleRowCount + _headerHeight + + clip: true + headerPositioning: ListView.OverlayHeader + boundsBehavior: Flickable.StopAtBounds + enabled: count > 0 + + ScrollBar.vertical: EaElements.ScrollBar { + policy: listView.scrollBarPolicy + interactive: listView.scrollBarInteractive + topInset: listView._headerHeight + topPadding: listView._headerHeight + } + + // Empty-state label. + Rectangle { + parent: listView + visible: listView.count === 0 + width: listView.width + height: EaStyle.Sizes.tableRowHeight * 2 + color: EaStyle.Colors.themeBackground + + Behavior on color { EaAnimations.ThemeChange {} } + + EaElements.Label { + id: defaultInfoLabel + + anchors.verticalCenter: parent.verticalCenter + leftPadding: EaStyle.Sizes.fontPixelSize + } + } + + // Table border, z above all content. + Rectangle { + parent: listView + z: 4 + anchors.fill: parent + color: "transparent" + // Fixes disappearing border lines + antialiasing: true + border.color: EaStyle.Colors.appBarComboBoxBorder + Behavior on border.color { EaAnimations.ThemeChange {} } + } + + ItemSelectionModel { + id: selectionModel + model: listView.model + + onSelectionChanged: { + if (selectedIndexes.length === 0) + listView.anchorRow = -1 + } + } + + // Sink target for endEditing(). Non-FocusScope child of listView, so + // forceActiveFocus on it rewrites listView.focusedChild and breaks any + // delegate's editScope chain — which forceActiveFocus on listView itself + // cannot do (re-confirms the existing chain instead). + Item { + id: focusSink + parent: listView + } +} diff --git a/src/EasyApplication/Gui/Components/ListViewDelegate.qml b/src/EasyApplication/Gui/Components/ListViewDelegate.qml new file mode 100644 index 00000000..2b263019 --- /dev/null +++ b/src/EasyApplication/Gui/Components/ListViewDelegate.qml @@ -0,0 +1,141 @@ +import QtQuick + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Animations as EaAnimations + +Rectangle { + id: control + + default property alias contentRowData: contentRow.data + // Needs to be instantiated inside of a EaComponents.ListView, won't work otherwise + property Item listView: ListView.view + + // True while any focusable cell inside the row (typically a TextInput) + // owns activeFocus. Aggregated by the FocusScope wrapping contentRow. + // The delegate also factors this into its own selection visuals so + // inline editing isn't drawn over the accent row background. + readonly property alias editing: editScope.activeFocus + + // Row is in the selection model. Reads selectedIndexes to create a + // binding dependency so this re-evaluates when selection changes + // (isSelected() alone isn't tracked by QML). Used for the left accent + // bar and the hover overlay color — both stay selection-aware even + // while an inline editor owns focus. + readonly property bool inSelection: { + listView.selectedIndexes + return index >= 0 + && listView.isSelected(index) + && listView.selectionActive + } + + // Selection for the base row fill. Suppressed during editing so the + // editor isn't drawn over the highlight color. + readonly property bool selected: inSelection && !editing + + implicitWidth: listView.width + implicitHeight: listView.tableRowHeight + + color: { + let selectedColor = EaStyle.Colors.themeRowHighlight + let evenRowColor = EaStyle.Colors.themeBackgroundHovered2 + let oddRowColor = EaStyle.Colors.themeBackgroundHovered1 + let alternatingColor = index % 2 ? evenRowColor : oddRowColor + + return control.selected ? selectedColor : alternatingColor + } + Behavior on color { EaAnimations.ThemeChange {} } + + // Vertical accent bar on the left edge. Dual-purpose: + // - inSelection → solid themeAccent (selection indicator, persists + // during inline editing so the selected row stays identifiable). + // - shift-selection anchor row (not selected, not editing) + // → themeAccentMinor (replaces the former top-right triangle). + // z:2 keeps the bar above the hover overlay (z:1) so selection + // remains visible while hovering. + Rectangle { + z: 2 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: EaStyle.Colors.themeAccent + visible: { + listView.selectedIndexes + if (control.inSelection) return true + return listView.selectionActive + && index === listView.anchorRow + && !editing + } + Behavior on color { EaAnimations.ThemeChange {} } + } + + Component.onCompleted: if (listView) listView.applyWidths(contentRow) + + // A cell editor (e.g. ListViewTextInput) claiming activeFocus flips + // `editing` true. Mirror that into the row selection so the edited + // row is also the selected row. + onEditingChanged: { + if (editing && index >= 0 && !control.inSelection && listView.selectOnEdit) { + listView.selectWithModifiers(index, Qt.NoModifier) + } + } + + Connections { + target: listView + function onResolvedColumnWidthsChanged() { listView.applyWidths(contentRow) } + } + + // Hover tint. Lives in the delegate so position is implicit from the + // delegate's own bounds — no y math, no uniform-row-height assumption. + Rectangle { + anchors.fill: parent + color: control.inSelection && !editing + ? EaStyle.Colors.themeRowHighlightHovered + : EaStyle.Colors.themeRowHovered + opacity: mouseHoverHandler.hovered || editing ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: EaStyle.Sizes.tableHighlightMoveDuration } } + Behavior on color { EaAnimations.ThemeChange {} } + } + + FocusScope { + id: editScope + anchors.fill: parent + + Row { + id: contentRow + + anchors.horizontalCenter: parent.horizontalCenter + height: parent.height + spacing: EaStyle.Sizes.tableColumnSpacing + leftPadding: listView ? listView.rowPadding : 0 + rightPadding: listView ? listView.rowPadding : 0 + } + } + + // TapHandler (not MouseArea) so nested interactive children like + // TableViewButton receive their own press events — MouseArea's + // exclusive grab on press would swallow clicks on those buttons. + TapHandler { + id: tap + onTapped: { + if (index < 0) return + listView.currentIndex = index + // Tap lands on the row background (cell editors swallow their + // own press). Release any in-progress edit before updating + // selection so editor visuals drop on row-background clicks. + listView.endEditing() + listView.selectWithModifiers(index, tap.point.modifiers) + } + } + + // Visual-only hover tracking. Writes to listView.hoveredIndex, never + // currentIndex or selectionModel — keeping those independent prevents + // hover from stealing activeFocus from inline editors (e.g. TextInput) + // in a different row during editing. + HoverHandler { + id: mouseHoverHandler + acceptedDevices: PointerDevice.AllDevices + cursorShape: Qt.PointingHandCursor + blocking: false + } +} diff --git a/src/EasyApplication/Gui/Components/ListViewHeader.qml b/src/EasyApplication/Gui/Components/ListViewHeader.qml new file mode 100644 index 00000000..a28cd059 --- /dev/null +++ b/src/EasyApplication/Gui/Components/ListViewHeader.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Animations as EaAnimations + +Rectangle { + id: listViewHeader + default property alias contentRowData: contentRow.data + property Item listView: ListView.view ?? null + + // Per-cell implicit widths derived from header children. ListView's + // resolver reads this for columns marked tableColumnAuto (0). Re-fires + // when any child's implicitWidth changes (font, locale, text update). + readonly property var implicitColumnWidths: { + let arr = [] + for (let i = 0; i < contentRow.children.length; i++) + arr.push(Math.ceil(contentRow.children[i].implicitWidth)) + return arr + } + + z: 3 // To display header above delegate and highlighted area + + implicitWidth: parent === null ? 0 : parent.width + implicitHeight: listView ? listView.tableRowHeight : 0 + + color: EaStyle.Colors.contentBackground + Behavior on color { EaAnimations.ThemeChange {} } + + Component.onCompleted: if (listView) listView.applyWidths(contentRow) + + Connections { + target: listView + function onResolvedColumnWidthsChanged() { listView.applyWidths(contentRow) } + } + + Row { + id: contentRow + + anchors.horizontalCenter: parent.horizontalCenter + height: parent.height + spacing: EaStyle.Sizes.tableColumnSpacing + leftPadding: listView ? listView.rowPadding : 0 + rightPadding: listView ? listView.rowPadding : 0 + } + + // Header sits above delegate 0 (OverlayHeader). Without an input + // handler here, clicks fall through to that delegate's MouseArea, + // bypassing the ListView-level TapHandler. This claims the press + // so header clicks transfer focus to the list. + MouseArea { + anchors.fill: parent + onClicked: { + if (!listView) return + listView.endEditing() + listView.clearSelection() + } + } +} diff --git a/src/EasyApplication/Gui/Components/ListViewTextInput.qml b/src/EasyApplication/Gui/Components/ListViewTextInput.qml new file mode 100644 index 00000000..03055131 --- /dev/null +++ b/src/EasyApplication/Gui/Components/ListViewTextInput.qml @@ -0,0 +1,39 @@ +import QtQuick + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Animations as EaAnimations +import EasyApplication.Gui.Elements as EaElements + +EaElements.TextInput { + id: control + + property string headerText: "" + + height: parent.height + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + hoverEnabled: true + + // ListView has its own row selection, so we don't need the TableView-style + // "highlight the last-edited cell" behaviour — hence this separate component + // with a color override. Track activeFocus (real keyboard focus) rather than + // the per-FocusScope `focus` flag used in TextInput.qml, so sibling + // ListViewDelegates don't stay blue after editing ends. + color: enterFlash ? + EaStyle.Colors.themeForeground : + warned ? + EaStyle.Colors.red : + !enabled || readOnly || minored ? + EaStyle.Colors.themeForegroundMinor : + activeFocus || selected || hovered ? + EaStyle.Colors.themeForegroundHovered : + EaStyle.Colors.themeForeground + Behavior on color { EaAnimations.ThemeChange {} } + + Keys.onEscapePressed: (event) => { + focus = false + event.accepted = true + } + + onActiveFocusChanged: if (!activeFocus) cursorPosition = 0 +} diff --git a/src/EasyApplication/Gui/Components/qmldir b/src/EasyApplication/Gui/Components/qmldir index 55a7dd24..b2efd937 100644 --- a/src/EasyApplication/Gui/Components/qmldir +++ b/src/EasyApplication/Gui/Components/qmldir @@ -21,6 +21,10 @@ SideBarColumn 1.0 SideBarColumn.qml PreferencesDialog 1.0 PreferencesDialog.qml ProjectDescriptionDialog 1.0 ProjectDescriptionDialog.qml +ListView 1.0 ListView.qml +ListViewDelegate 1.0 ListViewDelegate.qml +ListViewHeader 1.0 ListViewHeader.qml +ListViewTextInput 1.0 ListViewTextInput.qml TableView 1.0 TableView.qml TableViewHeader 1.0 TableViewHeader.qml TableViewDelegate 1.0 TableViewDelegate.qml diff --git a/src/EasyApplication/Gui/Style/Colors.qml b/src/EasyApplication/Gui/Style/Colors.qml index 294ce144..e2f66f33 100644 --- a/src/EasyApplication/Gui/Style/Colors.qml +++ b/src/EasyApplication/Gui/Style/Colors.qml @@ -48,16 +48,16 @@ QtObject { property color themeBackgroundHovered1: isDarkPalette ? "#353535" : "#fefefe" property color themeBackgroundHovered2: isDarkPalette ? "#3a3a3a" : "#f7f7f7" - property color themeRowHovered: isDarkPalette ? "#394247" : "#E6F5FC" - property color themeRowHighlight: isDarkPalette ? "#3A484F" : "#DCF0FA" - property color themeRowHighlightHovered: isDarkPalette ? "#3E5059" : "#C9E6F5" - property color themeForeground: isDarkPalette ? "#eee" : "#333" property color themeForegroundMinor: isDarkPalette ? "#888" : "#aaa" property color themeForegroundDisabled: isDarkPalette ? "#666": "#bbb" // control.Material.hintTextColor property color themeForegroundHovered: themeAccent property color themeForegroundHighlight: isDarkPalette ? '#FFAB91' : '#FF5722' + property color themeRowHovered: isDarkPalette ? "#394247" : "#E6F5FC" + property color themeRowHighlight: isDarkPalette ? "#3A484F" : "#DCF0FA" + property color themeRowHighlightHovered: isDarkPalette ? "#3E5059" : "#C9E6F5" + // Application window property color appBorder: isDarkPalette ? "#292929" : "#ddd" diff --git a/src/EasyApplication/Gui/Style/Sizes.qml b/src/EasyApplication/Gui/Style/Sizes.qml index 8ae3299b..a6fcf9a1 100644 --- a/src/EasyApplication/Gui/Style/Sizes.qml +++ b/src/EasyApplication/Gui/Style/Sizes.qml @@ -42,6 +42,16 @@ QtObject { property int tableMaxRowCountShow: 5 property int tableHighlightMoveDuration: 100 + // ListView columnWidths sentinels. + // Use as entries in ListView.columnWidths instead of literal magic numbers. + // tableColumnAuto (0) — fit header label's text width plus autoColumnPadding. + // Resolved by ListView from ListViewHeader child implicitWidth. + // Falls back to tableColumnFlex behaviour if no header is set. + // tableColumnFlex (-1) — fill remaining row width. Multiple flex columns split evenly. + // Positive values are taken as fixed pixel widths. + readonly property int tableColumnAuto: 0 + readonly property int tableColumnFlex: -1 + // Status bar property int statusBarHeight: Math.round(fontPixelSize * 2.5) property int statusBarSpacing: fontPixelSize