« get me outta code hell

tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/ui/dialogs
diff options
context:
space:
mode:
Diffstat (limited to 'ui/dialogs')
-rw-r--r--ui/dialogs/CancelDialog.js60
-rw-r--r--ui/dialogs/ConfirmDialog.js76
-rw-r--r--ui/dialogs/Dialog.js55
-rw-r--r--ui/dialogs/FilePickerForm.js79
-rw-r--r--ui/dialogs/OpenFileDialog.js108
-rw-r--r--ui/dialogs/index.js16
6 files changed, 394 insertions, 0 deletions
diff --git a/ui/dialogs/CancelDialog.js b/ui/dialogs/CancelDialog.js
new file mode 100644
index 0000000..9069d43
--- /dev/null
+++ b/ui/dialogs/CancelDialog.js
@@ -0,0 +1,60 @@
+import {Button, Form} from 'tui-lib/ui/controls'
+import {Label, Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import telc from 'tui-lib/util/telchars'
+
+export default class CancelDialog extends FocusElement {
+  // A basic cancel dialog. Has one buttons, cancel, and a label.
+  // The escape (esc) key can be used to exit the dialog (which sends a
+  // 'cancelled' event, as the cancel button also does).
+
+  constructor(text) {
+    super()
+
+    this.pane = new Pane()
+    this.addChild(this.pane)
+
+    this.cancelBtn = new Button('Cancel')
+    this.pane.addChild(this.cancelBtn)
+
+    this.label = new Label(text)
+    this.pane.addChild(this.label)
+
+    this.initEventListeners()
+  }
+
+  initEventListeners() {
+    this.cancelBtn.on('pressed', () => this.cancelPressed())
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.h = this.parent.contentH
+
+    this.pane.w = Math.max(40, 4 + this.label.w)
+    this.pane.h = 7
+    this.pane.centerInParent()
+
+    this.label.x = Math.floor((this.pane.contentW - this.label.w) / 2)
+    this.label.y = 1
+
+    this.cancelBtn.x = Math.floor(
+      (this.pane.contentW - this.cancelBtn.w) / 2)
+    this.cancelBtn.y = this.pane.contentH - 2
+  }
+
+  selected() {
+    this.root.select(this.cancelBtn)
+  }
+
+  keyPressed(keyBuf) {
+    if (telc.isCancel(keyBuf)) {
+      this.emit('cancelled')
+    }
+  }
+
+  cancelPressed() {
+    this.emit('cancelled')
+  }
+}
diff --git a/ui/dialogs/ConfirmDialog.js b/ui/dialogs/ConfirmDialog.js
new file mode 100644
index 0000000..c0bcfae
--- /dev/null
+++ b/ui/dialogs/ConfirmDialog.js
@@ -0,0 +1,76 @@
+import {Button, Form} from 'tui-lib/ui/controls'
+import {Label, Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import telc from 'tui-lib/util/telchars'
+
+export default class ConfirmDialog extends FocusElement {
+  // A basic yes/no dialog. Has two buttons, confirm/cancel, and a label.
+  // The escape (esc) key can be used to exit the dialog (which sends a
+  // 'cancelled' event, as the cancel button also does).
+
+  constructor(text) {
+    super()
+
+    this.pane = new Pane()
+    this.addChild(this.pane)
+
+    this.form = new Form()
+    this.pane.addChild(this.form)
+
+    this.confirmBtn = new Button('Confirm')
+    this.form.addInput(this.confirmBtn)
+
+    this.cancelBtn = new Button('Cancel')
+    this.form.addInput(this.cancelBtn)
+
+    this.label = new Label(text)
+    this.form.addChild(this.label)
+
+    this.initEventListeners()
+  }
+
+  initEventListeners() {
+    this.confirmBtn.on('pressed', () => this.confirmPressed())
+    this.cancelBtn.on('pressed', () => this.cancelPressed())
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.h = this.parent.contentH
+
+    this.pane.w = Math.max(40, 2 + this.label.w)
+    this.pane.h = 7
+    this.pane.centerInParent()
+
+    this.form.w = this.pane.contentW
+    this.form.h = this.pane.contentH
+
+    this.label.x = Math.floor((this.form.contentW - this.label.w) / 2)
+    this.label.y = 1
+
+    this.confirmBtn.x = 1
+    this.confirmBtn.y = this.form.contentH - 2
+
+    this.cancelBtn.x = this.form.right - this.cancelBtn.w - 1
+    this.cancelBtn.y = this.form.contentH - 2
+  }
+
+  selected() {
+    this.root.select(this.form)
+  }
+
+  keyPressed(keyBuf) {
+    if (telc.isCancel(keyBuf)) {
+      this.emit('cancelled')
+    }
+  }
+
+  confirmPressed() {
+    this.emit('confirmed')
+  }
+
+  cancelPressed() {
+    this.emit('cancelled')
+  }
+}
diff --git a/ui/dialogs/Dialog.js b/ui/dialogs/Dialog.js
new file mode 100644
index 0000000..19565f5
--- /dev/null
+++ b/ui/dialogs/Dialog.js
@@ -0,0 +1,55 @@
+import {Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import telc from 'tui-lib/util/telchars'
+
+export default class Dialog extends FocusElement {
+  // A simple base dialog.
+  //
+  // Emits the 'cancelled' event when the cancel key (escape) is pressed,
+  // which should (probably) be handled by the dialog's creator.
+  //
+  // Doesn't do anything when focused by default - this should be overridden
+  // in subclasses.
+  //
+  // Automatically adjusts to fill its parent. Has a pane child (this.pane),
+  // but the pane isn't adjusted at all (you should change its size and
+  // likely center it in your subclass).
+
+  constructor() {
+    super()
+
+    this.pane = new Pane()
+    this.addChild(this.pane)
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.h = this.parent.contentH
+  }
+
+  open() {
+    this.oldSelectedElement = this.root.selectedElement
+    this.opened()
+    this.visible = true
+    this.root.select(this)
+    this.fixLayout()
+  }
+
+  close() {
+    this.closed()
+    this.visible = false
+    this.root.select(this.oldSelectedElement)
+  }
+
+  opened() {}
+
+  closed() {}
+
+  keyPressed(keyBuf) {
+    if (telc.isCancel(keyBuf)) {
+      this.emit('cancelled')
+      return false
+    }
+  }
+}
diff --git a/ui/dialogs/FilePickerForm.js b/ui/dialogs/FilePickerForm.js
new file mode 100644
index 0000000..6414818
--- /dev/null
+++ b/ui/dialogs/FilePickerForm.js
@@ -0,0 +1,79 @@
+import {readdir, stat} from 'node:fs/promises'
+import path from 'node:path'
+
+import {compare as naturalCompare} from 'natural-orderby'
+
+import {Button, ListScrollForm} from 'tui-lib/ui/controls'
+
+export default class FilePickerForm extends ListScrollForm {
+  fillItems(dirPath) {
+    this.inputs = []
+    this.children = []
+
+    const button = new Button('..Loading..')
+    this.addInput(button)
+    this.firstInput(false)
+
+    readdir(dirPath).then(
+      async items => {
+        this.removeInput(button)
+
+        const processedItems = await Promise.all(items.map(item => {
+          const itemPath = path.resolve(dirPath, item)
+          return stat(itemPath).then(s => {
+            return {
+              path: itemPath,
+              label: item + (s.isDirectory() ? '/' : ''),
+              isDirectory: s.isDirectory()
+            }
+          })
+        }))
+
+        const compare = naturalCompare()
+        processedItems.sort((a, b) => {
+          if (a.isDirectory === b.isDirectory) {
+            return compare(a.label, b.label)
+          } else {
+            if (a.isDirectory) {
+              return -1
+            } else {
+              return +1
+            }
+          }
+        })
+
+        processedItems.unshift({
+          path: path.resolve(dirPath, '..'),
+          label: '../',
+          isDirectory: true
+        })
+
+        let y = 0
+        for (const item of processedItems) {
+          const itemButton = new Button(item.label)
+          itemButton.y = y
+          y++
+          this.addInput(itemButton)
+
+          itemButton.on('pressed', () => {
+            if (item.isDirectory) {
+              this.emit('browsingDirectory', item.path)
+              this.fillItems(item.path)
+            } else {
+              this.emit('selected', item.path)
+            }
+          })
+        }
+
+        console.log('HALLO.', false)
+        this.firstInput(false)
+        this.fixLayout()
+      },
+      () => {
+        button.text = 'Failed to read path! (Cancel)'
+        button.on('pressed', () => {
+          this.emit('canceled')
+        })
+      })
+  }
+}
diff --git a/ui/dialogs/OpenFileDialog.js b/ui/dialogs/OpenFileDialog.js
new file mode 100644
index 0000000..970e291
--- /dev/null
+++ b/ui/dialogs/OpenFileDialog.js
@@ -0,0 +1,108 @@
+import path from 'node:path'
+
+import {Button, Form, TextInput} from 'tui-lib/ui/controls'
+import {Label} from 'tui-lib/ui/presentation'
+
+import Dialog from './Dialog.js'
+import FilePickerForm from './FilePickerForm.js'
+
+export default class OpenFileDialog extends Dialog {
+  constructor() {
+    super()
+
+    this.visible = false
+
+    this.form = new Form()
+    this.pane.addChild(this.form)
+
+    this.filePathLabel = new Label('Enter file path:')
+    this.filePathInput = new TextInput()
+    this.openButton = new Button('Open')
+    this.cancelButton = new Button('Cancel')
+
+    this.filePickerForm = new FilePickerForm()
+    this.filePickerForm.captureTab = false
+
+    this.form.addChild(this.filePathLabel)
+    this.form.addInput(this.filePathInput)
+    this.form.addInput(this.filePickerForm)
+    this.form.addInput(this.openButton)
+    this.form.addInput(this.cancelButton)
+
+    this._resolve = null
+
+    this.openButton.on('pressed', () => {
+      this._resolve(this.filePathInput.value)
+    })
+
+    this.filePathInput.on('value', () => {
+      this._resolve(this.filePathInput.value)
+    })
+
+    {
+      const cb = append => p => {
+        this.filePathInput.setValue((path.relative(process.cwd(), p) || '.') + append)
+      }
+
+      this.filePickerForm.on('selected', cb(''))
+      this.filePickerForm.on('browsingDirectory', cb('/'))
+    }
+
+    this.cancelButton.on('pressed', () => {
+      this._resolve(null)
+    })
+
+    const dir = (this.lastFilePath
+      ? path.relative(process.cwd(), path.dirname(this.lastFilePath)) + '/'
+      : './')
+
+    this.filePathInput.setValue(dir)
+    this.filePickerForm.fillItems(dir)
+  }
+
+  fixLayout() {
+    super.fixLayout()
+
+    this.pane.w = Math.min(this.contentW, 40)
+    this.pane.h = Math.min(this.contentH, 20)
+    this.pane.centerInParent()
+
+    this.form.w = this.pane.contentW
+    this.form.h = this.pane.contentH
+
+    this.filePathLabel.x = 0
+    this.filePathLabel.y = 0
+
+    this.filePathInput.x = this.filePathLabel.right + 2
+    this.filePathInput.y = this.filePathLabel.y
+    this.filePathInput.w = this.form.contentW - this.filePathInput.x
+
+    this.filePickerForm.x = 0
+    this.filePickerForm.y = this.filePathInput.y + 2
+    this.filePickerForm.w = this.form.contentW
+    this.filePickerForm.h = this.form.contentH - this.filePickerForm.y - 2
+
+    this.openButton.x = 0
+    this.openButton.y = this.form.contentH - 1
+
+    this.cancelButton.x = this.openButton.right + 2
+    this.cancelButton.y = this.openButton.y
+  }
+
+  selected() {
+    this.form.firstInput()
+  }
+
+  go() {
+    this.visible = true
+    this.root.select(this)
+
+    return new Promise(resolve => {
+      this._resolve = resolve
+    }).then(filePath => {
+      this.visible = false
+      this.lastFilePath = filePath
+      return filePath
+    })
+  }
+}
diff --git a/ui/dialogs/index.js b/ui/dialogs/index.js
new file mode 100644
index 0000000..5cb9f04
--- /dev/null
+++ b/ui/dialogs/index.js
@@ -0,0 +1,16 @@
+//
+// Import mapping:
+//
+//   controls, presentation, primitives ->
+//     CancelDialog
+//     ConfirmDialog
+//
+//     Dialog -> OpenFileDialog
+//     FilePickerForm -> OpenFileDialog
+//
+
+export {default as CancelDialog} from './CancelDialog.js'
+export {default as ConfirmDialog} from './ConfirmDialog.js'
+export {default as Dialog} from './Dialog.js'
+export {default as FilePickerForm} from './FilePickerForm.js'
+export {default as OpenFileDialog} from './OpenFileDialog.js'