« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--todo.txt9
-rw-r--r--ui.js162
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)
   }