diff --git a/lib/Value/String.pm b/lib/Value/String.pm
index fc7af6087b..829aaa6b4b 100644
--- a/lib/Value/String.pm
+++ b/lib/Value/String.pm
@@ -127,6 +127,20 @@ sub quoteHTML {
return '' . $s . ' ';
}
+#
+# Quote XML special characters
+#
+sub quoteXML {
+ shift;
+ my $s = shift;
+ return unless defined $s;
+ return $s if eval('$main::displayMode') eq 'TeX';
+ $s =~ s/&/\&/g;
+ $s =~ s/\</g;
+ $s =~ s/>/\>/g;
+ return $s;
+}
+
#
# Render the value verbatim
#
diff --git a/macros/parsers/parserMultipleChoice.pl b/macros/parsers/parserMultipleChoice.pl
new file mode 100644
index 0000000000..ed8d5d85fe
--- /dev/null
+++ b/macros/parsers/parserMultipleChoice.pl
@@ -0,0 +1,30 @@
+
+=head1 NAME
+
+parserMultipleChoice.pl - Load all the multiple choice parsers: PopUp, CheckboxList, RadioButtons, RadioMultiAnswer.
+
+=head1 SYNOPSIS
+
+ loadMacros('parserMultipleChoice.pl');
+
+=head1 DESCRIPTION
+
+parserMultipleChoice.pl loads the following macro files:
+
+=over
+
+=item * parserPopUp.pl
+
+=item * parserCheckboxList.pl
+
+=item * parserRadioButtons.pl
+
+=item * parserRadioMultiAnswer.pl
+
+=back
+
+=cut
+
+loadMacros("parserPopUp.pl", "parserCheckboxList.pl", "parserRadioButtons.pl", "parserRadioMultiAnswer.pl");
+
+1;
diff --git a/macros/parsers/parserPopUp.pl b/macros/parsers/parserPopUp.pl
index d7476aef08..980aa416f9 100644
--- a/macros/parsers/parserPopUp.pl
+++ b/macros/parsers/parserPopUp.pl
@@ -15,82 +15,164 @@
=head1 NAME
-parserPopUp.pl - Pop-up menus compatible with Value objects.
+parserPopUp.pl - Drop-down lists compatible with MathObjects,
+ specifically MultiAnswer objects.
=head1 DESCRIPTION
-This file implements a pop-up menu object that is compatible with
-MathObjects, and in particular, with the MultiAnswer object, and with
-PGML.
+This file implements drop-down select objects that are compatible
+with MathObjects, and in particular, with the MultiAnswer object, and
+with PGML.
-To create a PopUp object, use one of:
+To create a PopUp, DropDown, or DropDownTF object, use
- $popup = PopUp([choices,...], correct);
- $dropdown = DropDown([choices,...], correct);
- $truefalse = DropDownTF(correct);
+ $popup = PopUp([ choices, ... ], correct, options);
+ $dropdown = DropDown([ choices, ... ], correct, options);
+ $truefalse = DropDownTF(correct, options);
-where "choices" are the strings for the items in the popup menu,
-and "correct" is the choice that is the correct answer for the
-popup (or its index, with 0 being the first one).
+where "choices" are the items in the drop-down list, "correct" is the
+the correct answer for the group (or its index, with 0 being the
+first one), and options are chosen from among those listed below. If
+the correct answer is a number, it is interpreted as an index, even
+if the array of choices are also numbers. (See the C below
+for more details.)
-The difference between C and Cis that in HTML,
-the latter will have an unselectable placeholder value. This value is '?'
-by default, but can be customized with a C option.
+Note that drop-down menus can not contain mathematical notation, only
+plain text. This is because the browser's native menus are used, and
+these can contain only text, not mathematics or graphics.
-C is like C with options being localized versions of
-"True" and "False". 1 is understood as "True" and 0 as "False". The initial
-letter of the localized word is understood as that word if those letter are
-different. All of this is case-insensitive. Also, in static output (PDF, PTX)
-C is 0. It is assumed that context makes the menu redundant.
+The difference between C and Cis that in HTML,
+the latter will have an unselectable placeholder value. This value
+is '?' by default, but can be customized with a C option.
+
+C is like C with options being localized
+versions of "True" and "False". 1 is understood as "True" and 0 as
+"False". The initial letter of the localized word is understood as
+that word if those letter are different. All of this is not case
+sensitive. Also, in static output (PDF, PTX) C defaults
+to 0. It is assumed that text preceding the drop-down makes the menu
+redundant.
+
+The entries in the choices array can either be the actual strings to
+be used in the drop-down menu (which is known as a "label" for the
+option input in HTML) or C<< { label => value } >> where C is
+the text string to display in the drop-down list and C is the
+value to for the option input for this choice. The "value" is what is
+actually submitted when a student submits an answer, and this is what
+will appear in the past answers table, feedback messages, etc. If an
+option is not set as a hash in this way, the text of the option serves
+as both the label and the value.
By default, the choices are left in the order that you provide them,
but you can cause some or all of them to be ordered randomly by
enclosing those that should be randomized within a second set of
brackets. For example
- $radio = PopUp([
- "First Item",
- ["Random 1","Random 2","Random 3"],
- "Last Item"
- ],
- "Random 3"
- );
+ $dropdown = DropDown(
+ [
+ "First Item",
+ [ "Random 1", "Random 2", "Random 3" ],
+ "Last Item"
+ ],
+ "Random 3"
+ );
-will make a pop-up menu that has the first item always on top, the
-next three ordered randomly, and the last item always on the bottom.
-In this example
+will make a list of options that has the first item always on top,
+the next three ordered randomly, and the last item always on the
+bottom. In this example
- $radio = PopUp([["Random 1","Random 2","Random 3"]],2);
+ $dropdown = DropDown([ [ "Random 1", "Random 2", "Random 3" ] ], 2);
all the entries are randomized, and the correct answer is "Random 3"
-(the one with index 2 in the original, unrandomized list). You can
-have as many randomized groups, with as many static items in between,
-as you want.
+(the one with index 2 in the flattened list). You can have as many
+randomized groups as you want, with as many static items in between.
+
+The C are taken from the following list:
+
+=over
+
+=item C array reference >>>
+
+Values are the form of the student answer that is actually submitted
+when the student submits an answer. They will be displayed in the past
+answers table for this answer, appear in feedback messages, etc. By
+default these are the option text (aka the option label). However,
+that can be changed either with this option or by specifying the
+choices as C<< { label => value } >> as described previously. If this
+option is used, then it must be set as a reference to an array
+containing the values for the options. For example:
+
+ values => [ 'first choice', 'second choice', ... ]
+
+If a choice is not represented in the hash, then the option text will
+be used for the value instead.
+
+These values can be any descriptive string that is unique for the
+choice, but care should be taken to ensure that these values do not
+indicate which choice is the correct answer.
+
+Note that values given via C<< { label => value } >> will override any
+values given by the C option if both are provided for a
+particular choice.
+
+=item C 0 or 1 >>>
+
+Determines whether or not a numeric value for the correct answer is
+interpreted as an index for the choice array or not. If set to 1,
+then the number is treated as the literal correct answer, not an index
+to it. Default: 0
-Note that pop-up menus can not contain mathematical notation, only
-plain text. This is because the PopUp object uses the browser's
-native menus, and these can contain only text, not mathematics or
-graphics.
+=item C string >>>
-To insert the pop-up menu into the problem text, use
+If nonempty, this will be the first option in the drop-down list. It
+will be unselectable and grayed out, indicating that it is not an
+option the user can/should actually select and submit. Default: ''
+for C, '?' for C and C
+
+=item C 0 or 1 >>>
+
+In static output, such as PDF or PTX, this controls whether or not
+the list of answer options is displayed. (The text preceding the list
+of answer options might make printing the answer option list
+unnecessary in a static output format.) Default: 1, except 0 for
+DropDownTF.
+
+=back
+
+To insert the drop-down into the problem text when using PGML:
+
+ BEGIN_PGML
+ [_]{$dropdown}
+ END_PGML
+
+Or when not using PGML:
BEGIN_TEXT
- \{$popup->menu\}
+ \{$dropdown->menu\}
END_TEXT
-and then
+and then to get the answer checker for the drop-down:
- ANS($popup->cmp);
+ ANS($dropdown->cmp);
-to get the answer checker for the popup.
-You can use the PopUp menu object in MultiAnswer objects. This is
-the reason for the pop-up menu's ans_rule method (since that is what
-MultiAnswer calls to get answer rules).
+You can use the PopUp, DropDown, and DropDownTF object in MultiAnswer
+objects. This is the reason for the C method (since that
+is what MultiAnswer calls to get answer rules). Just pass the object
+as one of the arguments of the MultiAnswer constructor.
-There is one option, C. It is 1 by default, except for
-C it is 0. This option controls whether or not the menu
-is displayed in a static output format (PDF hardcopy or PTX).
+When writing a custom answer checker involving a PopUp, DropDown, or
+DropDownTF object (e.g. if it is part of a MultiAnswer and its answer
+depends on, or affects, the answers given to other parts), note that
+the actual answer strings associated to one of these objects (which
+are those appearing in the "student answer" argument passed to a
+custom answer checker) are not the supplied option text (aka the
+labels), but rather they the option values. These are the values
+given by the C option or C<< { label => value } >> choice
+format if provided. Otherwise they are an internal implementation
+detail whose format should not be depended on. In any case, you can
+convert these value strings to a choice string (aka label string) with
+the method C.
=cut
@@ -103,28 +185,11 @@ =head1 DESCRIPTION
#
package parser::PopUp;
our @ISA = ('Value::String');
-my $context;
#
-# Setup the context and the PopUp() command
+# Set up the main:: namespace
#
sub Init {
- #
- # make a context in which arbitrary strings can be entered
- #
- $context = Parser::Context->getCopy("Numeric");
- $context->{name} = "PopUp";
- $context->parens->clear();
- $context->variables->clear();
- $context->constants->clear();
- $context->operators->clear();
- $context->functions->clear();
- $context->strings->clear();
- $context->{pattern}{number} = "^\$";
- $context->variables->{patterns} = {};
- $context->strings->{patterns}{".*"} = [ -20, 'str' ];
- $context->{parser}{String} = "parser::PopUp::String";
- $context->update;
main::PG_restricted_eval('sub PopUp {parser::PopUp->new(@_)}');
main::PG_restricted_eval('sub DropDown {parser::PopUp->DropDown(@_)}');
main::PG_restricted_eval('sub DropDownTF {parser::PopUp->DropDownTF(@_)}');
@@ -140,26 +205,38 @@ sub new {
my $choices = shift;
my $value = shift;
my %options = @_;
- Value->Error("A PopUp's first argument should be a list of menu items")
+ Value::Error("A PopUp's first argument should be a list of menu items")
unless ref($choices) eq 'ARRAY';
- Value->Error("A PopUp's second argument should be the correct menu choice")
+ Value::Error("A PopUp's second argument should be the correct menu choice")
unless defined($value) && $value ne "";
+ #
+ # make a context in which arbitrary strings can be entered
+ #
+ my $context = Parser::Context->getCopy("Numeric");
+ $context->{name} = "PopUp";
+ $context->parens->clear();
+ $context->variables->clear();
+ $context->constants->clear();
+ $context->operators->clear();
+ $context->functions->clear();
+ $context->strings->clear();
+ $context->{pattern}{number} = "^\$";
+ $context->variables->{patterns} = {};
+ $context->strings->{patterns}{".*"} = [ -20, 'str' ];
+ $context->{parser}{String} = "parser::PopUp::String";
+ $context->update;
$self = bless {
data => [$value],
context => $context,
choices => $choices,
placeholder => $options{placeholder} // '',
- showInStatic => $options{showInStatic} // 1
+ showInStatic => $options{showInStatic} // 1,
+ values => $options{values} // [],
+ noindex => $options{noindex} // 0
}, $class;
$self->getChoiceOrder;
- my %choice;
- map { $choice{$_} = 1 } @{ $self->{choices} };
-
- if (!$choice{$value}) {
- my @order = map { ref($_) eq "ARRAY" ? @$_ : $_ } @$choices;
- if ($value =~ m/^\d+$/ && $order[$value]) { $self->{data}[0] = $order[$value] }
- else { Value->Error("The correct choice must be one of the PopUp menu items") }
- }
+ $self->addLabelsValues;
+ $self->getCorrectChoice($value);
return $self;
}
@@ -170,36 +247,145 @@ sub getChoiceOrder {
my $self = shift;
my @choices = ();
foreach my $choice (@{ $self->{choices} }) {
- if (ref($choice) eq "ARRAY") { push(@choices, $self->randomOrder($choice)) }
- else { push(@choices, $choice) }
+ if (ref($choice) eq "ARRAY") { push(@choices, $self->randomOrder($choice)) }
+ else { push(@choices, $choice); push(@{ $self->{order} }, scalar(@{ $self->{order} })); }
}
- $self->{choices} = \@choices;
+ $self->{orderedChoices} = \@choices;
+ $self->{n} = scalar(@choices);
}
sub randomOrder {
+ my ($self, $choices) = @_;
+ my @indices = 0 .. $#$choices;
+ my @order = map { splice(@indices, $main::PG_random_generator->random(0, $#indices), 1) } @indices;
+ push(@{ $self->{order} }, map { $_ + scalar(@{ $self->{order} }) } @order);
+ return map { $choices->[$_] } @order;
+}
+
+#
+# Collect the labels and values
+#
+sub addLabelsValues {
my $self = shift;
- my $choices = shift;
- my %index = (map { $main::PG_random_generator->rand => $_ } (0 .. scalar(@$choices) - 1));
- return (map { $choices->[ $index{$_} ] } main::PGsort(sub { $_[0] lt $_[1] }, keys %index));
+ my $choices = $self->{orderedChoices};
+ my $labels = [];
+ my $values = $self->{values};
+ my $n = $self->{n};
+
+ foreach my $i (0 .. $n - 1) {
+ if (ref($choices->[$i]) eq "HASH") {
+ $labels->[$i] = (keys %{ $choices->[$i] })[0];
+ $values->[$i] = $choices->[$i]{ $labels->[$i] };
+ } else {
+ $labels->[$i] = $choices->[$i];
+ $values->[$i] = $choices->[$i] unless (defined($values->[$i]) && $values->[$i] ne '');
+ }
+
+ }
+ $self->{labels} = $labels;
+ $self->{values} = $values;
+
+ return;
}
#
-# Create the menu list
+# Find the correct choice in the ordered array
#
+sub getCorrectChoice {
+ my $self = shift;
+ my $label = shift;
+ if ($label =~ m/^\d+$/ && !$self->{noindex}) {
+ $label = ($self->flattenChoices)[$label];
+ Value::Error("The correct answer index is outside the range of choices provided")
+ if !defined($label);
+ }
+ my @choices = @{ $self->{orderedChoices} };
+ foreach my $i (0 .. $#choices) {
+ if ($label eq $self->{labels}[$i]) {
+ $self->{data} = [ $self->{labels}[$i] ];
+ return;
+ }
+ }
+ Value::Error("The correct choice must be one of the PopUp menu items");
+}
+
+sub flattenChoices {
+ my $self = shift;
+ my @choices = map { ref($_) eq "ARRAY" ? @$_ : $_ } @{ $self->{choices} };
+ foreach my $choice (@choices) {
+ if (ref($choice) eq "HASH") {
+ $choice = (keys %{$choice})[0];
+ }
+ }
+ return @choices;
+}
+
+# Convert a value string into a numeric index.
+sub getIndexByValue {
+ my ($self, $value) = @_;
+ return -1 unless defined $value;
+ my ($index) = grep { $self->{values}[$_] eq $value } 0 .. $#{ $self->{values} };
+ return $index // -1;
+}
+
+#
+# Use the actual choice string (aka label) rather than the value string as the output
+#
+sub string {
+ my $self = shift;
+ my $value = $self->value;
+ my $index = $self->getIndexByValue($value);
+ return $self->{labels}[$index];
+}
+
+#
+# Adjust student preview and answer strings to be the actual
+# choice string rather than the value string.
+#
+sub cmp_preprocess {
+ my $self = shift;
+ my $ans = shift;
+ if (defined $ans->{student_value} && $ans->{student_value} ne '') {
+ my $value = $ans->{student_value}->value;
+ my $index = $self->getIndexByValue($value);
+ my $label = $self->{labels}[$index];
+ $ans->{preview_latex_string} = $self->quoteTeX($label);
+ $ans->{student_ans} = $self->quoteHTML($label);
+ $ans->{original_student_ans} = $label;
+ }
+}
+
+# Allow users to convert the value string into a label
+
+sub answerLabel {
+ my ($self, $value) = @_;
+ my $index = $self->getIndexByValue($value);
+ return $self->{labels}[$index];
+}
+
+# Include the value string for the correct choice in the answer hash
+sub cmp {
+ my $self = shift;
+ my $cmp = $self->SUPER::cmp(
+ correct_choice => $self->value,
+ @_
+ );
+ return $cmp;
+}
+
sub menu { shift->MENU(0, @_) }
sub MENU {
- my $self = shift;
- my $extend = shift;
- my $name = shift;
- my $size = shift;
- my %options = @_;
- my @list = @{ $self->{choices} };
- my $placeholder = $self->{placeholder};
- my $menu = "";
+ my $self = shift;
+ my $extend = shift;
+ my $name = shift;
+ my $size = shift;
+ my %options = @_;
+ my @list = @{ $self->{labels} };
+ my $menu = "";
main::RECORD_IMPLICIT_ANS_NAME($name = main::NEW_ANS_NAME()) unless $name;
my $answer_value = (defined($main::inputs_ref->{$name}) ? $main::inputs_ref->{$name} : '');
- my $label = main::generate_aria_label($name);
+ my $aria_label = main::generate_aria_label($name);
if ($main::displayMode =~ m/^HTML/) {
$menu = main::tag(
@@ -212,17 +398,17 @@ sub MENU {
class => 'pg-select',
name => $name,
id => $name,
- aria_label => $label,
+ aria_label => $aria_label,
size => 1,
(
- $placeholder
+ $self->{placeholder}
? main::tag(
'option',
disabled => undef,
selected => undef,
value => '',
class => 'tex2jax_ignore',
- $placeholder
+ $self->{placeholder}
)
: ''
)
@@ -230,27 +416,22 @@ sub MENU {
'',
map {
main::tag(
- 'option', $_ eq $answer_value ? (selected => undef) : (),
- value => $_,
+ 'option', $self->{values}[$_] eq $answer_value ? (selected => undef) : (),
+ value => $self->{values}[$_],
class => 'tex2jax_ignore',
- $self->quoteHTML($_, 1)
+ $self->quoteHTML($self->{labels}[$_], 1)
)
- } @list
+ } (0 .. $#list)
)
)
);
} elsif ($main::displayMode eq 'PTX') {
if ($self->{showInStatic}) {
- $menu = qq() . "\n";
- foreach my $item (@list) {
- $menu .= '';
- my $escaped_item = $item;
- $escaped_item =~ s/&/&/g;
- $escaped_item =~ s/</g;
- $escaped_item =~ s/>/>/g;
- $menu .= $escaped_item . ' ' . "\n";
- }
- $menu .= ' ';
+ $menu = main::tag(
+ 'fillin',
+ name => $name,
+ join('', map { main::tag('choice', $self->quoteXML($_)) } (@list))
+ );
} else {
$menu = qq( );
}
diff --git a/macros/parsers/parserRadioButtons.pl b/macros/parsers/parserRadioButtons.pl
index cb20a0055d..53449ca5d1 100644
--- a/macros/parsers/parserRadioButtons.pl
+++ b/macros/parsers/parserRadioButtons.pl
@@ -52,7 +52,7 @@ =head1 DESCRIPTION
The values set as described above are the answers that will be
displayed in the past answers table. See the C option below
-for more information. Problem authors are encourages to set these
+for more information. Problem authors are encouraged to set these
values either as described above, or via the C option. This
is useful for instructors viewing past answers.
@@ -228,7 +228,7 @@ =head1 DESCRIPTION
ANS($radio->cmp);
-to get the answer checker for the radion buttons.
+to get the answer checker for the radio buttons.
You can use the RadioButtons object in MultiAnswer objects. This is
the reason for the RadioButton's C method (since that is