From 3233c5ba82b89a3aae68081dd8cf9f8fa4282b60 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 14:32:39 -0300 Subject: rework mark/selection system No more issues with duplicate tracks, and way more power to the user regardless of the interface they use or their experience with the mark/selection system! --- todo.txt | 9 ++++ ui.js | 162 ++++++++++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 134 insertions(+), 37 deletions(-) diff --git a/todo.txt b/todo.txt index 1f7de4d..75de2f2 100644 --- a/todo.txt +++ b/todo.txt @@ -326,6 +326,14 @@ TODO: Figure out duplicates in the selection system! Right now, it's possible but it doesn't seem worth it - better to keep them separate and let us explicitly decide when we do or don't want to consider duplicates.) + (Done! But sort of in the reverse direction: you can't select groups + themselves anymore; whether they display as selected is based upon if + all their child tracks are selected. Accordingly, groups can also show + as "partial" selections, if only some of the tracks are selected. + Might be worth revisiting, but I think what I've got implemented is + easier to wrap your head around than this stuff, however cool the ideas + here probably are.) + TODO: Default to 'after selected track' in context menu, and make pressing Q (the shorthand for queuing the selection) act as though that's the selected option, instead of queuing at the end of the queue (which is @@ -481,6 +489,7 @@ TODO: Expand selection context menu by pressing the heading button! It should TODO: Opening the selection contxt menu should show an option to either add or remove the cursor-focused item from the selection - this would make selection accessible when a keyboard or the shift key is inaccessible. + (Done!) TODO: Integrate the rest of the stuff that handles argv into parseOptions. (Done!) diff --git a/ui.js b/ui.js index d948208..c734a74 100644 --- a/ui.js +++ b/ui.js @@ -204,6 +204,7 @@ class AppElement extends FocusElement { // TODO: Move edit mode stuff to the backend! this.undoManager = new UndoManager() this.markGrouplike = {name: 'Selected Items', items: []} + this.cachedMarkStatuses = new Map() this.editMode = false // We add this is a child later (so that it's on top of every element). @@ -846,8 +847,73 @@ class AppElement extends FocusElement { this.queueListingElement.selectAndShow(item) } - deselectAll() { - this.markGrouplike.items.splice(0) + replaceMark(items) { + this.markGrouplike.items = items.slice(0) // Don't share the array! :) + this.emitMarkChanged() + } + + unmarkAll() { + this.markGrouplike.items = [] + this.emitMarkChanged() + } + + markItem(item) { + if (isGroup(item)) { + for (const child of item.items) { + this.markItem(child) + } + } else { + const { items } = this.markGrouplike + if (!items.includes(item)) { + items.push(item) + this.emitMarkChanged() + } + } + } + + unmarkItem(item) { + if (isGroup(item)) { + for (const child of item.items) { + this.unmarkItem(child) + } + } else { + const { items } = this.markGrouplike + if (items.includes(item)) { + items.splice(items.indexOf(item), 1) + this.emitMarkChanged() + } + } + } + + getMarkStatus(item) { + if (!this.cachedMarkStatuses.get(item)) { + const { items } = this.markGrouplike + let status + if (isGroup(item)) { + const tracks = flattenGrouplike(item).items + if (tracks.every(track => items.includes(track))) { + status = 'marked' + } else if (tracks.some(track => items.includes(track))) { + status = 'partial' + } else { + status = 'unmarked' + } + } else { + if (items.includes(item)) { + status = 'marked' + } else { + status = 'unmarked' + } + } + this.cachedMarkStatuses.set(item, status) + } + return this.cachedMarkStatuses.get(item) + } + + emitMarkChanged() { + this.emit('mark changed') + this.cachedMarkStatuses = new Map() + this.scheduleDrawWithoutPropertyChange() } pauseAll() { @@ -1080,7 +1146,12 @@ class AppElement extends FocusElement { canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, {divider: true}, - item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()} + ...(item === this.markGrouplike + ? [{label: 'Deselect all', action: () => this.unmarkAll()}] + : [ + this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, + this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + ]) ] } } @@ -1802,7 +1873,7 @@ class GrouplikeListingElement extends Form { */ } } else if (keyBuf[0] === 1) { // ctrl-A - this.toggleSelectAll() + this.toggleMarkAll() } else { return super.keyPressed(keyBuf) } @@ -1843,14 +1914,34 @@ class GrouplikeListingElement extends Form { this.form.scrollItems = 0 } - toggleSelectAll() { + toggleMarkAll() { const { items } = this.grouplike - if (items.every(item => this.app.markGrouplike.items.includes(item))) { - this.app.markGrouplike.items = [] + const actions = [] + const tracks = flattenGrouplike(this.grouplike).items + if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) { + if (this.app.markGrouplike.items.length > tracks.length) { + actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)}) + } + actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()}) } else { - this.app.markGrouplike.items = items.slice(0) // Don't share the array! :) + actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)}) + if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) { + actions.push({label: 'Replace selection', action: () => { + this.app.unmarkAll() + this.app.markItem(this.grouplike) + }}) + } + } + if (actions.length === 1) { + actions[0].action() + } else { + const el = this.form.inputs[this.form.curIndex] + this.app.showContextMenu({ + x: el.absLeft, + y: el.absTop + 1, + items: actions + }) } - this.scheduleDrawWithoutPropertyChange() } /* @@ -2153,13 +2244,13 @@ class GrouplikeListingForm extends ListScrollForm { return } const { item } = input - if (this.app.markGrouplike.items.includes(item)) { - this.selectMode = 'deselect' - } else { + if (this.app.getMarkStatus(item) === 'unmarked') { if (!ctrl) { - this.app.markGrouplike.items = [] + this.app.unmarkAll() } this.selectMode = 'select' + } else { + this.selectMode = 'deselect' } if (ctrl) { this.dragInputs = [item] @@ -2196,27 +2287,22 @@ class GrouplikeListingForm extends ListScrollForm { } dragEnteredRange(item) { - const { items } = this.app.markGrouplike if (this.selectMode === 'select') { - if (!items.includes(item)) { - items.push(item) - } + this.app.markItem(item) } else if (this.selectMode === 'deselect') { - if (items.includes(item)) { - items.splice(items.indexOf(item), 1) - } + this.app.unmarkItem(item) } } dragLeftRange(item) { const { items } = this.app.markGrouplike if (this.selectMode === 'select') { - if (items.includes(item) && !this.oldMarkedItems.includes(item)) { - items.splice(items.indexOf(item), 1) + if (!this.oldMarkedItems.includes(item)) { + this.app.unmarkItem(item) } } else if (this.selectMode === 'deselect') { - if (!items.includes(item) && this.oldMarkedItems.includes(item)) { - items.push(item) + if (this.oldMarkedItems.includes(item)) { + this.app.markItem(item) } } } @@ -2255,11 +2341,13 @@ class GrouplikeListingForm extends ListScrollForm { return } this.keyboardDragDirection = direction - this.oldMarkedItems = this.app.markGrouplike.items.slice() - if (this.app.markGrouplike.items.includes(item)) { - this.selectMode = 'deselect' - } else { + this.oldMarkedItems = (this.inputs + .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked') + .map(input => input.item)) + if (this.app.getMarkStatus(item) === 'unmarked') { this.selectMode = 'select' + } else { + this.selectMode = 'deselect' } this.dragEnteredRange(item) } @@ -2972,21 +3060,23 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } writeStatus(writable) { + const markStatus = this.app.getMarkStatus(this.item) + if (this.isGroup) { // The ANSI attributes here will apply to the rest of the line, too. // (We don't reset the active attributes until after drawing the rest of // the line.) - if (this.isMarked) { + if (markStatus === 'marked' || markStatus === 'partial') { writable.write(ansi.setAttributes([ansi.C_BLUE + 10])) } else { writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT])) } } else if (this.isTrack) { - if (this.isMarked) { + if (markStatus === 'marked') { writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT])) } } else if (!this.isPlayable) { - if (this.isMarked) { + if (markStatus === 'marked') { writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT])) } else { writable.write(ansi.setAttributes([ansi.A_DIM])) @@ -3000,8 +3090,10 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { const record = this.app.backend.getRecordFor(this.item) - if (this.isMarked) { - writable.write('>') + if (markStatus === 'marked') { + writable.write('+') + } else if (markStatus === 'partial') { + writable.write('*') } else { writable.write(' ') } @@ -3021,10 +3113,6 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writable.write(' ') } - get isMarked() { - return this.app.markGrouplike.items.includes(this.item) - } - get isGroup() { return isGroup(this.item) } -- cgit 1.3.0-6-gf8a5