Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.org
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [[https://keepachangelog.com/en/1.1.0/][Keep a Changelog]
- *vui-typed-field component*: New component in =vui-components.el= for typed input with parsing and validation. Uses component state to preserve raw input (e.g., users can see "123f" they typed while error is displayed).
- Supported types: =integer=, =natnum=, =float=, =number=, =file=, =directory=, =symbol=, =sexp=
- Path types (=file=, =directory=) automatically expand =~= and relative paths via =expand-file-name=
- =:must-exist= constraint for =file= and =directory= types (validates path existence)
- =:on-change= and =:on-submit= receive typed values only when input is valid
- =:on-error= callback receives =(error-msg raw-input)= on invalid input
- Numeric constraints via =:min= and =:max=
Expand Down
4 changes: 3 additions & 1 deletion docs/examples/08-typed-fields.el
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,14 @@
:on-change (lambda (expr) (funcall update :features expr))))
(vui-text " (a list of symbols)" :face 'shadow)

;; Data directory (file path)
;; Data directory (file path with existence check)
(vui-hstack
(vui-text "Data Dir: ")
(vui-directory-field
:value (plist-get config :data-dir)
:size 30
:must-exist t
:show-error 'inline
:on-change (lambda (path) (funcall update :data-dir path))))

;; Preview
Expand Down
36 changes: 30 additions & 6 deletions test/vui-components-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -165,29 +165,53 @@ Buttons are widget.el push-buttons, so we use widget-apply."

(describe "vui--typed-field-validate"
(it "validates :min constraint"
(let ((err (vui--typed-field-validate 5 'integer 10 nil nil nil "5")))
(let ((err (vui--typed-field-validate 5 'integer 10 nil nil nil nil "5")))
(expect err :to-match "at least 10")))

(it "validates :max constraint"
(let ((err (vui--typed-field-validate 100 'integer nil 50 nil nil "100")))
(let ((err (vui--typed-field-validate 100 'integer nil 50 nil nil nil "100")))
(expect err :to-match "at most 50")))

(it "validates :required constraint"
(let ((err (vui--typed-field-validate nil 'integer nil nil nil t " ")))
(let ((err (vui--typed-field-validate nil 'integer nil nil nil t nil " ")))
(expect err :to-match "required")))

(it "passes when constraints are met"
(let ((err (vui--typed-field-validate 25 'integer 10 50 nil nil "25")))
(let ((err (vui--typed-field-validate 25 'integer 10 50 nil nil nil "25")))
(expect err :to-be nil)))

(it "calls custom validator with typed value"
(let* ((received-value nil)
(validator (lambda (v)
(setq received-value v)
(when (cl-oddp v) "Must be even")))
(err (vui--typed-field-validate 5 'integer nil nil validator nil "5")))
(err (vui--typed-field-validate 5 'integer nil nil validator nil nil "5")))
(expect received-value :to-equal 5)
(expect err :to-equal "Must be even")))))
(expect err :to-equal "Must be even")))

(it "validates :must-exist for file type"
(let ((err (vui--typed-field-validate "/nonexistent/file.txt" 'file
nil nil nil nil t "/nonexistent/file.txt")))
(expect err :to-match "File does not exist")))

(it "validates :must-exist for directory type"
(let ((err (vui--typed-field-validate "/nonexistent/dir" 'directory
nil nil nil nil t "/nonexistent/dir")))
(expect err :to-match "Directory does not exist")))

(it "passes :must-exist when file exists"
(let ((err (vui--typed-field-validate (expand-file-name "vui.el") 'file
nil nil nil nil t "vui.el")))
(expect err :to-be nil)))

(it "passes :must-exist when directory exists"
(let ((err (vui--typed-field-validate (expand-file-name "test") 'directory
nil nil nil nil t "test")))
(expect err :to-be nil)))

(it "skips :must-exist check for nil value"
(let ((err (vui--typed-field-validate nil 'file nil nil nil nil t "")))
(expect err :to-be nil)))))

(describe "vui-collapsible"
(describe "basic structure"
Expand Down
46 changes: 29 additions & 17 deletions vui-components.el
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,13 @@ When TYPE is nil, returns (ok . STRING) unchanged."
(_ (cons 'ok string))))
(error (cons 'error (format "Parse error: %s" (error-message-string err)))))))

(defun vui--typed-field-validate (value _type min max validate required string-value)
(defun vui--typed-field-validate (value type min max validate required must-exist string-value)
"Validate VALUE against constraints.
TYPE is the field type (may be nil for untyped fields).
MIN and MAX are numeric constraints (only for numeric types).
VALIDATE is a custom validator function.
REQUIRED indicates if empty values are invalid.
MUST-EXIST indicates if file/directory must exist.
STRING-VALUE is the raw string (for :required check).
Returns nil if valid, or an error message string if invalid."
(cond
Expand All @@ -155,6 +156,12 @@ Returns nil if valid, or an error message string if invalid."
;; Check max constraint
((and max (numberp value) (> value max))
(format "Must be at most %s" max))
;; Check file existence
((and must-exist (eq type 'file) value (not (file-exists-p value)))
"File does not exist")
;; Check directory existence (must be a directory, not just exist)
((and must-exist (eq type 'directory) value (not (file-directory-p value)))
"Directory does not exist")
;; Check custom validator
(validate
(funcall validate value))
Expand Down Expand Up @@ -420,7 +427,8 @@ PROPS is a plist accepting :level (1-8, default 1) and :key."
;;; Typed Field Component

(vui-defcomponent vui-typed-field--internal
(type value min max validate on-change on-submit on-error show-error size secret key required)
(type value min max validate on-change on-submit on-error show-error
size secret key required must-exist)
"Internal component for typed input fields.

TYPE is the field type (integer, natnum, float, number, file, directory, symbol, sexp).
Expand All @@ -434,7 +442,8 @@ SHOW-ERROR can be t, \\='inline, or \\='below to display error.
SIZE is field width.
SECRET enables password mode.
KEY is for field lookup.
REQUIRED means empty is invalid."
REQUIRED means empty is invalid.
MUST-EXIST means file/directory must exist (for file/directory types)."

:state ((raw-value (vui--field-value-to-string value type))
(error-msg nil)
Expand Down Expand Up @@ -466,7 +475,8 @@ REQUIRED means empty is invalid."
;; Parse succeeded - validate
(let* ((typed-value (cdr parse-result))
(validation-err (vui--typed-field-validate
typed-value type min max validate required string-input)))
typed-value type min max validate
required must-exist string-input)))
(if validation-err
;; Validation failed
(progn
Expand Down Expand Up @@ -499,29 +509,31 @@ REQUIRED means empty is invalid."
"Create a typed input field with parsing and validation.

PROPS is a plist accepting:
:type - Type for parsing: \\='integer, \\='natnum, \\='float,
\\='number, \\='file, \\='directory, \\='symbol, \\='sexp
:value - Typed value (will be stringified for display)
:min/:max - Numeric constraints
:validate - (lambda (typed-value) error-string-or-nil)
:on-change - (lambda (typed-value)) called only when valid
:on-submit - (lambda (typed-value)) called only when valid on RET
:on-error - (lambda (error-msg raw-input)) on parse/validation failure
:type - Type for parsing: \\='integer, \\='natnum, \\='float,
\\='number, \\='file, \\='directory, \\='symbol, \\='sexp
:value - Typed value (will be stringified for display)
:min/:max - Numeric constraints
:must-exist - Non-nil means file/directory must exist
:validate - (lambda (typed-value) error-string-or-nil)
:on-change - (lambda (typed-value)) called only when valid
:on-submit - (lambda (typed-value)) called only when valid on RET
:on-error - (lambda (error-msg raw-input)) on parse/validation failure
:show-error - t or \\='below for below, \\='inline for same line
:size - Field width
:secret - Password mode
:key - Field key
:required - Non-nil means empty is invalid
:size - Field width
:secret - Password mode
:key - Field key
:required - Non-nil means empty is invalid

Examples:
(vui-typed-field :type \\='integer :value 42 :min 0 :max 100
:on-change (lambda (n) (message \"Got: %d\" n)))
(vui-typed-field :type \\='float :value 3.14 :show-error t)"
(vui-typed-field :type \\='file :must-exist t :show-error t)"
(vui-component 'vui-typed-field--internal
:type (plist-get props :type)
:value (plist-get props :value)
:min (plist-get props :min)
:max (plist-get props :max)
:must-exist (plist-get props :must-exist)
:validate (plist-get props :validate)
:on-change (plist-get props :on-change)
:on-submit (plist-get props :on-submit)
Expand Down