« get me outta code hell

Add text/notes editor, using tui-text-editor - 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>2019-10-23 12:20:27 -0300
committerFlorrie <towerofnix@gmail.com>2019-10-23 12:20:27 -0300
commit147172fe720f8af8a219ba384c0407ca751e0321 (patch)
tree85b97ccb8ea210392493806ebf6d35e41615e76b
parent1d1483a9dc18d7acf4267727893a281b99645c02 (diff)
Add text/notes editor, using tui-text-editor
:D!
-rw-r--r--README.md4
-rw-r--r--package-lock.json19
-rw-r--r--package.json1
-rw-r--r--playlist-utils.js38
-rw-r--r--todo.txt15
-rw-r--r--ui.js169
6 files changed, 236 insertions, 10 deletions
diff --git a/README.md b/README.md
index de6aa4e..8360989 100644
--- a/README.md
+++ b/README.md
@@ -77,3 +77,7 @@ You're also welcome to share any ideas, suggestions, and questions through there
   * .: back (and any action that would take backspace)
   * +: open a context menu
   * 1, 3: skip to previous, next track
+* **In the text file / notes editor:**
+  * (mtui will automatically present an embedded text editor when you select a '.txt' file. It will also show this editor when there is an *adjacent* file to a selected group or track: a file whose name is the same, but with the '.txt' extension. This is useful for keeping track of notes, lyrics, etc on tracks or groups, and for just quickly accessing any miscellaneous text files.)
+  * Ctrl+S: manually save the text file (it will also auto-save when it is closed)
+  * Ctrl+X or Escape: defocus the text editor
diff --git a/package-lock.json b/package-lock.json
index 52cb54d..99afaa1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -120,6 +120,25 @@
         "word-wrap": "^1.2.3"
       }
     },
+    "tui-text-editor": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.2.0.tgz",
+      "integrity": "sha512-XnqfBQ3EGqABUf6Xdyxl47byRUR1q+mxDJ4tFBDpezi9ui7Loub4t7LTnsb8fwxJkeMiemF4gpVnEC5JkV5Iqw==",
+      "requires": {
+        "tui-lib": "^0.1.0"
+      },
+      "dependencies": {
+        "tui-lib": {
+          "version": "0.1.0",
+          "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.0.tgz",
+          "integrity": "sha512-pSq5gn4/8MDlG4B0qbz2u1G5YeuF4xFvqfVLmC0hDAlv90r3yj7pErilDg4Sk7pAE5LEPrxIPsklKbaIXYTEaw==",
+          "requires": {
+            "wcwidth": "^1.0.1",
+            "word-wrap": "^1.2.3"
+          }
+        }
+      }
+    },
     "unique-string": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
diff --git a/package.json b/package.json
index c7638d8..81fb70a 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "sanitize-filename": "^1.6.1",
     "tempy": "^0.2.1",
     "tui-lib": "0.0.4",
+    "tui-text-editor": "^0.3.1",
     "word-wrap": "^1.2.3"
   }
 }
diff --git a/playlist-utils.js b/playlist-utils.js
index de2c3f8..9c6f0ed 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -560,6 +560,15 @@ function isTrack(obj) {
   return !!(obj && obj.downloaderArg)
 }
 
+function isPlayable(obj) {
+  return isGroup(obj) || isTrack(obj)
+}
+
+function isOpenable(obj) {
+  return !!(obj && obj.url)
+}
+
+
 function searchForItem(grouplike, value, preferredStartIndex = -1) {
   if (value.length) {
     // We prioritize searching past the index that the user opened the jump
@@ -600,6 +609,31 @@ function searchForItem(grouplike, value, preferredStartIndex = -1) {
   return null
 }
 
+function getCorrespondingFileForItem(item, extension) {
+  if (!(item && item.url)) {
+    return null
+  }
+
+  const checkExtension = item => item.url && path.extname(item.url) === extension
+
+  if (isPlayable(item)) {
+    const parent = item[parentSymbol]
+
+    if (!parent) {
+      return null
+    }
+
+    const basename = path.basename(item.url, path.extname(item.url))
+    return parent.items.find(item => checkExtension(item) && path.basename(item.url, extension) === basename)
+  }
+
+  if (checkExtension(item)) {
+    return item
+  }
+
+  return null
+}
+
 module.exports = {
   parentSymbol,
   updatePlaylistFormat, updateGroupFormat, updateTrackFormat,
@@ -618,5 +652,7 @@ module.exports = {
   getTrackIndexInParent,
   getNameWithoutTrackNumber,
   searchForItem,
-  isGroup, isTrack
+  getCorrespondingFileForItem,
+  isGroup, isTrack,
+  isOpenable, isPlayable
 }
diff --git a/todo.txt b/todo.txt
index 891479a..759fbbf 100644
--- a/todo.txt
+++ b/todo.txt
@@ -391,6 +391,8 @@ TODO: Text file support. Yes. Heck yes. Heck hecking yes.
       file which has the same basename as a track (or group), show a mark on
       the item in grouplike listings, and let it be viewed through an action
       on the context menu. Maybe, just MAYBE, implement editing support too.
+      (Done!!! Still some WIP stuff in the text editor library, but this is
+       definitely functional as of now.)
 
 TODO: Make 'after selected song' the default in the context menu, too. I miight
       go back on this decision, but I think it's just more convenient in
@@ -439,3 +441,16 @@ TODO: "Shuffle queue", "shuffle these tracks" options in the queue's
 
 TODO: It looks like you can't move the currently playing track by re-queuing
       it? Investigate this.
+
+TODO: Fix mtui crashing when you interact with it before the main listing has
+      loaded its contents.
+
+TODO: Specific config permissions to view and edit text files.
+
+TODO: I lied when I said I finished text editor stuff. Still to-do:
+      Don't show text files which are adjacent (same filename, but .txt) to
+      a playable. Instead, show a mark on the listing item. Have a context menu
+      option for focusing the text editor.
+
+TODO: Have a context menu option for creating a text file for notes on a
+      playable, when there is no existing adjacent file.
diff --git a/ui.js b/ui.js
index 03337f5..cfb3e87 100644
--- a/ui.js
+++ b/ui.js
@@ -15,9 +15,12 @@ const {
   cloneGrouplike,
   countTotalTracks,
   flattenGrouplike,
+  getCorrespondingFileForItem,
   getItemPath,
   getNameWithoutTrackNumber,
   isGroup,
+  isOpenable,
+  isPlayable,
   isTrack,
   parentSymbol,
   reverseOrderOfGroups,
@@ -47,10 +50,15 @@ const {
   }
 } = require('tui-lib')
 
-const open = require('open')
+const TuiTextEditor = require('tui-text-editor')
 
-const isPlayable = item => isGroup(item) || isTrack(item)
-const isOpenable = item => !!(item && item.url)
+const { promisify } = require('util')
+const fs = require('fs')
+const open = require('open')
+const path = require('path')
+const url = require('url')
+const readFile = promisify(fs.readFile)
+const writeFile = promisify(fs.writeFile)
 
 const input = {}
 
@@ -101,6 +109,10 @@ const keyBindings = [
   ['isActOnPlayer', [0x1b, 'a']],
   ['isActOnPlayer', [0x1b, '!']],
 
+  ['isSaveTextEditor', [0x13]], // ^S
+  ['isDeselectTextEditor', [0x18]], // ^X
+  ['isDeselectTextEditor', telc.isEscape],
+
   // Number pad
   ['isUp', '8'],
   ['isDown', '2'],
@@ -200,6 +212,15 @@ class AppElement extends FocusElement {
     this.queuePane = new Pane()
     this.addChild(this.queuePane)
 
+    this.textInfoPane = new Pane()
+    this.addChild(this.textInfoPane)
+
+    this.textEditor = new NotesTextEditor()
+    this.textInfoPane.addChild(this.textEditor)
+    this.textInfoPane.visible = false
+
+    this.textEditor.on('deselect', () => this.root.select(this.tabber))
+
     if (!this.config.showTabberPane) {
       this.tabberPane.visible = false
     }
@@ -677,6 +698,20 @@ class AppElement extends FocusElement {
 
     grouplikeListing.pathElement.on('select', item => this.reveal(item))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
+
+    grouplikeListing.on('selected', async item => {
+      const status = await this.textEditor.openItem(item, {
+        doubleCheckItem: () => item === grouplikeListing.currentItem
+      })
+
+      if (status === true) {
+        this.textInfoPane.visible = true
+        this.fixLayout()
+      } else if (status === false) {
+        this.textInfoPane.visible = false
+        this.fixLayout()
+      }
+    })
   }
 
   showContextMenu(opts) {
@@ -800,6 +835,10 @@ class AppElement extends FocusElement {
   openSpecialOrThroughSystem(item) {
     if (item.url.endsWith('.json')) {
       return this.handlePlaylistSource(item.url, true)
+    } else if (item.url.endsWith('.txt')) {
+      if (this.textInfoPane.visible) {
+        this.root.select(this.textEditor)
+      }
     } else {
       return this.openThroughSystem(item)
     }
@@ -921,11 +960,9 @@ class AppElement extends FocusElement {
         canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => this.processMetadata(item, false)},
         canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => this.processMetadata(item, true)},
         canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => this.processMetadata(item, true)},
-        canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
-        {divider: true},
-
         isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
         isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
+        canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
         {divider: true},
 
         item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()}
@@ -999,6 +1036,7 @@ class AppElement extends FocusElement {
       await this.backend.stopPlayingAll()
     }
 
+    await this.textEditor.save()
     this.emit('quitRequested')
   }
 
@@ -1057,15 +1095,37 @@ class AppElement extends FocusElement {
       this.alignPartyLabel()
     }
 
+    const leftWidth = Math.max(Math.floor(0.8 * this.contentW), this.contentW - 80)
+
+    if (this.textInfoPane.visible) {
+      this.textInfoPane.w = leftWidth
+      if (this.textEditor.isSelected) {
+        this.textInfoPane.h = 8
+      } else {
+        this.textEditor.w = this.textInfoPane.contentW
+        this.textInfoPane.h = Math.min(8, this.textEditor.getOptimalHeight() + 2)
+      }
+
+      this.textEditor.fillParent()
+      this.textEditor.fixLayout()
+    }
+
     if (this.tabberPane.visible) {
-      this.tabberPane.w = Math.max(Math.floor(0.8 * this.contentW), this.contentW - 80)
+      this.tabberPane.w = leftWidth
       this.tabberPane.y = bottomY
       this.tabberPane.h = topY - this.tabberPane.y
+      if (this.textInfoPane.visible) {
+        this.tabberPane.h -= this.textInfoPane.h
+        this.textInfoPane.y = this.tabberPane.bottom
+      }
       this.queuePane.x = this.tabberPane.right
       this.queuePane.w = this.contentW - this.tabberPane.right
     } else {
       this.queuePane.x = 0
       this.queuePane.w = this.contentW
+      if (this.textInfoPane.visible) {
+        this.textInfoPane.y = bottomY
+      }
     }
 
     this.queuePane.y = bottomY
@@ -1440,9 +1500,10 @@ class GrouplikeListingElement extends Form {
     this.form = this.getNewForm()
     this.addInput(this.form)
 
-    this.form.on('selected input', input => {
+    this.form.on('selected', input => {
       if (input && this.pathElement) {
         this.pathElement.showItem(input.item)
+        this.emit('selected', input.item)
       }
     })
 
@@ -1490,6 +1551,7 @@ class GrouplikeListingElement extends Form {
   selected() {
     this.curIndex = 0
     this.root.select(this.form)
+    this.emit('selected', this.currentItem)
   }
 
   clicked(button) {
@@ -1782,7 +1844,7 @@ class GrouplikeListingForm extends ListScrollForm {
 
   set curIndex(newIndex) {
     this.setDep('curIndex', newIndex)
-    this.emit('selected input', this.inputs[this.curIndex])
+    this.emit('selected', this.inputs[this.curIndex])
     return newIndex
   }
 
@@ -3884,4 +3946,93 @@ class PartyBanner extends DisplayElement {
   }
 }
 
+class NotesTextEditor extends TuiTextEditor {
+  constructor() {
+    super()
+
+    this.openedItem = null
+  }
+
+  keyPressed(keyBuf) {
+    if (input.isDeselectTextEditor(keyBuf)) {
+      this.emit('deselect')
+      return false
+    } else if (input.isSaveTextEditor(keyBuf)) {
+      this.saveManually()
+      return false
+    } else {
+      return super.keyPressed(keyBuf)
+    }
+  }
+
+  async openItem(item, {doubleCheckItem}) {
+    if (this.hasBeenEdited) {
+      // Save in the background.
+      this.save()
+    }
+
+    const textFile = getCorrespondingFileForItem(item, '.txt')
+    if (!textFile) {
+      this.openedItem = null
+      return false
+    }
+
+    if (textFile === this.openedItem) {
+      // This file is already open - don't do anything.
+      return null
+    }
+
+    let filePath
+    try {
+      filePath = url.fileURLToPath(new URL(textFile.url))
+    } catch (error) {
+      this.openedItem = null
+      return false
+    }
+
+    let buffer
+    try {
+      buffer = await readFile(filePath)
+    } catch (error) {
+      this.openedItem = null
+      return false
+    }
+
+    if (!doubleCheckItem(item)) {
+      return null
+    }
+
+    this.openedItem = textFile
+    this.openedPath = filePath
+    this.clearSourceAndLoadText(buffer.toString())
+    return true
+  }
+
+  async saveManually() {
+    if (!this.openedItem || !this.openedPath) {
+      return
+    }
+
+    const item = this.openedItem
+
+    if (await this.save()) {
+      if (item === this.openedItem) {
+        this.showStatusMessage('Saved manually.')
+      }
+    }
+  }
+
+  async save() {
+    const text = this.getSourceText()
+    try {
+      await writeFile(this.openedPath, text)
+      this.clearEditStatus()
+      return true
+    } catch (error) {
+      this.showStatusMessage(`Failed to save (${path.basename(this.openedPath)}: ${error.code}).`)
+      return false
+    }
+  }
+}
+
 module.exports = AppElement