branch: elpa/casual
commit 02de27d7cb9451c85fe77ff35927543e8ffa45eb
Author: Charles Choi <[email protected]>
Commit: Charles Choi <[email protected]>

    Add CSV mode support
    
    This change adds support for Emacs `csv-mode`.
    
    Includes workaround fix for timezone calculation for test regression.
---
 docs/casual.info                                   | Bin 139918 -> 143571 bytes
 docs/casual.org                                    |   4 +-
 docs/casual.texi                                   | 108 ++++++++++++++-
 docs/csv.org                                       |  80 +++++++++++
 docs/images/casual-csv-align-screenshot.png        | Bin 0 -> 38846 bytes
 docs/images/casual-csv-edit-screenshot.png         | Bin 0 -> 203137 bytes
 docs/images/casual-csv-settings-screenshot.png     | Bin 0 -> 71135 bytes
 docs/images/casual-csv-view-screenshot.png         | Bin 0 -> 68512 bytes
 docs/images/casual-csv-view-unicode-screenshot.png | Bin 0 -> 63214 bytes
 lisp/Makefile                                      |   5 +
 lisp/Makefile-csv.make                             |  32 +++++
 lisp/casual-csv-settings.el                        | 146 +++++++++++++++++++++
 lisp/casual-csv-utils.el                           | 105 +++++++++++++++
 lisp/casual-csv.el                                 | 135 +++++++++++++++++++
 lisp/casual-lib.el                                 |  26 ++++
 templates/lisp/casual-MODULE.el                    |   2 +-
 templates/tests/test-casual-MODULE-settings.el     |   8 +-
 tests/Makefile                                     |   5 +
 tests/casual-csv-test-utils.el                     |  39 ++++++
 tests/test-casual-csv-settings.el                  |  68 ++++++++++
 tests/test-casual-csv-utils.el                     |  74 +++++++++++
 tests/test-casual-csv.el                           |  91 +++++++++++++
 tests/test-casual-timezone-utils.el                |   6 +-
 23 files changed, 923 insertions(+), 11 deletions(-)

diff --git a/docs/casual.info b/docs/casual.info
index 4b1f8889ea..4797f91e00 100644
Binary files a/docs/casual.info and b/docs/casual.info differ
diff --git a/docs/casual.org b/docs/casual.org
index 1ed82fcee6..7a2bccd7f2 100644
--- a/docs/casual.org
+++ b/docs/casual.org
@@ -5,7 +5,7 @@
 #+EMAIL: [email protected]
 #+OPTIONS: ':t toc:t author:t email:t H:4 f:t
 #+LANGUAGE: en
-#+MACRO: version 2.10.2
+#+MACRO: version 2.11.0
 #+MACRO: kbd (eval (org-texinfo-kbd-macro $1))
 #+TEXINFO_FILENAME: casual.info
 #+TEXINFO_CLASS: casual
@@ -147,6 +147,7 @@ Configuration of a particular Casual user interface is 
performed per mode. Go to
 - [[#calc-install][Calc]]
 - [[#calendar-install][Calendar]]
 - [[#compile-install][Compile (Grep)]]
+- [[#csv-install][CSV]]
 - [[#dired-install][Dired]]
 - [[#ediff-install][Ediff]]
 - [[#editkit-install][EditKit]]
@@ -260,6 +261,7 @@ The following modes are supported by Casual:
 #+INCLUDE: "./calc.org" :minlevel 2
 #+INCLUDE: "./calendar.org" :minlevel 2
 #+INCLUDE: "./compile.org" :minlevel 2
+#+INCLUDE: "./csv.org" :minlevel 2
 #+INCLUDE: "./dired.org" :minlevel 2
 #+INCLUDE: "./ediff.org" :minlevel 2
 #+INCLUDE: "./editkit.org" :minlevel 2
diff --git a/docs/casual.texi b/docs/casual.texi
index 928a0c44ea..edfa7b0b90 100644
--- a/docs/casual.texi
+++ b/docs/casual.texi
@@ -20,7 +20,7 @@ Copyright © 2024-2025 Charles Y@. Choi
 @finalout
 @titlepage
 @title Casual User Guide
-@subtitle for version 2.10.2
+@subtitle for version 2.11.0
 @author Charles Y@. Choi (@email{kickingvegas@@gmail.com})
 @page
 @vskip 0pt plus 1filll
@@ -33,7 +33,7 @@ Copyright © 2024-2025 Charles Y@. Choi
 @node Top
 @top Casual User Guide
 
-Version: 2.10.2
+Version: 2.11.0
 
 Casual is a project to re-imagine the primary user interface for Emacs using 
keyboard-driven menus.
 
@@ -105,6 +105,7 @@ Casual Modes
 * Calc::
 * Calendar::
 * Compile::
+* CSV::
 * Dired::
 * Ediff::
 * EditKit::
@@ -163,6 +164,11 @@ Compile
 * Compile Install::
 * Compile Usage::
 
+CSV
+
+* CSV Install::
+* CSV Usage::
+
 Dired
 
 * Dired Requirements::
@@ -446,6 +452,8 @@ Configuration of a particular Casual user interface is 
performed per mode. Go to
 @item
 @ref{Compile Install, , Compile (Grep)}
 @item
+@ref{CSV Install, , CSV}
+@item
 @ref{Dired Install, , Dired}
 @item
 @ref{Ediff Install, , Ediff}
@@ -608,6 +616,7 @@ The following modes are supported by Casual:
 * Calc::
 * Calendar::
 * Compile::
+* CSV::
 * Dired::
 * Ediff::
 * EditKit::
@@ -1418,6 +1427,101 @@ If the output window is from a Grep command, 
@code{casual-compile-tmenu} will ad
 
 By enabling “@kbd{u} Use Unicode Symbols” from the Settings menu, Casual 
Compile will use Unicode symbols as appropriate in its menus.
 
+@node CSV
+@section CSV
+
+@cindex CSV
+@vindex casual-csv-tmenu
+
+Casual CSV is a user interface for @code{csv-mode}, a mode for working with 
CSV files.
+
+@image{images/casual-csv-edit-screenshot,,,,png}
+
+@menu
+* CSV Install::
+* CSV Usage::
+@end menu
+
+@node CSV Install
+@subsection CSV Install
+
+@cindex CSV Install
+
+In your initialization file, bind the Transient @code{casual-csv-tmenu} to 
your key binding of preference.
+
+@lisp
+(keymap-set csv-mode-map "M-m" #'casual-csv-tmenu)
+@end lisp
+
+While not required, the following configuration is recommended for working 
with CSV files.
+
+@lisp
+;; disable line wrap
+(add-hook 'csv-mode-hook
+          (lambda ()
+            (visual-line-mode -1)
+            (toggle-truncate-lines 1)))
+
+;; auto detect separator
+(add-hook 'csv-mode-hook #'csv-guess-set-separator)
+;; turn on field alignment
+(add-hook 'csv-mode-hook #'csv-align-mode)
+@end lisp
+
+@node CSV Usage
+@subsection CSV Usage
+
+@cindex CSV Usage
+
+@image{images/casual-csv-edit-screenshot,,,,png}
+
+The following sections are offered in the menu:
+
+@table @asis
+@item Navigation, Line, Buffer
+Commands for moving the point, mostly with respect to a field.
+@item Page
+Move up or down a page.
+@item Buffer/File
+Commands associated the current buffer or file, such changing the buffer state 
from viewable (read-only) to editable (writeable), display alignment, or 
duplicating the file for subsequent editing.
+@item Field
+Commands to mark or copy a field.
+@item Sort
+Sorting commands. This section is displayed only if the buffer is editable.
+@item Fields
+Kill and yank commands dedicated for CSV mode. Note that these commands do 
@emph{not} use the default @code{kill-ring} and are marked with a bullet (•). 
This section is displayed only if the buffer is editable.
+@item Misc
+Miscellaneous commands. Note if a region is selected containing multiple 
complete rows, the “@kbd{C} Copy as Table” command will reformat the selected 
rows as an Org table and copy them in the @code{kill-ring} for subsequent 
pasting.
+@end table
+
+@subheading CVS View/Edit, Duplicate
+
+If the buffer is in view (read-only) mode, then only relevant commands are 
displayed.
+
+@image{images/casual-csv-view-screenshot,,,,png}
+
+If the buffer is editable, a common to desire to instead work on a copy of the 
CSV file to avoid making unwanted changes. This can be done using the “@kbd{d} 
Duplicate” command.
+
+@subheading CSV Align
+@vindex casual-csv-align-tmenu
+
+The display of the CSV buffer can be controlled with this menu.
+
+@image{images/casual-csv-align-screenshot,,,,png}
+
+
+@subheading CSV Settings
+@vindex casual-csv-settings-tmenu
+
+@image{images/casual-csv-settings-screenshot,,,,png}
+
+
+@subheading CSV Unicode Symbol Support
+
+By enabling “@kbd{u} Use Unicode Symbols” from the Settings menu, Casual CSV 
will use Unicode symbols as appropriate in its menus.
+
+@image{images/casual-csv-view-unicode-screenshot,,,,png}
+
 @node Dired
 @section Dired
 
diff --git a/docs/csv.org b/docs/csv.org
new file mode 100644
index 0000000000..26dcc84688
--- /dev/null
+++ b/docs/csv.org
@@ -0,0 +1,80 @@
+* CSV
+#+CINDEX: CSV
+#+VINDEX: casual-csv-tmenu
+
+Casual CSV is a user interface for ~csv-mode~, a mode for working with CSV 
files.
+
+[[file:images/casual-csv-edit-screenshot.png]]
+
+** CSV Install
+:PROPERTIES:
+:CUSTOM_ID: csv-install
+:END:
+
+#+CINDEX: CSV Install
+
+In your initialization file, bind the Transient ~casual-csv-tmenu~ to your key 
binding of preference.
+
+#+begin_src elisp :lexical no
+  (keymap-set csv-mode-map "M-m" #'casual-csv-tmenu)
+#+end_src
+
+While not required, the following configuration is recommended for working 
with CSV files.
+
+#+BEGIN_SRC elisp :lexical no
+  ;; disable line wrap
+  (add-hook 'csv-mode-hook
+            (lambda ()
+              (visual-line-mode -1)
+              (toggle-truncate-lines 1)))
+
+  ;; auto detect separator
+  (add-hook 'csv-mode-hook #'csv-guess-set-separator)
+  ;; turn on field alignment
+  (add-hook 'csv-mode-hook #'csv-align-mode)
+#+END_SRC
+
+** CSV Usage
+#+CINDEX: CSV Usage
+
+[[file:images/casual-csv-edit-screenshot.png]]
+
+The following sections are offered in the menu:
+
+- Navigation, Line, Buffer :: Commands for moving the point, mostly with 
respect to a field.
+- Page :: Move up or down a page.
+- Buffer/File :: Commands associated the current buffer or file, such changing 
the buffer state from viewable (read-only) to editable (writeable), display 
alignment, or duplicating the file for subsequent editing.
+- Field :: Commands to mark or copy a field.
+- Sort :: Sorting commands. This section is displayed only if the buffer is 
editable.
+- Fields :: Kill and yank commands dedicated for CSV mode. Note that these 
commands do /not/ use the default ~kill-ring~ and are marked with a bullet (•). 
This section is displayed only if the buffer is editable.
+- Misc :: Miscellaneous commands. Note if a region is selected containing 
multiple complete rows, the “{{{kbd(C)}}} Copy as Table” command will reformat 
the selected rows as an Org table and copy them in the ~kill-ring~ for 
subsequent pasting.
+  
+#+TEXINFO: @subheading CVS View/Edit, Duplicate
+
+If the buffer is in view (read-only) mode, then only relevant commands are 
displayed.
+
+[[file:images/casual-csv-view-screenshot.png]]
+
+If the buffer is editable, a common to desire to instead work on a copy of the 
CSV file to avoid making unwanted changes. This can be done using the 
“{{{kbd(d)}}} Duplicate” command.
+
+#+TEXINFO: @subheading CSV Align
+#+VINDEX: casual-csv-align-tmenu
+
+The display of the CSV buffer can be controlled with this menu.
+
+[[file:images/casual-csv-align-screenshot.png]]
+
+
+#+TEXINFO: @subheading CSV Settings
+#+VINDEX: casual-csv-settings-tmenu
+
+[[file:images/casual-csv-settings-screenshot.png]]
+
+
+#+TEXINFO: @subheading CSV Unicode Symbol Support
+
+By enabling “{{{kbd(u)}}} Use Unicode Symbols” from the Settings menu, Casual 
CSV will use Unicode symbols as appropriate in its menus.
+
+[[file:images/casual-csv-view-unicode-screenshot.png]]
+
+
diff --git a/docs/images/casual-csv-align-screenshot.png 
b/docs/images/casual-csv-align-screenshot.png
new file mode 100644
index 0000000000..56f95f3443
Binary files /dev/null and b/docs/images/casual-csv-align-screenshot.png differ
diff --git a/docs/images/casual-csv-edit-screenshot.png 
b/docs/images/casual-csv-edit-screenshot.png
new file mode 100644
index 0000000000..006e8cfc88
Binary files /dev/null and b/docs/images/casual-csv-edit-screenshot.png differ
diff --git a/docs/images/casual-csv-settings-screenshot.png 
b/docs/images/casual-csv-settings-screenshot.png
new file mode 100644
index 0000000000..7b02ef0209
Binary files /dev/null and b/docs/images/casual-csv-settings-screenshot.png 
differ
diff --git a/docs/images/casual-csv-view-screenshot.png 
b/docs/images/casual-csv-view-screenshot.png
new file mode 100644
index 0000000000..64bcee2700
Binary files /dev/null and b/docs/images/casual-csv-view-screenshot.png differ
diff --git a/docs/images/casual-csv-view-unicode-screenshot.png 
b/docs/images/casual-csv-view-unicode-screenshot.png
new file mode 100644
index 0000000000..b406054c7d
Binary files /dev/null and b/docs/images/casual-csv-view-unicode-screenshot.png 
differ
diff --git a/lisp/Makefile b/lisp/Makefile
index b65da829cc..ab5c1fe54d 100644
--- a/lisp/Makefile
+++ b/lisp/Makefile
@@ -25,6 +25,7 @@ bookmarks-tests                                       \
 calc-tests                                     \
 calendar-tests                                 \
 compile-tests                                  \
+csv-tests                                      \
 dired-tests                                    \
 ediff-tests                                    \
 editkit-tests                                  \
@@ -67,6 +68,10 @@ calendar-tests:
 compile-tests:
        $(MAKE) -C $(SRC_DIR) -f Makefile-compile.make tests
 
+.PHONY: csv-tests
+csv-tests:
+       $(MAKE) -C $(SRC_DIR) -f Makefile-csv.make tests
+
 .PHONY: dired-tests
 dired-tests:
        $(MAKE) -C $(SRC_DIR) -f Makefile-dired.make tests
diff --git a/lisp/Makefile-csv.make b/lisp/Makefile-csv.make
new file mode 100644
index 0000000000..8709566e06
--- /dev/null
+++ b/lisp/Makefile-csv.make
@@ -0,0 +1,32 @@
+##
+# Copyright (C) 2025 Charles Y. Choi
+#
+# 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 <https://www.gnu.org/licenses/>.
+
+include Makefile--defines.make
+
+PACKAGE_NAME=casual-csv
+ELISP_INCLUDES=casual-csv-utils.el             \
+casual-csv-settings.el
+ELISP_PACKAGES=
+ELISP_TEST_INCLUDES=casual-csv-test-utils.el
+PACKAGE_PATHS=                                 \
+-L $(EMACS_ELPA_DIR)/compat-current            \
+-L $(EMACS_ELPA_DIR)/seq-current               \
+-L $(EMACS_ELPA_DIR)/transient-current         \
+-L $(EMACS_ELPA_DIR)/cond-let-current          \
+-L $(EMACS_ELPA_DIR)/csv-mode-current          \
+-L $(CASUAL_LIB_LISP_DIR)
+
+include Makefile--rules.make
diff --git a/lisp/casual-csv-settings.el b/lisp/casual-csv-settings.el
new file mode 100644
index 0000000000..35564fea11
--- /dev/null
+++ b/lisp/casual-csv-settings.el
@@ -0,0 +1,146 @@
+;;; casual-csv-settings.el --- Casual CSV Settings -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+
+;;; Code:
+(require 'csv-mode)
+(require 'casual-lib)
+
+(transient-define-prefix casual-csv-settings-tmenu ()
+  "Casual csv settings menu."
+  ["Casual csv: Settings"
+   ["Customize"
+    ("A" "Align Style" casual-csv--customize-align-style
+     :description (lambda () (format "Align Style (%s)"
+                                (capitalize (symbol-name csv-align-style)))))
+    ("s" "Separators" casual-csv--customize-separators)
+    ("i" "Invisibility Default" casual-csv--customize-invisibility-default
+     :description (lambda () (casual-lib-checkbox-label 
csv-invisibility-default
+                                                   "Invisibility Default")))]
+
+   [""
+    ("G" "CSV Group" casual-csv--customize-group)
+    ("h" "Header Lines" casual-csv--customize-header-lines
+     :description (lambda () (format "Header Lines (%d)" csv-header-lines)))
+    ("c" "Comment Start Default" casual-csv--customize-comment-start-default
+     :description (lambda () (format
+                         "Comment Start Default (%s)"
+                         csv-comment-start-default)))
+    ("f" "Field Quotes" casual-csv--customize-field-quotes
+     :description (lambda () (format
+                         "Field Quotes (%s)"
+                         (string-join csv-field-quotes))))]
+
+   ["Width"
+    ("w" "Min" casual-csv--customize-align-min-width
+     :description (lambda () (format "Min (%d)" csv-align-min-width)))
+    ("W" "Max" casual-csv--customize-align-max-width
+     :description (lambda () (format "Max (%d)" csv-align-max-width)))]]
+
+  [:class transient-row
+   (casual-lib-customize-unicode)
+   (casual-lib-customize-hide-navigation)]
+
+  [:class transient-row
+   (casual-lib-quit-one)
+   ("a" "About" casual-csv-about)
+   (casual-lib-quit-all)])
+
+
+;; -------------------------------------------------------------------
+;; Functions
+
+(defun casual-csv--customize-group ()
+  "Customize csv group."
+  (interactive)
+  (customize-group "CSV"))
+
+(defun casual-csv--customize-align-style ()
+  "Customize `csv-align-style'."
+  (interactive)
+  (customize-variable 'csv-align-style))
+
+(defun casual-csv--customize-separators ()
+  "Customize `csv-separators'."
+  (interactive)
+  (customize-variable 'csv-separators))
+
+(defun casual-csv--customize-field-quotes ()
+  "Customize `csv-field-quotes'."
+  (interactive)
+  (customize-variable 'csv-field-quotes))
+
+(defun casual-csv--customize-align-max-width ()
+  "Customize `csv-align-max-width'."
+  (interactive)
+  (customize-variable 'csv-align-max-width))
+
+(defun casual-csv--customize-align-min-width ()
+  "Customize `csv-align-min-width'."
+  (interactive)
+  (customize-variable 'csv-align-min-width))
+
+(defun casual-csv--customize-invisibility-default ()
+  "Customize `csv-invisibility-default'."
+  (interactive)
+  (customize-variable 'csv-invisibility-default))
+
+(defun casual-csv--customize-comment-start-default ()
+  "Customize `csv-comment-start-default'."
+  (interactive)
+  (customize-variable 'csv-comment-start-default))
+
+(defun casual-csv--customize-header-lines ()
+  "Customize `csv-comment-header-lines'."
+  (interactive)
+  (customize-variable 'csv-header-lines))
+
+(defun casual-csv-about-csv ()
+  "Casual csv is a Transient menu for csv pages.
+
+Learn more about using Casual csv at our discussion group on GitHub.
+Any questions or comments about it should be made there.
+URL `https://github.com/kickingvegas/casual/discussions'
+
+If you find a bug or have an enhancement request, please file an issue.
+Our best effort will be made to answer it.
+URL `https://github.com/kickingvegas/casual/issues'
+
+If you enjoy using Casual csv, consider making a modest financial
+contribution to help support its development and maintenance.
+URL `https://www.buymeacoffee.com/kickingvegas'
+
+Casual csv was conceived and crafted by Charles Choi in San Francisco,
+California.
+
+Thank you for using Casual csv.
+
+Always choose love."
+  (ignore))
+
+(defun casual-csv-about ()
+  "About information for Casual csv."
+  (interactive)
+  (describe-function #'casual-csv-about-csv))
+
+(provide 'casual-csv-settings)
+;;; casual-csv-settings.el ends here
diff --git a/lisp/casual-csv-utils.el b/lisp/casual-csv-utils.el
new file mode 100644
index 0000000000..4569351d3e
--- /dev/null
+++ b/lisp/casual-csv-utils.el
@@ -0,0 +1,105 @@
+;;; casual-csv-utils.el --- Casual CSV Utils -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+
+;;; Code:
+(require 'csv-mode)
+(require 'casual-lib)
+(require 'org-table)
+
+(defconst casual-csv-unicode-db
+  '((:up . '("↑" "Up"))
+    (:down . '("↓" "Down"))
+    (:right . '("→" "Right"))
+    (:left . '("←" "Left"))
+    (:bol . '("⇤" "Begin"))
+    (:eol . '("⇥" "End"))
+    (:beginning-of-buffer . '("⇱" "Begin"))
+    (:end-of-buffer . '("⇲" "End")))
+  "Unicode symbol DB to use for CSV Transient menus.")
+
+(defun casual-csv-unicode-get (key)
+  "Lookup Unicode symbol for KEY in DB.
+
+- KEY symbol used to lookup Unicode symbol in DB.
+
+If the value of customizable variable `casual-lib-use-unicode'
+is non-nil, then the Unicode symbol is returned, otherwise a
+plain ASCII-range string."
+  (casual-lib-unicode-db-get key casual-csv-unicode-db))
+
+(defun casual-csv-kill-region-as-org-table (start end)
+  "Copy CSV region at START, END as Org table in the `kill-ring'."
+  (interactive "r")
+  (let ((buf (buffer-substring start end)))
+    (with-temp-buffer
+      (insert buf)
+      (org-table-convert-region (point-min) (point-max))
+      (kill-region (point-min) (point-max)))))
+
+(defun casual-csv-align-auto ()
+  "Auto align CSV fields."
+  (interactive)
+  (setopt csv-align-style 'auto)
+  (call-interactively #'csv-align-fields))
+
+(defun casual-csv-align-left ()
+  "Left align CSV fields."
+  (interactive)
+  (setopt csv-align-style 'left)
+  (call-interactively #'csv-align-fields))
+
+(defun casual-csv-align-centre ()
+  "Centre align CSV fields."
+  (interactive)
+  (setopt csv-align-style 'centre)
+  (call-interactively #'csv-align-fields))
+
+(defun casual-csv-align-right ()
+  "Right align CSV fields."
+  (interactive)
+  (setopt csv-align-style 'right)
+  (call-interactively #'csv-align-fields))
+
+
+;; -------------------------------------------------------------------
+;; Transients
+(transient-define-prefix casual-csv-align-tmenu ()
+  ["Align"
+   :description (lambda () (format
+                       "Casual CSV Align: %s %s"
+                       (buffer-name)
+                       (capitalize (symbol-name csv-align-style))))
+   :class transient-row
+   ("a" "Auto" casual-csv-align-auto :transient t)
+   ("l" "Left" casual-csv-align-left :transient t)
+   ("c" "Centre" casual-csv-align-centre :transient t)
+   ("r" "Right" casual-csv-align-right :transient t)
+   ("t" "Toggle" csv-align-mode :transient t)]
+
+  [:class transient-row
+          (casual-lib-quit-one)
+          ("RET" "Dismiss" casual-lib-quit-all)
+          (casual-lib-quit-all)])
+
+(provide 'casual-csv-utils)
+;;; casual-csv-utils.el ends here
diff --git a/lisp/casual-csv.el b/lisp/casual-csv.el
new file mode 100644
index 0000000000..2b259e69bb
--- /dev/null
+++ b/lisp/casual-csv.el
@@ -0,0 +1,135 @@
+;;; casual-csv.el --- Transient UI for CSV mode -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This library provides a Transient-based user interface for `csv-mode'.
+
+;; INSTALLATION
+
+;; In your initialization file, bind the Transient `casual-csv-tmenu' to your
+;; key binding of preference.
+
+;; (require 'casual-csv) ; optional if using autoloaded menu
+;; (keymap-set csv-mode-map "M-m" #'casual-csv-tmenu)
+
+;; While not required, the following configuration is recommended for working
+;; with CSV files.
+
+;; (add-hook 'csv-mode-hook
+;;           (lambda ()
+;;             (visual-line-mode -1)
+;;             (toggle-truncate-lines 1)))
+
+;; (add-hook 'csv-mode-hook #'csv-guess-set-separator)
+;; (add-hook 'csv-mode-hook #'csv-align-mode)
+
+;;; Code:
+(require 'casual-editkit-utils)
+(require 'casual-csv-settings)
+(require 'casual-csv-utils)
+
+;;;###autoload (autoload 'casual-csv-tmenu "casual-csv" nil t)
+(transient-define-prefix casual-csv-tmenu ()
+  :refresh-suffixes t
+  ["Casual CSV"
+   :description (lambda () (format
+                       "Casual CSV: %s [%d,%d] %s"
+                       (buffer-name)
+                       (line-number-at-pos)
+                       (csv--field-index)
+                       (capitalize (symbol-name csv-align-style))))
+   :pad-keys t
+
+   ["Navigation"
+    ("S-TAB" "←" csv-backtab-command
+     :description (lambda () (format "%s" (casual-csv-unicode-get :left)))
+     :transient t)
+    ("TAB" "→" csv-tab-command
+     :description (lambda () (format "%s" (casual-csv-unicode-get :right)))
+     :transient t)]
+   [""
+    ("p" "↑" previous-line
+     :description (lambda () (format "%s" (casual-csv-unicode-get :up)))
+     :transient t)
+    ("n" "↓" next-line
+     :description (lambda () (format "%s" (casual-csv-unicode-get :down)))
+     :transient t)]
+   ["Line"
+    ("C-a" "⇤" move-beginning-of-line
+     :description (lambda () (format "%s" (casual-csv-unicode-get :bol)))
+     :transient t)
+    ("C-e" "⇥" move-end-of-line
+     :description (lambda () (format "%s" (casual-csv-unicode-get :eol)))
+     :transient t)]
+   ["Buffer"
+    ("<" "⇱" beginning-of-buffer
+     :description (lambda () (format "%s" (casual-csv-unicode-get 
:beginning-of-buffer)))
+     :transient t)
+    (">" "⇲" end-of-buffer
+     :description (lambda () (format "%s" (casual-csv-unicode-get 
:end-of-buffer)))
+     :transient t)]
+
+   ["Page"
+    ("M-v" "Up" scroll-down-command :transient t)
+    ("C-v" "Down" scroll-up-command :transient t)]
+
+   ["Buffer/File"
+    ("a" "Align›" casual-csv-align-tmenu)
+    ("v" "View" view-mode
+     :if (lambda () (not buffer-read-only))
+     :transient t)
+    ("e" "Edit" View-exit
+     :if (lambda () buffer-read-only)
+     :transient t)
+    ("d" "Duplicate" casual-lib-duplicate-file)]]
+
+  [["Field"
+    :pad-keys t
+    ("m" "Mark" mark-sexp)
+    ("c" "Copy" casual-editkit-copy-sexp)]
+
+   ["Sort"
+    :if (lambda () (not buffer-read-only))
+    ("s" "Fields" csv-sort-fields)
+    ("N" "Numeric" csv-sort-numeric-fields)
+    ("r" "Reverse" csv-reverse-region)]
+
+   ["Fields"
+    :if (lambda () (not buffer-read-only))
+    ("k" "Kill∙" csv-kill-fields)
+    ("y" "Yank∙" csv-yank-fields)]
+
+   ["Misc"
+    ("t" "Transpose" csv-transpose
+     :if (lambda () (not buffer-read-only)))
+    ("S" "Separator…" csv-set-separator)
+    ("o" "Occur…" occur)
+    ("C" "Copy as Table" casual-csv-kill-region-as-org-table
+     :inapt-if-not use-region-p)]]
+
+  [:class transient-row
+   (casual-lib-quit-one)
+   ("," "Settings" casual-csv-settings-tmenu)
+   ("q" "Quit" quit-window)
+   (casual-lib-quit-all)])
+
+(provide 'casual-csv)
+;;; casual-csv.el ends here
diff --git a/lisp/casual-lib.el b/lisp/casual-lib.el
index 7efb4d8017..fe56ea5abb 100644
--- a/lisp/casual-lib.el
+++ b/lisp/casual-lib.el
@@ -144,6 +144,32 @@ V is either nil or non-nil."
   (forward-paragraph)
   (forward-line))
 
+(defun casual-lib-duplicate-file (&optional arg)
+  "Duplicate the current file with prefix option ARG.
+
+This command will duplicate and open the current file in the buffer to a
+filename of the form “<filename> copy.<extension>”. If the current
+buffer is modified, a prompt will be raised to save it before making the
+duplicate copy.
+
+By default this command will immediate open the duplicate file into a
+new buffer. This can be avoided if a prefix ARG is provided."
+  (interactive "P")
+  (if (and (buffer-modified-p) (y-or-n-p "Save buffer? "))
+      (save-buffer))
+
+  (let ((filename (buffer-file-name)))
+    (unless filename
+      (error "This command only works on a file."))
+
+    (let* ((extension (file-name-extension filename t))
+           (target (format "%s copy%s"
+                           (file-name-sans-extension filename)
+                           extension)))
+      (copy-file filename target)
+      (if (not arg)
+          (find-file target)))))
+
 ;; Transients
 (transient-define-suffix casual-lib-quit-all ()
   "Casual suffix to call `transient-quit-all'."
diff --git a/templates/lisp/casual-MODULE.el b/templates/lisp/casual-MODULE.el
index d232b3300f..0bc40c3344 100644
--- a/templates/lisp/casual-MODULE.el
+++ b/templates/lisp/casual-MODULE.el
@@ -43,7 +43,7 @@
    (casual-lib-quit-one)
    ("," "Settings" casual-$MODULE-settings-tmenu)
    ;; ("I" "ⓘ" casual-$MODULE-info)
-   ;; ("q" "Quit" quit-window)
+   ("q" "Quit" quit-window)
    (casual-lib-quit-all)])
 
 (provide 'casual-$MODULE)
diff --git a/templates/tests/test-casual-MODULE-settings.el 
b/templates/tests/test-casual-MODULE-settings.el
index d50632f5b4..a5b55b8ba4 100644
--- a/templates/tests/test-casual-MODULE-settings.el
+++ b/templates/tests/test-casual-MODULE-settings.el
@@ -34,10 +34,10 @@
               (casualt-mock #'casual-$MODULE-about))
 
       (let ((test-vectors
-             '((:binding "G" :com$MODULEd casual-$MODULE--customize-group)
-               (:binding "u" :com$MODULEd 
casual-lib-customize-casual-lib-use-unicode)
-               (:binding "n" :com$MODULEd 
casual-lib-customize-casual-lib-hide-navigation)
-               (:binding "a" :com$MODULEd casual-$MODULE-about))))
+             '((:binding "G" :command casual-$MODULE--customize-group)
+               (:binding "u" :command 
casual-lib-customize-casual-lib-use-unicode)
+               (:binding "n" :command 
casual-lib-customize-casual-lib-hide-navigation)
+               (:binding "a" :command casual-$MODULE-about))))
 
         (casualt-suffix-testcase-runner test-vectors
                                         #'casual-$MODULE-settings-tmenu
diff --git a/tests/Makefile b/tests/Makefile
index 82b98bed4e..b803100565 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -24,6 +24,7 @@ bookmarks-tests                                       \
 calc-tests                                     \
 calendar-tests                                 \
 compile-tests                                  \
+csv-tests                                      \
 dired-tests                                    \
 ediff-tests                                    \
 editkit-tests                                  \
@@ -47,6 +48,7 @@ bookmarks-tests                                       \
 calc-tests                                     \
 calendar-tests                                 \
 compile-tests                                  \
+csv-tests                                      \
 dired-tests                                    \
 ediff-tests                                    \
 editkit-tests                                  \
@@ -82,6 +84,9 @@ calendar-tests:
 compile-tests:
        $(MAKE) -C $(SRC_DIR) $@
 
+csv-tests:
+       $(MAKE) -C $(SRC_DIR) $@
+
 dired-tests:
        $(MAKE) -C $(SRC_DIR) $@
 
diff --git a/tests/casual-csv-test-utils.el b/tests/casual-csv-test-utils.el
new file mode 100644
index 0000000000..c738b7281f
--- /dev/null
+++ b/tests/casual-csv-test-utils.el
@@ -0,0 +1,39 @@
+;;; casual-csv-test-utils.el --- Casual Test Utils       -*- lexical-binding: 
t; -*-
+
+;; Copyright (C) 2025  Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+(require 'ert)
+(require 'casual-lib)
+(require 'kmacro)
+
+(defun casualt-csv-setup ()
+  "Casual csv setup."
+  )
+
+(defun casualt-csv-breakdown ()
+  "Casual csv breakdown."
+  )
+
+(provide 'casual-csv-test-utils)
+;;; casual-csv-test-utils.el ends here
diff --git a/tests/test-casual-csv-settings.el 
b/tests/test-casual-csv-settings.el
new file mode 100644
index 0000000000..8adfd89fc3
--- /dev/null
+++ b/tests/test-casual-csv-settings.el
@@ -0,0 +1,68 @@
+;;; test-casual-csv-settings.el --- Casual Make Settings Tests  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+
+(require 'ert)
+(require 'casual-csv-test-utils)
+(require 'casual-csv-settings)
+
+(ert-deftest test-casual-csv-settings-tmenu ()
+  (let ()
+    (cl-letf ((casualt-mock #'casual-csv--customize-align-style)
+              (casualt-mock #'casual-csv--customize-separators)
+              (casualt-mock #'casual-csv--customize-invisibility-default)
+              (casualt-mock #'casual-csv--customize-group)
+              (casualt-mock #'casual-csv--customize-header-lines)
+              (casualt-mock #'casual-csv--customize-comment-start-default)
+              (casualt-mock #'casual-csv--customize-field-quotes)
+              (casualt-mock #'casual-csv--customize-align-min-width)
+              (casualt-mock #'casual-csv--customize-align-max-width)
+              (casualt-mock #'casual-csv-about))
+
+      (let ((test-vectors
+             '(
+               (:binding "A" :command casual-csv--customize-align-style)
+               (:binding "s" :command casual-csv--customize-separators)
+               (:binding "i" :command 
casual-csv--customize-invisibility-default)
+               (:binding "G" :command casual-csv--customize-group)
+               (:binding "h" :command casual-csv--customize-header-lines)
+               (:binding "c" :command 
casual-csv--customize-comment-start-default)
+               (:binding "f" :command casual-csv--customize-field-quotes)
+               (:binding "w" :command casual-csv--customize-align-min-width)
+               (:binding "W" :command casual-csv--customize-align-max-width)
+
+               (:binding "u" :command 
casual-lib-customize-casual-lib-use-unicode)
+               (:binding "n" :command 
casual-lib-customize-casual-lib-hide-navigation)
+               (:binding "a" :command casual-csv-about))))
+
+        (casualt-suffix-testcase-runner test-vectors
+                                        #'casual-csv-settings-tmenu
+                                        '(lambda () (random 5000)))))))
+
+(ert-deftest test-casual-csv-about ()
+  (should (stringp (casual-csv-about))))
+
+(provide 'test-casual-csv-settings)
+;;; test-casual-csv-setttings.el ends here
diff --git a/tests/test-casual-csv-utils.el b/tests/test-casual-csv-utils.el
new file mode 100644
index 0000000000..4a87ef1c49
--- /dev/null
+++ b/tests/test-casual-csv-utils.el
@@ -0,0 +1,74 @@
+;;; test-casual-csv-utils.el --- Casual Make Utils Tests  -*- lexical-binding: 
t; -*-
+
+;; Copyright (C) 2025  Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+(require 'ert)
+(require 'casual-csv-test-utils)
+(require 'casual-csv-utils)
+
+(ert-deftest test-casual-csv-unicode-get ()
+  (let ((casual-lib-use-unicode nil))
+    (should (string-equal (casual-csv-unicode-get :up) "Up"))
+    (should (string-equal (casual-csv-unicode-get :down) "Down"))
+    (should (string-equal (casual-csv-unicode-get :right) "Right"))
+    (should (string-equal (casual-csv-unicode-get :left) "Left"))
+    (should (string-equal (casual-csv-unicode-get :bol) "Begin"))
+    (should (string-equal (casual-csv-unicode-get :eol) "End"))
+    (should (string-equal (casual-csv-unicode-get :beginning-of-buffer) 
"Begin"))
+    (should (string-equal (casual-csv-unicode-get :end-of-buffer) "End")))
+
+  (let ((casual-lib-use-unicode t))
+    (should (string-equal (casual-csv-unicode-get :up) "↑"))
+    (should (string-equal (casual-csv-unicode-get :down) "↓"))
+    (should (string-equal (casual-csv-unicode-get :right) "→"))
+    (should (string-equal (casual-csv-unicode-get :left) "←"))
+    (should (string-equal (casual-csv-unicode-get :bol) "⇤"))
+    (should (string-equal (casual-csv-unicode-get :eol) "⇥"))
+    (should (string-equal (casual-csv-unicode-get :beginning-of-buffer) "⇱"))
+    (should (string-equal (casual-csv-unicode-get :end-of-buffer) "⇲"))))
+
+
+(ert-deftest test-casual-csv-align-tmenu ()
+  (let ((tmpfile "casual-csv-align-tmenu.txt"))
+    (casualt-csv-setup)
+    (cl-letf ((casualt-mock #'csv-align-mode)
+              (casualt-mock #'casual-csv-align-auto)
+              (casualt-mock #'casual-csv-align-left)
+              (casualt-mock #'casual-csv-align-right)
+              (casualt-mock #'casual-csv-align-centre))
+
+      (let ((test-vectors
+             '((:binding "t" :command csv-align-mode)
+               (:binding "a" :command casual-csv-align-auto)
+               (:binding "l" :command casual-csv-align-left)
+               (:binding "r" :command casual-csv-align-right)
+               (:binding "c" :command casual-csv-align-centre))))
+
+        (casualt-suffix-testcase-runner test-vectors
+                                        #'casual-csv-align-tmenu
+                                        '(lambda () (random 5000)))))
+    (casualt-csv-breakdown)))
+
+(provide 'test-casual-csv-utils)
+;;; test-casual-csv-utils.el ends here
diff --git a/tests/test-casual-csv.el b/tests/test-casual-csv.el
new file mode 100644
index 0000000000..5d826a33e5
--- /dev/null
+++ b/tests/test-casual-csv.el
@@ -0,0 +1,91 @@
+;;; test-casual-csv.el --- Casual Make Tests -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Charles Y. Choi
+
+;; Author: Charles Choi <[email protected]>
+;; Keywords: tools
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+
+(require 'ert)
+(require 'casual-csv-test-utils)
+(require 'casual-lib-test-utils)
+(require 'casual-csv)
+
+(ert-deftest test-casual-csv-tmenu ()
+  (let ()
+    (casualt-csv-setup)
+
+    (cl-letf (
+              (casualt-mock #'csv-backtab-command)
+              (casualt-mock #'csv-tab-command)
+              (casualt-mock #'previous-line)
+              (casualt-mock #'next-line)
+              (casualt-mock #'move-beginning-of-line)
+              (casualt-mock #'move-end-of-line)
+              (casualt-mock #'beginning-of-buffer)
+              (casualt-mock #'end-of-buffer)
+              (casualt-mock #'scroll-down-command)
+              (casualt-mock #'scroll-up-command)
+              (casualt-mock #'casual-csv-align-tmenu)
+              (casualt-mock #'view-mode)
+              (casualt-mock #'View-exit)
+              (casualt-mock #'casual-lib-duplicate-file)
+              (casualt-mock #'mark-sexp)
+              (casualt-mock #'casual-editkit-copy-sexp)
+
+              (casualt-mock #'occur)
+              (casualt-mock #'casual-csv-kill-region-as-org-table)
+              (casualt-mock #'casual-csv-settings-tmenu)
+
+              (casualt-mock #'quit-window))
+
+      (let ((test-vectors
+             '(
+               (:binding "S-TAB" :command csv-backtab-command)
+               (:binding "TAB" :command csv-tab-command)
+               (:binding "n" :command next-line)
+               (:binding "p" :command previous-line)
+               (:binding "C-e" :command move-end-of-line)
+               (:binding "C-a" :command move-beginning-of-line)
+               (:binding ">" :command end-of-buffer)
+               (:binding "<" :command beginning-of-buffer)
+               (:binding "C-v" :command scroll-up-command)
+               (:binding "M-v" :command scroll-down-command)
+               (:binding "a" :command casual-csv-align-tmenu)
+               ;; (:binding "v" :command view-mode)
+               ;; (:binding "e" :command View-exit)
+               ;; (:binding "s" :command csv-sort-fields)
+
+               (:binding "m" :command mark-sexp)
+               (:binding "c" :command casual-editkit-copy-sexp)
+
+               (:binding "o" :command occur)
+               (:binding "," :command casual-csv-settings-tmenu)
+               (:binding "q" :command quit-window)
+               )))
+
+        (casualt-suffix-testcase-runner test-vectors
+                                        #'casual-csv-tmenu
+                                        '(lambda () (random 5000)))))
+    (casualt-csv-breakdown)))
+
+(provide 'test-casual-csv)
+;;; test-casual-csv.el ends here
diff --git a/tests/test-casual-timezone-utils.el 
b/tests/test-casual-timezone-utils.el
index 5420381fb3..82f53039e0 100644
--- a/tests/test-casual-timezone-utils.el
+++ b/tests/test-casual-timezone-utils.el
@@ -102,7 +102,7 @@
 (ert-deftest test-casual-timezone-map-local-to-timezone ()
   (let* ((ts "2025-05-23")
          (remote-tz "Europe/Berlin")
-         (control "2025-05-23 09:00:00 CEST")
+         (control "2025-05-23 10:00:00 CEST")
          (result (casual-timezone-map-local-to-timezone ts remote-tz)))
     (should (string-equal control result))))
 
@@ -133,7 +133,7 @@
 (ert-deftest test-casual-timezone-local-time-to-remote ()
   (let* ((read-date "2025-05-23 12:00")
          (remote-tz "Europe/Berlin")
-         (control "Europe/Berlin 2025-05-23 21:00:00 CEST")
+         (control "Europe/Berlin 2025-05-23 22:00:00 CEST")
          (result (casual-timezone-local-time-to-remote read-date remote-tz)))
 
     (should (string-equal control result))))
@@ -141,7 +141,7 @@
 (ert-deftest test-casual-timezone-local-time-to-remote-victoria ()
   (let* ((read-date "2025-05-23 12:00")
          (remote-tz "Australia/Victoria")
-         (control "Australia/Victoria 2025-05-24 05:00:00 AEST")
+         (control "Australia/Victoria 2025-05-24 06:00:00 AEST")
          (result (casual-timezone-local-time-to-remote read-date remote-tz)))
 
     (should (string-equal control result))))


Reply via email to