diff --git a/src/EasyApp/Gui/Components/ListView.qml b/src/EasyApp/Gui/Components/ListView.qml new file mode 100644 index 00000000..882f7f2f --- /dev/null +++ b/src/EasyApp/Gui/Components/ListView.qml @@ -0,0 +1,245 @@ +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Animations as EaAnimations +import EasyApp.Gui.Elements as EaElements +import EasyApp.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 + + // Scroll bar display mode. + // Internally, use enum type name: ScrollBarMode.Indicator, etc. + // Externally, use full module path: EaComponents.ListView.ScrollBarMode.Indicator, etc. + // Cannot use id (listView.Indicator) — enums are type-scoped, not instance-scoped. + // Cannot use ListView.Indicator — resolves to QtQuick's ListView, not this component. + enum ScrollBarMode { + Indicator, + AsNeeded, + AlwaysOn + } + property int scrollBarMode: ScrollBarMode.Indicator + + // Allow ctrl/shift multi-select. + property bool multiSelection: true + + // Column widths definition. Each entry is a width in px, or -1 to fill remaining space. + // Example: columnWidths: [40, -1, 100] + property var columnWidths: [] + + // Clear all selection and reset anchor. + function clearSelection() { + selectionModel.clearSelection() + anchorRow = -1 + } + + // ── 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 + + // 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) + readonly property var resolvedColumnWidths: { + if (!columnWidths.length) return [] + let fixed = 0, flexCount = 0 + for (let w of columnWidths) { + if (w > 0) fixed += w + else flexCount++ + } + const spacing = EaStyle.Sizes.tableColumnSpacing * (columnWidths.length - 1) + const border = EaStyle.Sizes.borderThickness * 2 + const fill = flexCount > 0 ? Math.max(0, (width - fixed - spacing - border) / flexCount) : 0 + return columnWidths.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) { + 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 + if (listView.multiSelection && modifiers & Qt.ControlModifier) { + selectionModel.select( + idx, + ItemSelectionModel.Toggle | 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 + + highlightMoveDuration: EaStyle.Sizes.tableHighlightMoveDuration + highlight: Rectangle { + z: 2 + color: mouseHoverHandler.hovered ? + EaStyle.Colors.tableHighlight : + "transparent" + Behavior on color { EaAnimations.ThemeChange {} } + } + + HoverHandler { + id: mouseHoverHandler + acceptedDevices: PointerDevice.AllDevices + blocking: false + } + + ScrollBar.vertical: EaElements.ScrollBar { + // ScrollBarMode enum not in scope inside child objects; use int values. + // AlwaysOn=2, AsNeeded=1 + policy: listView.scrollBarMode === 2 ? ScrollBar.AlwaysOn + : listView.scrollBarMode === 1 ? ScrollBar.AsNeeded + : ScrollBar.AlwaysOff + topInset: listView._headerHeight + topPadding: listView.padding + listView._headerHeight + } + + ScrollIndicator.vertical: EaElements.ScrollIndicator { + // Indicator=0 + active: listView.scrollBarMode === 0 + topInset: listView._headerHeight + topPadding: listView.padding + 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" + 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 + } + } +} diff --git a/src/EasyApp/Gui/Components/ListViewDelegate.qml b/src/EasyApp/Gui/Components/ListViewDelegate.qml new file mode 100644 index 00000000..edaa341e --- /dev/null +++ b/src/EasyApp/Gui/Components/ListViewDelegate.qml @@ -0,0 +1,94 @@ +import QtQuick + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Animations as EaAnimations + +Rectangle { + id: control + + default property alias contentRowData: contentRow.data + property Item listView: ListView.view ?? null + + implicitWidth: listView.width + implicitHeight: listView.tableRowHeight + + color: { + // Read selectedIndexes to create a binding dependency — forces + // this color expression to re-evaluate whenever the selection changes. + listView.selectedIndexes + + let selected = index >= 0 && listView.isSelected(index) + let c1 = EaStyle.Colors.themeAccentMinor + let c2 = EaStyle.Colors.themeBackgroundHovered2 + let c3 = EaStyle.Colors.themeBackgroundHovered1 + + return selected ? c1 : (index % 2 ? c2 : c3) + } + Behavior on color { EaAnimations.ThemeChange {} } + + Component.onCompleted: if (listView) listView.applyWidths(contentRow) + + Connections { + target: listView + function onResolvedColumnWidthsChanged() { listView.applyWidths(contentRow) } + } + + Row { + id: contentRow + + height: parent.height + spacing: EaStyle.Sizes.tableColumnSpacing + } + + // Anchor indicator: small triangle in top-right corner when row is + // the shift-selection anchor but not currently selected. + Item { + visible: { + // Read selectedIndexes to create binding dependency for reactivity. + listView.selectedIndexes + return index === listView.anchorRow && !listView.isSelected(index) + } + anchors.top: parent.top + anchors.right: parent.right + width: 8 + height: 8 + clip: true + layer.enabled: true + layer.smooth: false + + Rectangle { + width: parent.width * 1.5 + height: parent.height * 1.5 + rotation: 45 + x: Math.round(parent.width / 2) + y: Math.round(-parent.height * 0.75) + antialiasing: false + color: EaStyle.Colors.themeAccentMinor + Behavior on color { EaAnimations.ThemeChange {} } + } + } + + //Mouse area to react on click events + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: false + onClicked: (mouse) => { + if (index >= 0) + listView.selectWithModifiers(index, mouse.modifiers) + } + } + + // Mouse-only navigation: highlight follows hover. + // If keyboard navigation is added, guard this with a keyboardActive flag. + HoverHandler { + id: mouseHoverHandler + acceptedDevices: PointerDevice.AllDevices + cursorShape: Qt.PointingHandCursor + blocking: false + onHoveredChanged: { + if (hovered && index >= 0) + listView.currentIndex = index + } + } +} diff --git a/src/EasyApp/Gui/Components/ListViewHeader.qml b/src/EasyApp/Gui/Components/ListViewHeader.qml new file mode 100644 index 00000000..adb5f75d --- /dev/null +++ b/src/EasyApp/Gui/Components/ListViewHeader.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Animations as EaAnimations + +Rectangle { + id: listViewHeader + default property alias contentRowData: contentRow.data + property Item listView: ListView.view ?? null + + 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 + + height: parent.height + spacing: EaStyle.Sizes.tableColumnSpacing + } +} diff --git a/src/EasyApp/Gui/Components/qmldir b/src/EasyApp/Gui/Components/qmldir index 55a7dd24..576f2a8f 100644 --- a/src/EasyApp/Gui/Components/qmldir +++ b/src/EasyApp/Gui/Components/qmldir @@ -21,6 +21,9 @@ 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 TableView 1.0 TableView.qml TableViewHeader 1.0 TableViewHeader.qml TableViewDelegate 1.0 TableViewDelegate.qml