diff --git a/CHANGELOG.org b/CHANGELOG.org index dae7092..3754757 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -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= diff --git a/docs/examples/08-typed-fields.el b/docs/examples/08-typed-fields.el index 9002f31..6696bc1 100644 --- a/docs/examples/08-typed-fields.el +++ b/docs/examples/08-typed-fields.el @@ -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 diff --git a/test/vui-components-test.el b/test/vui-components-test.el index 319b267..9b0734b 100644 --- a/test/vui-components-test.el +++ b/test/vui-components-test.el @@ -165,19 +165,19 @@ 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" @@ -185,9 +185,33 @@ Buttons are widget.el push-buttons, so we use widget-apply." (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" diff --git a/vui-components.el b/vui-components.el index b1cc09b..198f8cf 100644 --- a/vui-components.el +++ b/vui-components.el @@ -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 @@ -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)) @@ -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). @@ -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) @@ -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 @@ -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)