« 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.js457
1 files changed, 319 insertions, 138 deletions
diff --git a/ui.js b/ui.js
index 3bac8c6..68cda91 100644
--- a/ui.js
+++ b/ui.js
@@ -65,6 +65,7 @@ const TuiTextEditor = require('tui-text-editor')
 
 const { promisify } = require('util')
 const { spawn } = require('child_process')
+const { orderBy } = require('natural-orderby')
 const fs = require('fs')
 const open = require('open')
 const path = require('path')
@@ -135,6 +136,7 @@ const keyBindings = [
   ['isTogglePause', '5'],
   ['isBackspace', '.'],
   ['isMenu', '+'],
+  ['isMenu', '0'],
   ['isSkipBack', '1'],
   ['isSkipAhead', '3'],
   // Disabled because this is the jump key! Oops.
@@ -210,6 +212,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).
@@ -324,11 +327,12 @@ class AppElement extends FocusElement {
     this.addChild(this.menuLayer)
 
     this.whereControl = new InlineListPickerElement('Where?', [
-      {value: 'next-selected', label: 'After selected song'},
-      {value: 'next', label: 'After current song'},
+      {value: 'after-selected', label: 'After selected track'},
+      {value: 'next', label: 'After current track'},
       {value: 'end', label: 'At end of queue'},
       {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
-      {value: 'distribute-randomly', label: 'Distributed across queue randomly'}
+      {value: 'distribute-randomly', label: 'Distributed across queue randomly'},
+      {value: 'before-selected', label: 'Before selected track'}
     ], this.showContextMenu)
 
     this.orderControl = new InlineListPickerElement('Order?', [
@@ -336,6 +340,7 @@ class AppElement extends FocusElement {
       {value: 'shuffle-groups', label: 'Shuffle order of groups'},
       {value: 'reverse', label: 'Reverse all'},
       {value: 'reverse-groups', label: 'Reverse order of groups'},
+      {value: 'alphabetic', label: 'Alphabetically'},
       {value: 'normal', label: 'In order'}
     ], this.showContextMenu)
 
@@ -358,6 +363,7 @@ class AppElement extends FocusElement {
           {divider: true},
           playingTrack && {element: this.playingControl},
           {element: this.loopingControl},
+          {element: this.loopQueueControl},
           {element: this.pauseNextControl},
           {element: this.autoDJControl},
           {element: this.volumeSlider},
@@ -413,6 +419,12 @@ class AppElement extends FocusElement {
       getEnabled: () => this.config.canControlPlayback
     })
 
+    this.loopQueueControl = new ToggleControl('Loop queue?', {
+      setValue: val => this.SQP.setLoopQueueAtEnd(val),
+      getValue: () => this.SQP.loopQueueAtEnd,
+      getEnabled: () => this.config.canControlPlayback
+    })
+
     this.volumeSlider = new SliderElement('Volume', {
       setValue: val => this.SQP.setVolume(val),
       getValue: () => this.SQP.player.volume,
@@ -761,7 +773,7 @@ class AppElement extends FocusElement {
     // Sets up event listeners that are common to ordinary grouplike listings
     // (made by newGrouplikeListing) as well as the queue grouplike listing.
 
-    grouplikeListing.pathElement.on('select', item => this.reveal(item))
+    grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
     /*
     grouplikeListing.on('select', item => this.editNotesFile(item, false))
@@ -782,7 +794,7 @@ class AppElement extends FocusElement {
     return menu
   }
 
-  reveal(item) {
+  reveal(item, child) {
     if (!this.tabberPane.visible) {
       return
     }
@@ -793,6 +805,9 @@ class AppElement extends FocusElement {
     const parent = item[parentSymbol]
     if (isGroup(item)) {
       tabberListing.loadGrouplike(item)
+      if (child) {
+        tabberListing.selectAndShow(child)
+      }
     } else if (parent) {
       if (tabberListing.grouplike !== parent) {
         tabberListing.loadGrouplike(parent)
@@ -866,8 +881,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() {
@@ -1024,99 +1104,104 @@ class AppElement extends FocusElement {
   }
 
   showMenuForItemElement(el, listing) {
-    const emitControls = play => () => {
-      this.handleQueueOptions(item, {
-        where: this.whereControl.curValue,
-        order: this.orderControl.curValue,
-        play: play
-      })
-    }
-
-    let item
-    if (this.markGrouplike.items.length) {
-      item = this.markGrouplike
-    } else {
-      item = el.item
-    }
-
-    // TODO: Implement this! :P
-    const isMarked = false
-
     const { editMode } = this
     const { canControlQueue, canProcessMetadata } = this.config
     const anyMarked = editMode && this.markGrouplike.items.length > 0
-    const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
 
-    let items;
-    if (listing.grouplike.isTheQueue && isTrack(item)) {
-      items = [
-        item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
-        {divider: true},
-        canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
-        canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
-        {divider: true},
-        canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
-        canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
-        {divider: true},
-        {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
-        {divider: true},
-        canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
-      ]
-    } else {
-      const numTracks = countTotalTracks(item)
-      const { string: durationString } = this.backend.getDuration(item)
-      items = [
-        // A label that just shows some brief information about the item.
-        {label:
-          `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
-          (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
-          durationString +
-          ')',
-          keyboardIdentifier: item.name
-        },
+    const generatePageForItem = item => {
+      const emitControls = play => () => {
+        this.handleQueueOptions(item, {
+          where: this.whereControl.curValue,
+          order: this.orderControl.curValue,
+          play: play
+        })
+      }
 
-        // The actual controls!
-        {divider: true},
+      const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      if (listing.grouplike.isTheQueue && isTrack(item)) {
+        return [
+          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
+          {divider: true},
+          canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
+          canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
+          {divider: true},
+          canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
+          canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
+          {divider: true},
+          {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
+          {divider: true},
+          canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
+        ]
+      } else {
+        const numTracks = countTotalTracks(item)
+        const { string: durationString } = this.backend.getDuration(item)
+        return [
+          // A label that just shows some brief information about the item.
+          {label:
+            `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
+            (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
+            durationString +
+            ')',
+            keyboardIdentifier: item.name,
+            isPageSwitcher: true
+          },
 
-        // TODO: Don't emit these on the element (and hence receive them from
-        // the listing) - instead, handle their behavior directly. We'll want
-        // to move the "mark"/"paste" (etc) code into separate functions,
-        // instead of just defining their behavior inside the listing event
-        // handlers.
-        /*
-        editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
-        anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
-        anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
-        // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
-        {divider: true},
-        */
+          // The actual controls!
+          {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},
+          // TODO: Don't emit these on the element (and hence receive them from
+          // the listing) - instead, handle their behavior directly. We'll want
+          // to move the "mark"/"paste" (etc) code into separate functions,
+          // instead of just defining their behavior inside the listing event
+          // handlers.
+          /*
+          editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
+          anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
+          anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
+          // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
+          {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)},
-        {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},
 
-        item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()}
-      ]
+          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)},
+          {divider: true},
+
+          ...(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)}
+            ])
+        ]
+      }
     }
 
+    const pages = [
+      this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
+      el.item && generatePageForItem(el.item)
+    ].filter(Boolean)
+
+    // TODO: Implement this! :P
+    const isMarked = false
+
     this.showContextMenu({
       x: el.absLeft,
       y: el.absTop + 1,
-      items
+      pages
     })
   }
 
@@ -1524,26 +1609,45 @@ class AppElement extends FocusElement {
       } else if (order === 'reverse-groups') {
         item = reverseOrderOfGroups(item)
         item.name = `${oldName} (group order reversed)`
+      } else if (order === 'alphabetic') {
+        item = {
+          name: `${oldName} (alphabetic)`,
+          items: orderBy(
+            flattenGrouplike(item).items,
+            t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
+          )
+        }
       }
     } else {
       // Make it into a grouplike that just contains itself.
       item = {name: oldName, items: [item]}
     }
 
-    if (where === 'next' || where === 'next-selected' || where === 'end') {
+    if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') {
+      const selected = this.queueListingElement.currentItem
       let afterItem = null
       if (where === 'next') {
         afterItem = playingTrack
-      } else if (where === 'next-selected') {
-        afterItem = this.queueListingElement.currentItem
+      } else if (where === 'after-selected') {
+        afterItem = selected
+      } else if (where === 'before-selected') {
+        const { items } = this.SQP.queueGrouplike
+        const index = items.indexOf(selected)
+        if (index === 0) {
+          afterItem = 'FRONT'
+        } else if (index > 0) {
+          afterItem = items[index - 1]
+        }
       }
 
       this.SQP.queue(item, afterItem, {
-        movePlayingTrack: order === 'normal'
+        movePlayingTrack: order === 'normal' || order === 'alphabetic'
       })
 
       if (isTrack(passedItem)) {
         this.queueListingElement.selectAndShow(passedItem)
+      } else {
+        this.queueListingElement.selectAndShow(selected)
       }
     } else if (where.startsWith('distribute-')) {
       this.SQP.distributeQueue(item, {
@@ -1823,7 +1927,7 @@ class GrouplikeListingElement extends Form {
         */
       }
     } else if (keyBuf[0] === 1) { // ctrl-A
-      this.toggleSelectAll()
+      this.toggleMarkAll()
     } else {
       return super.keyPressed(keyBuf)
     }
@@ -1864,14 +1968,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()
   }
 
   /*
@@ -2080,15 +2204,21 @@ class GrouplikeListingElement extends Form {
   }
 
   hideJumpElement(isCancel) {
-    if (isCancel) {
-      this.form.curIndex = this.oldFocusedIndex
-      this.form.scrollSelectedElementIntoView()
-    }
-    this.jumpElement.visible = false
-    if (this.jumpElement.isSelected) {
-      this.root.select(this)
+    if (this.jumpElement.visible) {
+      if (isCancel) {
+        this.form.curIndex = this.oldFocusedIndex
+        this.form.scrollSelectedElementIntoView()
+      }
+      this.jumpElement.visible = false
+      if (this.jumpElement.isSelected) {
+        this.root.select(this)
+      }
+      this.fixLayout()
     }
-    this.fixLayout()
+  }
+
+  unselected() {
+    this.hideJumpElement(true)
   }
 
   get tabberLabel() {
@@ -2168,13 +2298,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]
@@ -2211,27 +2341,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)
       }
     }
   }
@@ -2270,11 +2395,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)
     }
@@ -2987,21 +3114,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]))
@@ -3015,8 +3144,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(' ')
     }
@@ -3036,10 +3167,6 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     writable.write(' ')
   }
 
-  get isMarked() {
-    return this.app.markGrouplike.items.includes(this.item)
-  }
-
   get isGroup() {
     return isGroup(this.item)
   }
@@ -3113,10 +3240,12 @@ class PathElement extends ListScrollForm {
     const itemPath = getItemPath(item)
     const parentPath = itemPath.slice(0, -1)
 
-    for (const pathItem of parentPath) {
-      const isFirst = pathItem === parentPath[0]
+    for (let i = 0; i < parentPath.length; i++) {
+      const pathItem = parentPath[i]
+      const nextItem = itemPath[i + 1]
+      const isFirst = (i === 0)
       const element = new PathItemElement(pathItem, isFirst)
-      element.on('select', () => this.emit('select', pathItem))
+      element.on('select', () => this.emit('select', pathItem, nextItem))
       element.fixLayout()
       this.addInput(element)
     }
@@ -3861,7 +3990,7 @@ class ContextMenu extends FocusElement {
     this.submenu = null
   }
 
-  show({x = 0, y = 0, items: itemsArg, focusKey = null}) {
+  show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) {
     this.reload = () => {
       const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors]
       const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key]))
@@ -3869,6 +3998,39 @@ class ContextMenu extends FocusElement {
       this.show({x, y, items: itemsArg, focusKey})
     }
 
+    this.nextPage = () => {
+      if (pages.length > 1) {
+        pageNum++
+        if (pageNum === pages.length) {
+          pageNum = 0
+        }
+        this.close(false)
+        this.show({x, y, pages, pageNum})
+      }
+    }
+
+    this.previousPage = () => {
+      if (pages.length > 1) {
+        pageNum--
+        if (pageNum === -1) {
+          pageNum = pages.length - 1
+        }
+        this.close(false)
+        this.show({x, y, pages, pageNum})
+      }
+    }
+
+    if (!pages && !itemsArg || pages && itemsArg) {
+      return
+    }
+
+    if (pages) {
+      if (pages.length === 0) {
+        return
+      }
+      itemsArg = pages[pageNum]
+    }
+
     let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg
 
     items = items.filter(Boolean)
@@ -3918,8 +4080,12 @@ class ContextMenu extends FocusElement {
         wantDivider = true
       } else {
         addDividerIfWanted()
-        const button = new Button(item.label)
-        button.keyboardIdentifier = item.keyboardIdentifier || item.label
+        let label = item.label
+        if (item.isPageSwitcher && pages.length > 1) {
+          label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m`
+        }
+        const button = new Button(label)
+        button.keyboardIdentifier = item.keyboardIdentifier || label
         if (item.action) {
           button.on('pressed', async () => {
             this.restoreSelection()
@@ -3930,6 +4096,12 @@ class ContextMenu extends FocusElement {
             }
           })
         }
+        if (item.isPageSwitcher) {
+          button.on('pressed', async () => {
+            this.nextPage()
+          })
+        }
+        button.item = item
         focusEl = button
         this.form.addInput(button)
         if (item.isDefault) {
@@ -3980,6 +4152,15 @@ class ContextMenu extends FocusElement {
       this.form.scrollToBeginning()
     } else if (input.isScrollToEnd(keyBuf)) {
       this.form.lastInput()
+    } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) {
+      if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) {
+        if (input.isLeft(keyBuf)) {
+          this.previousPage()
+        } else {
+          this.nextPage()
+        }
+        return false
+      }
     } else {
       return super.keyPressed(keyBuf)
     }