« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui.js')
-rw-r--r--ui.js220
1 files changed, 165 insertions, 55 deletions
diff --git a/ui.js b/ui.js
index 6cabb32..cb38990 100644
--- a/ui.js
+++ b/ui.js
@@ -49,6 +49,7 @@ import {
   parentSymbol,
   reverseOrderOfGroups,
   searchForItem,
+  selectTracks,
   shuffleOrderOfGroups,
 } from './playlist-utils.js'
 
@@ -210,6 +211,8 @@ export default class AppElement extends FocusElement {
 
     this.timestampDictionary = new WeakMap()
 
+    this.itemMenuPage = 'cursor'
+
     // We add this is a child later (so that it's on top of every element).
     this.menuLayer = new DisplayElement()
     this.menuLayer.clickThrough = true
@@ -344,6 +347,15 @@ export default class AppElement extends FocusElement {
       {value: 'normal', label: 'In order'}
     ], this.showContextMenu)
 
+    this.selectGrouplikeItemsControl = new InlineListPickerElement('Which?', [
+      {value: 'all', label: 'all tracks'},
+      {value: '1', label: 'one track, randomly'},
+      {value: '4', label: 'four tracks, randomly'},
+      {value: '25%', label: '25% of the tracks, randomly'},
+      {value: '50%', label: '50% of the tracks, randomly'},
+      {value: '75%', label: '75% of the tracks, randomly'},
+    ], this.showContextMenu)
+
     this.menubar.buildItems([
       {text: 'mtui', menuItems: [
         {label: 'mtui (perpetual development)'},
@@ -980,32 +992,95 @@ export default class AppElement extends FocusElement {
     this.emitMarkChanged()
   }
 
-  markItem(item) {
-    if (isGroup(item)) {
-      for (const child of item.items) {
-        this.markItem(child)
+  selectTracksForMarking(item, forUnmarking = false, useGroupSelectionControl = false) {
+    let mode = 'all'
+    let modeValue = 0
+
+    if (useGroupSelectionControl) {
+      const value = this.selectGrouplikeItemsControl.curValue
+      if (value === 'all') {
+        // Do nothing, this is the default
+      } else if (value.endsWith('%')) {
+        mode = 'random'
+        modeValue = value
+      } else if (parseInt(value).toString() === value) {
+        mode = 'random'
+        modeValue = value
       }
+    }
+
+    const range = {
+      items:
+        (flattenGrouplike(item)
+          .items
+          .filter(isTrack)
+          .filter(track =>
+            this.markGrouplike.items.includes(track) === forUnmarking))
+    }
+
+    return selectTracks(range, mode, modeValue).items
+  }
+
+  markItem(item, useGroupSelectionControl = false) {
+    const { items: mark } = this.markGrouplike
+
+    let add
+    if (isGroup(item)) {
+      add = this.selectTracksForMarking(item, false, useGroupSelectionControl)
+    } else if (mark.includes(item)) {
+      add = []
     } else {
-      const { items } = this.markGrouplike
-      if (!items.includes(item)) {
-        items.push(item)
-        this.emitMarkChanged()
-      }
+      add = [item]
     }
+
+    if (!add.length) {
+      return
+    }
+
+    // If this is the first addition (starting from empty), switch the
+    // remembered context menu page so that the next context menu will show
+    // the marked items automatically.
+    if (!mark.length) {
+      this.itemMenuPage = 'mark'
+    }
+
+    for (const track of add) {
+      mark.push(track)
+    }
+
+    this.emitMarkChanged()
   }
 
-  unmarkItem(item) {
+  unmarkItem(item, useGroupSelectionControl) {
+    const { items: mark } = this.markGrouplike
+
+    let remove
     if (isGroup(item)) {
-      for (const child of item.items) {
-        this.unmarkItem(child)
-      }
+      remove = this.selectTracksForMarking(item, true, useGroupSelectionControl)
+    } else if (mark.includes(item)) {
+      remove = [item]
     } else {
-      const { items } = this.markGrouplike
-      if (items.includes(item)) {
-        items.splice(items.indexOf(item), 1)
-        this.emitMarkChanged()
-      }
+      remove = []
     }
+
+    if (!remove.length) {
+      return
+    }
+
+    for (const track of remove) {
+      mark.splice(mark.indexOf(track), 1)
+    }
+
+    // If this is the last removal (going to empty), switch the remembered
+    // context menu page so that the next context menu will show the usual
+    // controls for the item under the cursor. This isn't exactly necessary
+    // since various fallbacks will handle this value pointing to a page that
+    // doesn't exist anymore, but it's nice for consistency.
+    if (!mark.length) {
+      this.itemMenuPage = 'cursor'
+    }
+
+    this.emitMarkChanged()
   }
 
   getMarkStatus(item) {
@@ -1466,6 +1541,9 @@ export default class AppElement extends FocusElement {
         }
       }
 
+      // TODO: Implement this! :P
+      // const isMarked = false
+
       // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
       const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
         ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
@@ -1519,50 +1597,66 @@ export default class AppElement extends FocusElement {
 
           ...((this.config.showPartyControls && !rootGroup.isPartySources)
             ? [
-              {label: 'Share with party', action: () => this.shareWithParty(item)},
-              {divider: true}
-            ]
+                {label: 'Share with party', action: () => this.shareWithParty(item)},
+                {divider: true}
+              ]
             : [
-              canControlQueue && isPlayable(item) && {element: this.whereControl},
-              canControlQueue && isGroup(item) && {element: this.orderControl},
-              canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
-              canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
-              {divider: true},
-              canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
-              canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
-              canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
-              isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
-              isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
-              // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
-              // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
-              canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
-              isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
-              {divider: true},
-
-              timestampsItem,
-              ...(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)}
-                ])
-            ])
+                canControlQueue && isPlayable(item) && {element: this.whereControl},
+                canControlQueue && isGroup(item) && {element: this.orderControl},
+                canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
+                canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
+                {divider: true},
+                canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
+                canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
+                canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
+                isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
+                isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
+                // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
+                // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
+                canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
+                isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
+                {divider: true},
+
+                timestampsItem,
+                ...(item === this.markGrouplike
+                  ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
+                  : [
+                      isGroup(item) && {element: this.selectGrouplikeItemsControl},
+                      this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
+                      this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)},
+                    ])
+              ])
         ]
       }
     }
 
-    const pages = [
-      this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
-      el.item && generatePageForItem(el.item)
+    const pageInfo = [
+      this.markGrouplike.items.length && {
+        id: 'mark',
+        page: generatePageForItem(this.markGrouplike),
+      },
+
+      el.item && {
+        id: 'cursor',
+        page: generatePageForItem(el.item),
+      }
     ].filter(Boolean)
 
-    // TODO: Implement this! :P
-    // const isMarked = false
+    const pages = pageInfo.map(({ page }) => page)
 
-    this.showContextMenu({
+    let pageNum = pageInfo.findIndex(({ id }) => id === this.itemMenuPage)
+    if (pageNum === -1) pageNum = pageInfo.findIndex(({ id }) => id === 'cursor')
+    if (pageNum === -1) pageNum = 0
+
+    const menu = this.showContextMenu({
       x: el.absLeft,
       y: el.absTop + 1,
-      pages
+      pages,
+      pageNum,
+    })
+
+    menu.on('page changed', pageNum => {
+      this.itemMenuPage = pageInfo[pageNum].id
     })
   }
 
@@ -4906,6 +5000,7 @@ class ContextMenu extends FocusElement {
         }
         this.close(false)
         this.show({x, y, pages, pageNum})
+        this.emit('page changed', pageNum)
       }
     }
 
@@ -4917,6 +5012,7 @@ class ContextMenu extends FocusElement {
         }
         this.close(false)
         this.show({x, y, pages, pageNum})
+        this.emit('page changed', pageNum)
       }
     }
 
@@ -4928,7 +5024,7 @@ class ContextMenu extends FocusElement {
       if (pages.length === 0) {
         return
       }
-      itemsArg = pages[pageNum]
+      itemsArg = pages[Math.min(pages.length - 1, pageNum)]
     }
 
     let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg
@@ -5133,16 +5229,30 @@ class ContextMenu extends FocusElement {
 
     width += 2 // Space for the pane border
     height += 2 // Space for the pane border
-    if (this.form.scrollBarShown) width++
+
+    // Hilarity time: at THIS point, we can't identify if the form has its
+    // scroll bar visible, because we haven't constrained the height of the
+    // form. We're going to apply all the dimensions leading to the form twice,
+    // adapting the second time to be one wider if the form shows its scroll
+    // bar the first time.
+
     this.w = width
     this.h = height
-
     this.fitToParent()
 
     this.pane.fillParent()
     this.form.fillParent()
     this.form.fixLayout()
 
+    // We only need to update our own (and the descendants') width this time,
+    // because height won't have changed from the earlier measurement.
+    if (this.form.scrollBarShown) width++
+    this.w = width
+
+    this.pane.fillParent()
+    this.form.fillParent()
+    this.form.fixLayout()
+
     // After everything else, do a second pass to apply the decided width
     // to every element, so that they expand to all be the same width.
     // In order to change the width of a button (which is what these elements