« get me outta code hell

Make a neater neato nice context menu! - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlorrie <towerofnix@gmail.com>2018-12-19 21:53:28 -0400
committerFlorrie <towerofnix@gmail.com>2018-12-19 21:53:28 -0400
commitcbad1567acb43d9f2462e75aef3f0aaaf582dceb (patch)
tree5caec8d358c11a11a8bad232aacd5d05b1c66319
parentc4fdc7792286090c631f29ebac66a813ecba7ddc (diff)
Make a neater neato nice context menu!
Not a lot of new potential utility here for now, but it's at the least
easier to use and cleaner than the old look! Also.. shuffle-groups
soon(TM). Maybe!
-rw-r--r--ui.js202
1 files changed, 186 insertions, 16 deletions
diff --git a/ui.js b/ui.js
index c4c0bae..175bf73 100644
--- a/ui.js
+++ b/ui.js
@@ -109,8 +109,8 @@ class AppElement extends FocusElement {
     grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item))
     grouplikeListing.on('menu', (item, opts) => this.menu.show(opts))
 
-    grouplikeListing.on('queue', (item, {where = 'end', shuffle = false, play = false} = {}) => {
-      if (isGroup(item) && shuffle) {
+    grouplikeListing.on('queue', (item, {where = 'end', order = 'normal', play = false} = {}) => {
+      if (isGroup(item) && order === 'shuffle') {
         item = {items: shuffleArray(flattenGrouplike(item).items)}
       }
 
@@ -120,7 +120,7 @@ class AppElement extends FocusElement {
       }
 
       this.queueGrouplikeItem(item, afterItem, {
-        movePlayingTrack: !shuffle
+        movePlayingTrack: order === 'normal'
       })
 
       if (play) {
@@ -1058,11 +1058,128 @@ class BasicGrouplikeItemElement extends Button {
   }
 }
 
+class InlineListPickerElement extends FocusElement {
+  // And you thought my class names couldn't get any worse...
+  // This is an element that looks something like the following:
+  //    Fruit?  [Apple]
+  // (Imagine that "[Apple]" just looks like "Apple" written in blue text.)
+  // If you press the element (like a button), it'll pick the next item in its
+  // list of options, like "Banana" or "Canteloupe" in this example. The arrow
+  // keys also work to move through the list. You typically don't want to put
+  // too many items in the list, since there's no visual way of telling what's
+  // next or previous. (That's the point, it's inline.) This element is mainly
+  // useful in forms or ContextMenus.
+
+  constructor(labelText, options) {
+    super()
+    this.labelText = labelText
+    this.options = options
+    this.curIndex = 0
+  }
+
+  fixLayout() {
+    // We want to fill the parent's width, but also fit ourselves, so we need
+    // to determine the ideal width which would fit us but not leave extra
+    // space.
+    const longestOptionLength = this.options.reduce(
+      (soFar, { label }) => Math.max(soFar, ansi.measureColumns(label)), 0)
+    const idealWidth = (
+      ansi.measureColumns(this.labelText) + longestOptionLength + 4)
+
+    // Then we use whichever is greater - our ideal width or the width of the
+    // parent - as our own width. The parent should respect our needs by
+    // growing if necessary. :)  (ContextMenu does this, which is where you'd
+    // typically embed this element.)
+    // I shall fill you, parent, even beyond your own bounds!!!
+    this.w = Math.max(this.parent.contentW, idealWidth)
+
+    // Height is always just 1.
+    this.h = 1
+  }
+
+  drawTo(writable) {
+    if (this.isSelected) {
+      writable.write(ansi.invert())
+    }
+
+    const curOption = this.options[this.curIndex].label.toString()
+    let drawX = 0
+    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+
+    writable.write(this.labelText + ' ')
+    drawX += ansi.measureColumns(this.labelText) + 1
+
+    writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
+    writable.write(' ' + curOption + ' ')
+    drawX += ansi.measureColumns(curOption) + 2
+
+    writable.write(ansi.setForeground(ansi.C_RESET))
+    writable.write(' '.repeat(Math.max(0, this.w - drawX)))
+
+    writable.write(ansi.resetAttributes())
+  }
+
+  keyPressed(keyBuf) {
+    if (telc.isSelect(keyBuf) || telc.isRight(keyBuf)) {
+      this.nextOption()
+    } else if (telc.isLeft(keyBuf)) {
+      this.previousOption()
+    } else {
+      return true
+    }
+  }
+
+  clicked(button) {
+    if (button === 'left') {
+      if (this.isSelected) {
+        this.nextOption()
+      } else {
+        this.root.select(this)
+      }
+    } else if (button === 'scroll-up') {
+      this.previousOption()
+    } else if (button === 'scroll-down') {
+      this.nextOption()
+    } else {
+      return true
+    }
+    return false
+  }
+
+  nextOption() {
+    this.curIndex++
+    if (this.curIndex === this.options.length) {
+      this.curIndex = 0
+    }
+  }
+
+  previousOption() {
+    this.curIndex--
+    if (this.curIndex < 0) {
+      this.curIndex = this.options.length - 1
+    }
+  }
+
+  get curValue() {
+    return this.options[this.curIndex].value
+  }
+}
+
 class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   constructor(item, recordStore) {
     super(item.name)
     this.item = item
     this.recordStore = recordStore
+
+    this.whereControl = new InlineListPickerElement('Where?', [
+      {value: 'next', label: 'After current song'},
+      {value: 'end', label: 'At end of queue'}
+    ])
+
+    this.orderControl = new InlineListPickerElement('Order?', [
+      {value: 'shuffle', label: 'Shuffle'},
+      {value: 'normal', label: 'In order'}
+    ])
   }
 
   drawTo(writable) {
@@ -1124,20 +1241,40 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
           ')'},
 
         // The actual controls!
+        {divider: true},
         editMode && {label: this.isMarked ? 'Unmark' : 'Mark', action: () => this.emit('mark')},
         anyMarked && {label: 'Paste (above)', action: () => this.emit('paste', {where: 'above'})},
         anyMarked && {label: 'Paste (below)', action: () => this.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)
-        {label: 'Play', action: () => this.emit('queue', {where: 'next', play: true})},
-        {label: 'Play next', action: () => this.emit('queue', {where: 'next'})},
-        {label: 'Play at end', action: () => this.emit('queue', {where: 'end'})},
-        this.isGroup && {label: 'Play next, shuffled', action: () => this.emit('queue', {where: 'next', shuffle: true})},
-        this.isGroup && {label: 'Play at end, shuffled', action: () => this.emit('queue', {where: 'end', shuffle: true})},
+        editMode && {divider: true},
+
+        {element: this.whereControl},
+        {element: this.orderControl},
+        {label: 'Play!', action: () => this.playWithControls()},
+        {label: 'Queue!', action: () => this.queueWithControls()},
+        {divider: true},
+
         {label: 'Remove from queue', action: () => this.emit('unqueue')}
       ]
     })
   }
 
+  playWithControls() {
+    this.emitControls(true)
+  }
+
+  queueWithControls() {
+    this.emitControls(false)
+  }
+
+  emitControls(play) {
+    this.emit('queue', {
+      where: this.whereControl.curValue,
+      order: this.orderControl.curValue,
+      play: play
+    })
+  }
+
   writeStatus(writable) {
     if (this.isGroup) {
       // The ANSI attributes here will apply to the rest of the line, too.
@@ -1737,15 +1874,22 @@ class ContextMenu extends FocusElement {
     this.y = y
     this.visible = true
 
-    for (const { label, action } of items.filter(Boolean)) {
-      const button = new Button(label)
-      if (action) {
-        button.on('pressed', () => {
-          this.close()
-          action()
-        })
+    for (const item of items.filter(Boolean)) {
+      if (item.element) {
+        this.form.addInput(item.element)
+      } else if (item.divider) {
+        const element = new HorizontalRule()
+        this.form.addInput(element)
+      } else {
+        const button = new Button(item.label)
+        if (item.action) {
+          button.on('pressed', () => {
+            this.close()
+            item.action()
+          })
+        }
+        this.form.addInput(button)
       }
-      this.form.addInput(button)
     }
 
     this.fixLayout()
@@ -1819,4 +1963,30 @@ class ContextMenu extends FocusElement {
   }
 }
 
+class HorizontalRule extends FocusElement {
+  // It's just a horizontal rule. Y'know..
+  // --------------------------------------------------------------------------
+  // You get the idea. :)
+
+  get selectable() {
+    // Just return false. A HorizontalRule is technically a FocusElement,
+    // but that's just so that it can be used in place of other inputs
+    // (e.g. in a ContextMenu).
+    return false
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.h = 1
+  }
+
+  drawTo(writable) {
+    // For the character we draw with, we use an ordinary dash instead of
+    // an actual box-drawing horizontal line. That's so that the rule is
+    // distinguishable from the edge of a Pane.
+    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+    writable.write('-'.repeat(this.w))
+  }
+}
+
 module.exports.AppElement = AppElement