diff options
| -rw-r--r-- | duet.el | 133 | ||||
| -rw-r--r-- | tests/test-duet-pane.el | 165 | ||||
| -rw-r--r-- | tests/test-duet-smoke.el | 9 |
3 files changed, 298 insertions, 9 deletions
@@ -721,11 +721,138 @@ ungated." (list :op 'delete :source s :gate 'copy-success))))) sources))) +;;; Commander mode, pane layout, and entry + +(defcustom duet-default-left-directory nil + "Directory shown in DUET's left pane at launch. +nil means the current directory when `duet' is invoked." + :type '(choice (const :tag "Current directory" nil) directory) + :group 'duet) + +(defcustom duet-default-right-directory nil + "Directory shown in DUET's right pane at launch. +nil means the current directory when `duet' is invoked." + :type '(choice (const :tag "Current directory" nil) directory) + :group 'duet) + +(defvar duet--saved-window-configuration nil + "Window configuration saved when DUET launched, restored by `duet-quit'.") + +;; The transfer/file actions land with their owning phases; until then they +;; announce themselves so the key is bound and its precedence is testable. + +(defun duet-view () + "View the file under point. Arrives with the viewer phase." + (interactive) + (user-error "DUET: view is not implemented yet")) + +(defun duet-edit () + "Edit the file under point. Arrives with the viewer phase." + (interactive) + (user-error "DUET: edit is not implemented yet")) + +(defun duet-copy () + "Copy the marked files to the other pane. Arrives with transfer execution." + (interactive) + (user-error "DUET: copy is not implemented yet")) + +(defun duet-move () + "Move the marked files to the other pane. Arrives with transfer execution." + (interactive) + (user-error "DUET: move is not implemented yet")) + +(defun duet-mkdir () + "Make a directory in the active pane. Arrives with transfer execution." + (interactive) + (user-error "DUET: mkdir is not implemented yet")) + +(defun duet-delete () + "Delete the marked files (to trash). Arrives with transfer execution." + (interactive) + (user-error "DUET: delete is not implemented yet")) + +(defun duet-quit () + "Close the DUET commander, restoring the window layout from before launch." + (interactive) + (if duet--saved-window-configuration + (progn + (set-window-configuration duet--saved-window-configuration) + (setq duet--saved-window-configuration nil)) + (message "DUET: no saved layout to restore"))) + +(defvar duet-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "<f3>") #'duet-view) + (define-key map (kbd "<f4>") #'duet-edit) + (define-key map (kbd "<f5>") #'duet-copy) + (define-key map (kbd "<f6>") #'duet-move) + (define-key map (kbd "<f7>") #'duet-mkdir) + (define-key map (kbd "<f8>") #'duet-delete) + (define-key map (kbd "<f10>") #'duet-quit) + map) + "Keymap active in DUET commander panes. +mc/Norton F-keys, taking precedence inside a pane only. dired's own chords +\(C, R, D, v, +) keep working as free aliases.") + +(define-minor-mode duet-mode + "Buffer-local minor mode marking a buffer as a DUET commander pane. +Carries `duet-mode-map', so the single-key F-key actions fire only inside a +commander pane and leave the global F-keys untouched elsewhere." + :lighter " Duet" + :keymap duet-mode-map) + +(defun duet--sibling-pane (window panes) + "Return the commander pane in PANES that is not WINDOW. +PANES is the list of commander windows. Signal a `user-error' unless there +are exactly two panes and WINDOW is one of them. This is the explicit +other-pane resolution that replaces `dired-dwim-target' (the dirvish#36 fix)." + (cond + ((/= (length panes) 2) + (user-error "DUET needs exactly two commander panes (found %d)" + (length panes))) + ((not (memq window panes)) + (user-error "Point is not in a DUET commander pane")) + (t (car (delq window (copy-sequence panes)))))) + +(defun duet--commander-windows () + "Return the live windows whose buffer has `duet-mode' enabled." + (cl-remove-if-not (lambda (w) (buffer-local-value 'duet-mode (window-buffer w))) + (window-list))) + +(defun duet--other-pane (&optional window) + "Return the sibling commander pane window to WINDOW (default selected)." + (duet--sibling-pane (or window (selected-window)) (duet--commander-windows))) + +(defun duet--pane-directory (window) + "Return the directory shown in commander pane WINDOW." + (with-current-buffer (window-buffer window) default-directory)) + +(defun duet--open-pane (directory) + "Open DIRECTORY in the selected window as a DUET commander pane. +The pane is a dired buffer with `duet-mode' enabled; when the user has dirvish +rendering dired buffers, it renders as dirvish with no extra work, which is why +dirvish is recommended but never required." + (let ((buffer (dired-noselect (expand-file-name directory)))) + (set-window-buffer (selected-window) buffer) + (with-current-buffer buffer (duet-mode 1)) + buffer)) + ;;;###autoload -(defun duet () - "Launch the DUET dual-pane file commander." +(defun duet (&optional left right) + "Launch the DUET dual-pane file commander. +Lay out two side-by-side commander panes: LEFT (or +`duet-default-left-directory', else the current directory) on the left and +RIGHT (or `duet-default-right-directory', else the current directory) on the +right. Each pane is a dired buffer with `duet-mode' enabled. `duet-quit' +\(F10) restores the window layout from before launch." (interactive) - (user-error "DUET is not yet implemented; see the design document")) + (setq duet--saved-window-configuration (current-window-configuration)) + (let ((left-dir (or left duet-default-left-directory default-directory)) + (right-dir (or right duet-default-right-directory default-directory))) + (delete-other-windows) + (duet--open-pane left-dir) + (with-selected-window (split-window-right) + (duet--open-pane right-dir)))) (provide 'duet) ;;; duet.el ends here diff --git a/tests/test-duet-pane.el b/tests/test-duet-pane.el new file mode 100644 index 0000000..181309b --- /dev/null +++ b/tests/test-duet-pane.el @@ -0,0 +1,165 @@ +;;; test-duet-pane.el --- Tests for the commander mode and pane layout -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for the batch-safe core of the pane layer: the pure sibling-pane +;; selector (the dirvish#36 fix without needing a live frame), the duet-mode +;; F-key map, and minor-mode keymap precedence. The two-window resolution is +;; also exercised against a real split frame. The live two-pane launch and the +;; F-key feel in a running daemon are Manual tests. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;;; Pure sibling-pane selection + +(ert-deftest test-duet-pane-sibling-returns-other-of-two () + "Given two panes, the sibling is the one that is not the active window." + (should (eq 'b (duet--sibling-pane 'a '(a b)))) + (should (eq 'a (duet--sibling-pane 'b '(a b))))) + +(ert-deftest test-duet-pane-sibling-errors-unless-exactly-two () + "Zero, one, or three commander panes is a clear error, not a guess." + (should-error (duet--sibling-pane 'a '(a)) :type 'user-error) + (should-error (duet--sibling-pane 'a '(a b c)) :type 'user-error)) + +(ert-deftest test-duet-pane-sibling-errors-when-window-not-a-pane () + "Asking for the sibling of a window that is not one of the panes errors." + (should-error (duet--sibling-pane 'x '(a b)) :type 'user-error)) + +;;; F-key map + +(ert-deftest test-duet-pane-fkeys-bound () + "The mc/Norton F-keys map to their DUET commands in `duet-mode-map'." + (should (eq 'duet-view (lookup-key duet-mode-map (kbd "<f3>")))) + (should (eq 'duet-edit (lookup-key duet-mode-map (kbd "<f4>")))) + (should (eq 'duet-copy (lookup-key duet-mode-map (kbd "<f5>")))) + (should (eq 'duet-move (lookup-key duet-mode-map (kbd "<f6>")))) + (should (eq 'duet-mkdir (lookup-key duet-mode-map (kbd "<f7>")))) + (should (eq 'duet-delete (lookup-key duet-mode-map (kbd "<f8>")))) + (should (eq 'duet-quit (lookup-key duet-mode-map (kbd "<f10>"))))) + +;;; Minor-mode precedence + +(ert-deftest test-duet-pane-mode-is-buffer-local () + "`duet-mode' is a buffer-local minor mode." + (with-temp-buffer + (duet-mode 1) + (should (bound-and-true-p duet-mode))) + (with-temp-buffer + (should-not (bound-and-true-p duet-mode)))) + +(ert-deftest test-duet-pane-fkey-precedence-inside-vs-outside () + "An F-key resolves to a DUET command inside a pane, but not outside one." + (with-temp-buffer + (duet-mode 1) + (should (eq 'duet-copy (key-binding (kbd "<f5>"))))) + (with-temp-buffer + (should-not (eq 'duet-copy (key-binding (kbd "<f5>")))))) + +;;; Not-yet-implemented action commands + +(ert-deftest test-duet-pane-unimplemented-actions-error () + "The transfer/file actions announce themselves until their phase lands." + (dolist (cmd '(duet-view duet-edit duet-copy duet-move duet-mkdir duet-delete)) + (should-error (funcall cmd) :type 'user-error))) + +;;; Launch, quit, and two-window resolution against a real frame + +(ert-deftest test-duet-pane-other-pane-resolves-sibling-window () + "With two commander panes, `duet--other-pane' returns the sibling window." + (let ((buf-a (generate-new-buffer " *duet-a*")) + (buf-b (generate-new-buffer " *duet-b*"))) + (unwind-protect + (progn + (delete-other-windows) + (set-window-buffer (selected-window) buf-a) + (with-current-buffer buf-a (duet-mode 1)) + (let ((win-b (split-window-right))) + (set-window-buffer win-b buf-b) + (with-current-buffer buf-b (duet-mode 1)) + (should (= 2 (length (duet--commander-windows)))) + (let ((other (duet--other-pane (selected-window)))) + (should (window-live-p other)) + (should (eq buf-b (window-buffer other)))))) + (delete-other-windows) + (kill-buffer buf-a) + (kill-buffer buf-b)))) + +(ert-deftest test-duet-launch-creates-two-commander-panes () + "Launching DUET lays out two side-by-side commander panes." + (let ((duet--saved-window-configuration nil)) + (unwind-protect + (progn + (delete-other-windows) + (duet "/tmp" "/tmp") + (should (= 2 (length (duet--commander-windows)))) + (should (window-live-p (duet--other-pane (selected-window))))) + (delete-other-windows)))) + +(ert-deftest test-duet-quit-restores-prior-layout () + "`duet-quit' restores the single-window layout DUET launched from." + (delete-other-windows) + (duet "/tmp" "/tmp") + (should (= 2 (length (duet--commander-windows)))) + (duet-quit) + (should (= 1 (length (window-list))))) + +(ert-deftest test-duet-pane-directory-reports-pane-dir () + "`duet--pane-directory' reports the directory a pane is showing." + (let ((duet--saved-window-configuration nil)) + (unwind-protect + (progn + (delete-other-windows) + (duet "/tmp" "/tmp") + (should (equal (expand-file-name "/tmp/") + (duet--pane-directory (selected-window))))) + (delete-other-windows)))) + +(ert-deftest test-duet-quit-without-launch-messages () + "Quitting with no saved layout messages rather than erroring." + (let ((duet--saved-window-configuration nil)) + (duet-quit) + (should-not duet--saved-window-configuration))) + +(ert-deftest test-duet-other-pane-defaults-to-selected-window () + "Called with no argument, `duet--other-pane' resolves from the selected window." + (let ((duet--saved-window-configuration nil)) + (unwind-protect + (progn + (delete-other-windows) + (duet "/tmp" "/tmp") + (should (window-live-p (duet--other-pane)))) + (delete-other-windows)))) + +(ert-deftest test-duet-launch-defaults-to-current-directory () + "With no directory arguments, both panes open on the current directory." + (let ((duet--saved-window-configuration nil) + (default-directory "/tmp/")) + (unwind-protect + (progn + (delete-other-windows) + (duet) + (should (= 2 (length (duet--commander-windows))))) + (delete-other-windows)))) + +(provide 'test-duet-pane) +;;; test-duet-pane.el ends here diff --git a/tests/test-duet-smoke.el b/tests/test-duet-smoke.el index 4f88a0e..07d75ea 100644 --- a/tests/test-duet-smoke.el +++ b/tests/test-duet-smoke.el @@ -28,13 +28,10 @@ (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (ert-deftest test-duet-smoke-feature-loaded () - "The package source loads and defines its entry command." + "The package source loads and defines its entry command and commander mode." (should (featurep 'duet)) - (should (commandp 'duet))) - -(ert-deftest test-duet-smoke-entry-not-yet-implemented () - "The entry command errors until the pane layout lands (Phase 4)." - (should-error (duet))) + (should (commandp 'duet)) + (should (fboundp 'duet-mode))) (provide 'test-duet-smoke) ;;; test-duet-smoke.el ends here |
