Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f92bf11
added a draft version of a new selectable table view
seventil Mar 20, 2026
50f4f48
Removed delegate to a separate file
seventil Mar 22, 2026
a947f3d
delegate not working fix
seventil Mar 23, 2026
27240b0
removed the initialized delegate from NewTableView component
seventil Mar 23, 2026
8c86671
changes to the scrollbar
seventil Mar 24, 2026
fb10fa2
updating scrollbar in the tableview
seventil Mar 24, 2026
0448146
Merge branch 'develop' into selectable_table_view
seventil Mar 24, 2026
fd20025
removed the color safeguards in the tableview
seventil Mar 24, 2026
3fcb201
removed antialiasing in tableview as there is another pr for that
seventil Mar 24, 2026
e15579d
make scrollbar and scroll indicator optional in ListView via vertical…
seventil Mar 27, 2026
6877173
Added a flag to enable/disable multiselection
seventil Mar 27, 2026
aca8418
Added a 0.5 row length to listview Height to visually indicate that t…
seventil Mar 27, 2026
a24d708
Cleaned the interface of the delegate to use listView property
seventil Mar 27, 2026
9570598
Changed the calculation of height
seventil Apr 9, 2026
a8e2734
Changed the column width calculation
seventil Apr 9, 2026
0b24b4d
added ListViewHeader to qmldir for imports
seventil Apr 9, 2026
963648d
listview cleanup
seventil Apr 9, 2026
0c24d0f
Fixing vibecoding issues
seventil Apr 9, 2026
bbbedfd
Refactored listview to not be encapsulated in item, resolved minor is…
seventil Apr 10, 2026
7ddaa63
Namspace clash fix
seventil Apr 10, 2026
a98f69c
Refactor ListView: separate public/companion/internal API, derive hea…
seventil Apr 13, 2026
844d107
Made shift+click additive, instead of a new selection
seventil Apr 13, 2026
1902300
added a small angle cap to indicate deselected anchor
seventil Apr 13, 2026
0360a0f
Fixed enum error in ListView that blocked depicting the scrollbar/ind…
seventil Apr 13, 2026
b6dbf9f
Vibecoding cleanup
seventil Apr 13, 2026
e3d6360
scope-aware selection visuals via focus tracking
seventil Apr 14, 2026
6faaf85
changed the default of selectionActive to true
seventil Apr 14, 2026
4fbf861
Fix the mousearea intercepting clicks completely
seventil Apr 16, 2026
d4c74c6
Added padding to left and right on delegate row of ListViewDelegate
seventil Apr 16, 2026
2c51f26
Changed the selection coloring so that it would disappear while editi…
seventil Apr 17, 2026
7cf00f0
Fixing the hover change forcing editing finished on ListView cells
seventil Apr 17, 2026
33757dc
Moved hover highlight from listview to delegate
seventil Apr 20, 2026
b4cc4b5
guard anchor reset on adding/removing from model
seventil Apr 20, 2026
29dd95b
Merge branch 'develop' into selectable_table_view
seventil Apr 20, 2026
53a9861
Got rid of indicator alltogether to use scrollbar with an interactive…
seventil Apr 20, 2026
91bf25e
Added hover highlight on editing and the color flash from TextInput
seventil Apr 21, 2026
0d33f56
Changed light-themed selection to be more readable with selected text…
seventil Apr 21, 2026
2188850
adjusted colors for dark mode selection, trying to fix selection/edit…
seventil Apr 21, 2026
4d29e6e
another dark theme adjustment for selection colors
seventil Apr 21, 2026
6167a3e
Fix for mixed up selections upon TextInput editing
seventil Apr 22, 2026
52ef34e
Merge branch 'develop' into selectable_table_view
seventil Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions src/EasyApp/Gui/Components/ListView.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
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

// 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

// Allow ctrl/shift multi-select.
property bool multiSelection: true

// Claim the enclosing FocusScope's default focus target.
focus: true

// Drives whether the delegate renders selection/anchor visuals.
// Default: always true. When hosted inside a FocusScope
// with focusable siblings (buttons, filters), override with the scope's
// activeFocus so visuals stay lit while focus is elsewhere in the panel,
// e.g. `selectionActive: myScope.activeFocus`. Or use this list's own focus,
// e.g. `selectionActive: activeFocus`.
// Used by: ListViewDelegate.
property bool selectionActive: 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: []

// Horizontal padding inside each row (header + delegate). Subtracted from
// flex-column budget so -1 columns don't overflow the row.
property real rowPadding: EaStyle.Sizes.tableColumnSpacing

// 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
onCountChanged: if (anchorRow >= count) anchorRow = -1

// Row index currently under the mouse. Visual-only. Never mutated from
// keyboard or selection paths — stays orthogonal to currentIndex and
// selectionModel so inline editors don't lose activeFocus on hover.
// Used by: ListViewDelegate (hover tint in row color binding)
property int hoveredIndex: -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 - rowPadding * 2) / 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

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
}
}
}
159 changes: 159 additions & 0 deletions src/EasyApp/Gui/Components/ListViewDelegate.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import QtQuick

import EasyApp.Gui.Style as EaStyle
import EasyApp.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.selectWithModifiers(index, Qt.NoModifier)
}
}

// If this row leaves the selection while its inline editor still owns
// focus (e.g. user tapped another row's background, and listView's
// forceActiveFocus didn't pull focus out of this row's FocusScope),
// release it locally so the editor visuals and activeFocus drop.
onInSelectionChanged: {
if (!inSelection && editing) {
editScope.focus = false
}
}

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: (listView && listView.hoveredIndex === index) || 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

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
// Any tap that reaches this handler lands on the row background
// (not an inline editor — T.TextField's grab swallows onTapped
// for clicks on the editor itself). So claiming focus here ends
// any in-progress inline edit in this or another row.
//if (!editing) listView.forceActiveFocus()
listView.forceActiveFocus()
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
onHoveredChanged: {
if (index < 0) return
if (hovered)
listView.hoveredIndex = index
else if (listView.hoveredIndex === index)
listView.hoveredIndex = -1
}
}
}
Loading