Index: trunk/lib/Text/FormBuilder.pm
===================================================================
--- trunk/lib/Text/FormBuilder.pm	(revision 19)
+++ trunk/lib/Text/FormBuilder.pm	(revision 21)
@@ -6,5 +6,5 @@
 use vars qw($VERSION);
 
-$VERSION = '0.05';
+$VERSION = '0.06';
 
 use Carp;
@@ -87,4 +87,30 @@
         delete $$_{validate} unless $$_{validate};
     }
+
+    # expand groups
+    my %groups = %{ $self->{form_spec}{groups} };
+    foreach (grep { $$_[0] eq 'group' } @{ $self->{form_spec}{lines} }) {
+        $$_[1]{group} =~ s/^\%//;       # strip leading % from group var name
+        
+        if (exists $groups{$$_[1]{group}}) {
+            my @fields; # fields in the group
+            push @fields, { %$_ } foreach @{ $groups{$$_[1]{group}} };
+            for my $field (@fields) {
+                $$field{label} ||= ucfirst $$field{name};
+                $$field{name} = "$$_[1]{name}_$$field{name}";                
+            }
+            $_ = [ 'group', { label => $$_[1]{label} || ucfirst(join(' ',split('_',$$_[1]{name}))), group => \@fields } ];
+        }
+    }
+    
+    $self->{form_spec}{fields} = [];
+    for my $line (@{ $self->{form_spec}{lines} }) {
+        if ($$line[0] eq 'group') {
+            push @{ $self->{form_spec}{fields} }, $_ foreach @{ $$line[1]{group} };
+        } elsif ($$line[0] eq 'field') {
+            push @{ $self->{form_spec}{fields} }, $$line[1];
+        }
+    }
+    
     
     # substitute in list names
@@ -105,5 +131,12 @@
         }
     }
-
+    
+    
+    
+
+    
+    # TODO: use lines instead of fields
+    # TODO: change template to do groups
+    
     # TODO: configurable threshold for this
     foreach (@{ $self->{form_spec}{fields} }) {
@@ -116,5 +149,5 @@
         keepextras => 1,
         title => $self->{form_spec}{title},
-        fields => [ map { $$_{name} } @{ $self->{form_spec}{fields} } ],
+        #fields => [ map { $$_{name} } @{ $self->{form_spec}{fields} } ],
         template => {
             type => 'Text',
@@ -125,4 +158,5 @@
             },
             data => {
+                lines       => $self->{form_spec}{lines},
                 headings    => $self->{form_spec}{headings},
                 author      => $self->{form_spec}{author},
@@ -137,4 +171,8 @@
     $self->{built} = 1;
     
+    # TEMP: dump @lines structure
+    use YAML;
+    warn YAML::Dump($self->{form_spec}->{lines}), "\n";
+    
     return $self;
 }
@@ -182,5 +220,5 @@
     my $source = $options{form_only} ? $self->_form_template : $self->_template;
     
-    delete $options{fomr_only};
+    delete $options{form_only};
     
     my $form_options = keys %options > 0 ? Data::Dumper->Dump([$self->{build_options}],['*options']) : '';
@@ -262,14 +300,40 @@
 <% $start %>
 <table>
-<% my $i; foreach(@fields) {
-    $OUT .= qq[  <tr><th class="sectionhead" colspan="2"><h2>$headings[$i]</h2></th></tr>\n] if $headings[$i];
-    $OUT .= $$_{invalid} ? qq[  <tr class="invalid">] : qq[  <tr>];
-    $OUT .= '<th class="label">' . ($$_{required} ? qq[<strong class="required">$$_{label}:</strong>] : "$$_{label}:") . '</th>';
-    if ($$_{invalid}) {
-        $OUT .= qq[<td>$$_{field} $$_{comment} Missing or invalid value.</td></tr>\n];
-    } else {
-        $OUT .= qq[<td>$$_{field} $$_{comment}</td></tr>\n];
-    }
-    $i++;
+
+<% for my $line (@lines) {
+
+    if ($$line[0] eq 'head') {
+        $OUT .= qq[  <tr><th class="sectionhead" colspan="2"><h2>$$line[1]</h2></th></tr>\n]
+    } elsif ($$line[0] eq 'field') {
+        #TODO: we only need the field names, not the full field spec in the lines strucutre
+        local $_ = $field{$$line[1]{name}};
+        $OUT .= $$_{invalid} ? qq[  <tr class="invalid">] : qq[  <tr>];
+        $OUT .= '<th class="label">' . ($$_{required} ? qq[<strong class="required">$$_{label}:</strong>] : "$$_{label}:") . '</th>';
+        if ($$_{invalid}) {
+            $OUT .= qq[<td>$$_{field} $$_{comment} Missing or invalid value.</td></tr>\n];
+        } else {
+            $OUT .= qq[<td>$$_{field} $$_{comment}</td></tr>\n];
+        }
+    } elsif ($$line[0] eq 'group') {
+        my @field_names = map { $$_{name} } @{ $$line[1]{group} };
+        my @group_fields = map { $field{$_} } @field_names;
+        $OUT .= (grep { $$_{invalid} } @group_fields) ? qq[  <tr class="invalid">\n] : qq[  <tr>\n];
+        
+        
+        #TODO: validated but not required fields
+        # in a form spec: //EMAIL?
+        
+        #TODO: this doesn't seem to be working; all groups are getting marked as required        
+        $OUT .= '    <th class="label">';
+        $OUT .= (grep { $$_{required} } @group_fields) ? qq[<strong class="required">$$line[1]{label}:</strong>] : "$$line[1]{label}:";
+        $OUT .= qq[</th>\n];
+        
+        $OUT .= qq[    <td>];
+        $OUT .= join(' ', map { qq[<small class="sublabel">$$_{label}</small> $$_{field} $$_{comment}] } @group_fields);
+        $OUT .= qq[    </td>\n];
+        $OUT .= qq[  </tr>\n];
+    }   
+    
+
 } %>
   <tr><th></th><td style="padding-top: 1em;"><% $submit %></td></tr>
@@ -290,4 +354,5 @@
     th.label { font-weight: normal; text-align: right; vertical-align: top; }
     td ul { list-style: none; padding-left: 0; margin-left: 0; }
+    .sublabel { color: #999; }
   </style>
 </head>
@@ -321,5 +386,5 @@
 =head1 NAME
 
-Text::FormBuilder - Parser for a minilanguage for generating web forms
+Text::FormBuilder - Create CGI::FormBuilder objects from simple text descriptions
 
 =head1 SYNOPSIS
@@ -536,8 +601,21 @@
     static
 
-This example also shows how you can list multiple values for the input types
-that take multiple values (C<select>, C<radio>, and C<checkbox>). Values are
-in a comma-separated list inside curly braces. Whitespace between values is
-irrelevant, although there cannot be any whitespace within a value.
+To change the size of the input field, add a bracketed subscript after the
+field name (but before the descriptive label):
+
+    # for a single line field, sets size="40"
+    title[40]:text
+    
+    # for a multiline field, sets rows="4" and cols="30"
+    description[4,30]:textarea
+
+For the input types that can have options (C<select>, C<radio>, and
+C<checkbox>), here's how you do it:
+
+    color|Favorite color:select{red,blue,green}
+
+Values are in a comma-separated list inside curly braces. Whitespace
+between values is irrelevant, although there cannot be any whitespace
+within a value.
 
 To add more descriptive display text to a vlaue in a list, add a square-bracketed
@@ -566,6 +644,6 @@
 The code inside the C<&{ ... }> is C<eval>ed by C<build>, and the results
 are stuffed into the list. The C<eval>ed code can either return a simple
-list, as the example does, or the fancier C<( { value1 => 'Description 1'},
-{ value2 => 'Description 2}, ...)> form.
+list, as the example does, or the fancier C<< ( { value1 => 'Description 1'},
+{ value2 => 'Description 2}, ... ) >> form.
 
 B<NOTE:> This feature of the language may go away unless I find a compelling
@@ -583,5 +661,5 @@
     email|Email address//EMAIL
 
-Valid validation types include any of the builtin defaults from CGI::FormBuilder,
+Valid validation types include any of the builtin defaults from L<CGI::FormBuilder>,
 or the name of a pattern that you define with the C<!pattern> directive elsewhere
 in your form spec:
Index: trunk/lib/Text/FormBuilder/grammar
===================================================================
--- trunk/lib/Text/FormBuilder/grammar	(revision 19)
+++ trunk/lib/Text/FormBuilder/grammar	(revision 21)
@@ -1,5 +1,26 @@
-{ my ($title, $author, $description, %lists, %patterns, @fields, @headings, $type, @options, $list_var, $size, $rows, $cols); }
+{ 
+    my (
+	$context,      # line or group
+	@lines,        # master data structure
+	$title,
+	$author,
+	$description,
+	%lists,
+	%patterns,
+	@fields,
+	@group,        # current group
+	%groups,       # stored groups of fields
+	@headings,
+	$type,
+	@options,
+	$list_var,
+	$size,
+	$rows,
+	$cols,
+    );
+    $context = 'line';
+}
 
-form_spec: (list_def | description_def | line)(s) 
+form_spec: (list_def | description_def | group_def | line)(s) 
     {
 	$return = {
@@ -11,4 +32,6 @@
 	    headings => \@headings,
 	    fields   => \@fields,
+	    lines    => \@lines,
+	    groups   => \%groups,
 	}
     }
@@ -36,5 +59,13 @@
     }
 
-line: <skip:'[ \t]*'> ( title | author | pattern_def | heading | unknown_directive | field | comment | blank ) "\n"
+group_def: '!group' { $context = 'group' } var_name '{' field_line(s) '}' { $context = 'line' }
+    { 
+	#warn "$item{var_name} group; context $context\n"
+	$groups{$item{var_name}} = [ @group ];
+	@group = ();
+    }
+
+field_line: <skip:'[ \t]*'> ( field | comment | blank ) "\n"
+line: <skip:'[ \t]*'> ( title | author | pattern_def | heading | group_field | unknown_directive | field | comment | blank ) "\n"
 
 title: '!title' /.*/
@@ -51,6 +82,16 @@
 
 heading: '!head' /.*/
-    { warn "[Text::FormBuilder] Header before field " . scalar(@fields) . " redefined at input text line $thisline\n" if defined $headings[@fields];
-    $headings[@fields] = $item[2] }
+    {
+	warn "[Text::FormBuilder] Header before field " . scalar(@fields) . " redefined at input text line $thisline\n" if defined $headings[@fields];
+	$headings[@fields] = $item[2];
+	push @lines, [ 'head', $item[2] ];
+    }
+
+group_field: '!field' group_name name label(?)
+    { #warn "[$thisline] $item{group_name}\n"; 
+    push @lines, [ 'group', { name => $item{name}, label => $item{'label(?)'}[0], group => $item{group_name} } ];
+    }
+
+group_name: /%[A-Z_]+/
 
 field: name field_size(?) label(?) hint(?) type(?) default(?) option_list(?) validate(?)
@@ -72,5 +113,11 @@
 	$$field{size} = $size if defined $size;
 	
-	push @fields, $field;
+	#warn "[$thisline] field $item{name}; context $context\n";   
+	if ($context eq 'group') {
+	    push @group, $field;
+	} else {
+	    push @fields, $field;
+	    push @lines, [ 'field', $field ];
+	}
 	
 	$type = undef;
@@ -95,5 +142,6 @@
     { $rows = $item[1]; $cols = $item[3] }
 
-label: '|' /[^:\[\{\/]+/i
+#TODO: zero width labels
+label: '|' /[^:\[\{\/\n]*/i { $item[2] }
 
 hint: '[' /[^\]]+/ ']'    { $item[2] }
