« get me outta code hell

Merge branch 'main' into merge-socket-mtui - 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:
author(quasar) nebula <qznebula@protonmail.com>2024-05-16 22:41:33 -0300
committer(quasar) nebula <qznebula@protonmail.com>2024-05-16 22:41:33 -0300
commitcd91ccbea9e76ee1de1e3cbcfe17d8a2cb6ef9b4 (patch)
treed98e0b2a100170c7f913da57fb8e010c4b581603 /ui.js
parent2a270f20cc8e2f2a0754d28d6892bb1bd27e45ce (diff)
parenteb6a997df675fc8176fb15f34450a0c1b8416edc (diff)
Merge branch 'main' into merge-socket-mtui merge-socket-mtui
So we made this merge commit *after* going through a whole
fidanglin' bunch of steps to flat-out rebase socket-mtui...
but of course, that was pretty hopeless from the start to
get just quite right. After all, we didn't know the exact
point of each commit and how to test out the changes, so
we couldn't make sure all our rebased commits were working
the same way. (This is maybe the single coolest reason to
make sure that automated tests *pass with 100% coverage
at every commit*, but obviously this project doesn't have
that. Alas!)

This merge commit follows up a different merge commit we
actually made almost exactly one year ago to this day
(also: our birthday LOL). We figure the testing done at
that point was quite a bit more thorough than we'd do
today, and anyway there's little reason to repeat the
work we did in that commit. Comparatively, this merge
commit is way smaller!

It was still fun to go through the whole rebasing
process, even if it didn't practically bring us
anywhere. You know, assuming the merge commit from
last year didn't accidentally destroy any code or
todos lol........ *prays* ^____^
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