File Coverage

blib/lib/Tk/MenuDialog.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Tk::MenuDialog;
2             ##----------------------------------------------------------------------------
3             ## :mode=perl:indentSize=2:tabSize=2:noTabs=true:
4             ##****************************************************************************
5             ## NOTES:
6             ## * Before comitting this file to the repository, ensure Perl Critic can be
7             ## invoked at the HARSH [3] level with no errors
8             ##****************************************************************************
9             =head1 NAME
10            
11             Tk::MenuDialog - A Moo based object oriented interface for creating and
12             display a dialog of buttons to be used as a menu using Tk
13            
14             =head1 VERSION
15            
16             Version 0.03
17            
18             =head1 SYNOPSIS
19            
20             use Tk::MenuDialog;
21             use File::Basename qw(dirname);
22            
23             my $menu = Tk::MenuDialog->new;
24            
25             ## Add the script's directory to the icon path
26             ## when searching for icon files
27             $menu->add_icon_path(dirname(__FILE__));
28            
29             ## Add menu items to the menu
30             $menu->add_item(
31             label => qq{&Configure},
32             icon => qq{settings.png},
33             );
34             $menu->add_item(
35             label => qq{&Run Tests},
36             icon => qq{run.png},
37             );
38            
39             ## Allow operator to cancel the menu
40             $menu->can_cancel(1);
41            
42             ## Display the menu and return hash reference of the selected item,
43             ## or UNDEF if canceled
44             my $selection = $menu->show;
45            
46             =cut
47            
48             ##****************************************************************************
49             ##****************************************************************************
50 1     1   19213 use Moo;
  1         13536  
  1         5  
51             ## Moo enables strictures
52             ## no critic (TestingAndDebugging::RequireUseStrict)
53             ## no critic (TestingAndDebugging::RequireUseWarnings)
54 1     1   2240 use Readonly;
  1         2713  
  1         45  
55 1     1   5 use Carp qw(confess cluck);
  1         2  
  1         55  
56 1     1   1490 use Tk;
  0            
  0            
57             use Tk::Photo;
58             use Tk::PNG;
59             use Tk::JPEG;
60             use Data::Dumper;
61             use JSON;
62             use Try::Tiny;
63            
64             ## Version string
65             our $VERSION = qq{0.03};
66            
67             ## Used when importing a form, these are "simple" non-array attributes
68             Readonly::Array my @SIMPLE_ATTRIBUTES => (
69             qw(title button_font min_width min_height can_cancel button_spacing)
70             );
71            
72             ##****************************************************************************
73             ## Object attribute
74             ##****************************************************************************
75            
76             =head1 ATTRIBUTES
77            
78             =cut
79            
80             ##****************************************************************************
81             ##****************************************************************************
82            
83             =head2 title
84            
85             =over 2
86            
87             Title of the menu
88            
89             DEFAULT: ''
90            
91             =back
92            
93             =cut
94            
95             ##----------------------------------------------------------------------------
96             has title => (
97             is => qq{rw},
98             default => qq{},
99             );
100            
101             ##****************************************************************************
102             ##****************************************************************************
103            
104             =head2 can_cancel
105            
106             =over 2
107            
108             Indicates if the operator can close the dialog without a selection
109            
110             DEFAULT: 1
111            
112             =back
113            
114             =cut
115            
116             ##----------------------------------------------------------------------------
117             has can_cancel => (
118             is => qq{rw},
119             default => 1,
120             );
121            
122             ##****************************************************************************
123            
124             =head2 cancel_on_escape
125            
126             =over 2
127            
128             Boolean value indicating if pressing the Escape key should simulate closing
129             the window and canceling the dialog.
130            
131             DEFAULT: 1
132            
133             =back
134            
135             =cut
136            
137             ##----------------------------------------------------------------------------
138             has cancel_on_escape => (
139             is => qq{rw},
140             default => 1,
141             );
142            
143             ##****************************************************************************
144             ##****************************************************************************
145            
146             =head2 items
147            
148             =over 2
149            
150             Array reference of items contained in this menu.
151            
152             =back
153            
154             =cut
155            
156             ##----------------------------------------------------------------------------
157             has items => (
158             is => qq{rwp},
159             );
160            
161             ##****************************************************************************
162             ##****************************************************************************
163            
164             =head2 icon_path
165            
166             =over 2
167            
168             An array containing various paths to use when locating icon image files.
169            
170             =back
171            
172             =cut
173            
174             ##----------------------------------------------------------------------------
175             has icon_path => (
176             is => qq{rwp},
177             );
178            
179             ##****************************************************************************
180            
181             =head2 button_font
182            
183             =over 2
184            
185             Font to use for the buttons.
186            
187             DEFAULT: 'times 10'
188            
189             =back
190            
191             =cut
192            
193             ##----------------------------------------------------------------------------
194             has button_font => (
195             is => qq{rw},
196             default => qq{times 10},
197             );
198            
199             ##****************************************************************************
200            
201             =head2 button_spacing
202            
203             =over 2
204            
205             Number of pixels between each button
206            
207             DEFAULT: 0
208            
209             =back
210            
211             =cut
212            
213             ##----------------------------------------------------------------------------
214             has button_spacing => (
215             is => qq{rw},
216             default => 0,
217             );
218            
219             ##****************************************************************************
220            
221             =head2 min_width
222            
223             =over 2
224            
225             Minimum width of the dialog.
226            
227             DEFAULT: 300
228            
229             =back
230            
231             =cut
232            
233             ##----------------------------------------------------------------------------
234             has min_width => (
235             is => qq{rw},
236             default => 300,
237             );
238            
239             ##****************************************************************************
240            
241             =head2 min_height
242            
243             =over 2
244            
245             Minimum height of the dialog.
246            
247             DEFAULT: 80
248            
249             =back
250            
251             =cut
252            
253             ##----------------------------------------------------------------------------
254             has min_height => (
255             is => qq{rw},
256             default => 80,
257             );
258            
259             ##****************************************************************************
260             ## "Private" atributes
261             ##***************************************************************************
262            
263             ## Holds reference to variable Tk watches for dialog completion
264             has _watch_variable => (
265             is => qq{rw},
266             );
267            
268             ## Grid row for placing the next widget
269             has _grid_row => (
270             is => qq{rw},
271             default => 0,
272             );
273            
274             ##****************************************************************************
275             ## Object Methods
276             ##****************************************************************************
277            
278             =head1 METHODS
279            
280             =cut
281            
282             =for Pod::Coverage BUILD
283             This causes Test::Pod::Coverage to ignore the list of subs
284             =cut
285             ##----------------------------------------------------------------------------
286             ## @fn BUILD()
287             ## @brief Moo calls BUILD after the constructor is complete
288             ## @return
289             ## @note
290             ##----------------------------------------------------------------------------
291             sub BUILD
292             {
293             my $self = shift;
294            
295             ## Create an empty list of items
296             $self->_set_items([]);
297            
298             ## Create an empty list
299             $self->_set_icon_path([]);
300            
301             return($self);
302             }
303            
304             ##****************************************************************************
305             ##****************************************************************************
306            
307             =head2 add_item($hash)
308            
309             =over 2
310            
311             =item B
312            
313             Add a field to the form.
314            
315             =item B
316            
317             A hash reference with the following key / value pairs:
318             label - Required paramater with
319             icon - Optional filename of the icon to display
320             icon_location - Optional location relative to button
321             text for the icon
322             DEFAULT: "left"
323            
324             =item B
325            
326             UNDEF on error, or the hash reference of the item created
327            
328             =back
329            
330             =cut
331            
332             ##----------------------------------------------------------------------------
333             sub add_item
334             {
335             my $self = shift;
336             my $param = shift;
337            
338             ## Check for missing keys
339             my @missing = ();
340             foreach my $key (qw(label))
341             {
342             push(@missing, $key) unless(exists($param->{$key}));
343             }
344             if (scalar(@missing))
345             {
346             cluck(qq{Item missing the following reuired key(s): "},
347             join(qq{", "}, @missing),
348             qq{"}
349             );
350             }
351            
352             ## Save the item in the list of items
353             push(@{$self->items}, $param) if ($param);
354            
355             return($param);
356             }
357            
358             ##****************************************************************************
359             ##****************************************************************************
360            
361             =head2 show()
362            
363             =over 2
364            
365             =item B
366            
367             Show the dialog as a new MainWindow.
368            
369             The function will return if the users cancels the dialog or clicks a button
370            
371             =item B
372            
373             NONE
374            
375             =item B
376            
377             UNDEF when canceled, or the hash reference associated with the button clicked.
378            
379             =back
380            
381             =cut
382            
383             ##----------------------------------------------------------------------------
384             sub show
385             {
386             my $self = shift;
387             my $test = shift;
388             my $win; ## Window widget
389             my $result; ## Variable used to capture the result
390             my $buttons = [];
391            
392             ## Create as a new MainWindow
393             $win = MainWindow->new(-title => $self->title);
394            
395             ## Hide the window
396             $win->withdraw;
397            
398             ## Do not allow user to resize
399             $win->resizable(0,0);
400            
401             ## Now use the grid geometry manager to layout everything
402             $self->_grid_row(0);
403            
404             ## Insert spacer (if needed)
405             $self->_insert_spacer($win);
406            
407             my $first;
408             ## Now add the itmes
409             my $number = 0;
410             foreach my $item (@{$self->items})
411             {
412             ## See if the widget was created
413             if (my $widget = $self->_build_button($item, $win, $number))
414             {
415             ## Place the widget
416             $widget->grid(
417             -row => $self->_next_row,
418             -rowspan => 1,
419             -column => 1,
420             -columnspan => 1,
421             -sticky => qq{nsew},
422             );
423            
424             ## See if button should be disabled
425             $widget->configure(-state => qq{disabled}) if ($item->{disabled});
426            
427             ## See if this is our first non-disabled field
428             $first = $widget if (!$first && !$item->{disabled});
429             }
430             $number++;
431            
432             ## Insert spacer (if needed)
433             $self->_insert_spacer($win);
434             }
435            
436             $self->_watch_variable(\$result);
437            
438             ## Setup any keyboard bindings
439             $self->_set_key_bindings($win);
440            
441             ## Calculate the geometry
442             $self->_calc_geometry($win);
443            
444             ## Display the window
445             $win->deiconify;
446            
447             ## Detect user closing the window
448             $win->protocol('WM_DELETE_WINDOW' =>
449             sub
450             {
451             return unless ($self->can_cancel);
452             $result = -1;
453             });
454            
455             ## See if we are testing
456             if ($test)
457             {
458             ## Make sure the string is the correct format
459             if ($test =~ /TEST:\s+(-?\d+)/x)
460             {
461             ## < 0 means CANCEL
462             ## >= 0 means select item indicated
463             $test = $1;
464            
465             ## Set a callback to close the window
466             $win->after(1500, sub {$result = $test;});
467             }
468             }
469            
470             ## Set the focus to the item
471             $first->focus() if ($first);
472            
473             ## Wait for variable to change
474             $win->waitVariable(\$result);
475            
476             ## Hide the window
477             $win->withdraw();
478            
479             ## See if we have a result
480             if (defined($result))
481             {
482             ## See if the result is a valid index
483             if (($result >= 0) && ($result < scalar(@{$self->items})))
484             {
485             ## Return the item object
486             $result = $self->items->[$result];
487             }
488             else
489             {
490             ## Invalid index, so return UNDEF
491             $result = undef;
492             }
493             ## Build the result
494             }
495            
496             ## Destroy the window and all its widgets
497             $win->destroy();
498            
499             return($result);
500             }
501            
502             ##****************************************************************************
503             ##****************************************************************************
504            
505             =head2 add_icon_path()
506            
507             =over 2
508            
509             =item B
510            
511             Description goes here
512            
513             =item B
514            
515             NONE
516            
517             =item B
518            
519             NONE
520            
521             =back
522            
523             =cut
524            
525             ##----------------------------------------------------------------------------
526             sub add_icon_path
527             {
528             my $self = shift;
529             my $path = shift;
530            
531             push(@{$self->icon_path}, $path) if ($path);
532            
533             return;
534             }
535            
536             ##----------------------------------------------------------------------------
537             ## @fn _build_button($item, $win)
538             ## @brief Build the button for the given item in the specified window
539             ## @param $item - HASH reference containing button information
540             ## @param $win - Parent object for the button
541             ## @return
542             ## @note
543             ##----------------------------------------------------------------------------
544             Readonly::Scalar my $IMAGE_SPACER => qq{ - };
545             sub _build_button
546             {
547             my $self = shift;
548             my $item = shift;
549             my $win = shift;
550             my $number = shift;
551             my $widget;
552            
553             my $button_text = $item->{label};
554             my $underline = index($button_text, qq{&});
555             $button_text =~ s/\&//gx; ## Remove the &
556            
557             my $image;
558             if (my $filename = $item->{icon})
559             {
560             unless (-f qq{$filename})
561             {
562             $filename = qq{};
563             FIND_ICON_FILE_LOOP:
564             foreach my $dir (@{$self->icon_path})
565             {
566             my $name = File::Spec->catfile(File::Spec->splitdir($dir), $item->{icon});
567             if (-f qq{$name})
568             {
569             $filename = $name;
570             last FIND_ICON_FILE_LOOP;
571             }
572             }
573             }
574            
575             ## See if we have a filename
576             if ($filename)
577             {
578             ## Load the filename
579             $image = $win->Photo(-file => $filename)
580             }
581             else
582             {
583             cluck(
584             qq{Could not locate icon "$item->{icon}"\nSearch Path:\n "} .
585             join(qq{"\n "}, (qq{.}, @{$self->icon_path})) .
586             qq{"\n}
587             );
588             }
589             }
590            
591             ## Create the button
592             if ($image)
593             {
594             $button_text = $IMAGE_SPACER . $button_text . qq{ };
595             $underline += length($IMAGE_SPACER) if ($underline >= 0);
596             $widget = $win->Button(
597             -text => $button_text,
598             -font => $self->button_font,
599             # -width => length($button_text) + 2,
600             -anchor => qq{w},
601             -command => sub {${$self->_watch_variable} = $number;},
602             -underline => $underline,
603             -image => $image,
604             -compound => qq{left},
605             );
606             }
607             else
608             {
609             $widget = $win->Button(
610             -text => $button_text,
611             -font => $self->button_font,
612             -width => length($button_text) + 2,
613             -command => sub {${$self->_watch_variable} = $number;},
614             -underline => $underline,
615             );
616             }
617            
618             return($widget);
619             }
620            
621             ##----------------------------------------------------------------------------
622             ## @fn _determine_dimensions($parent)
623             ## @brief Determine the overal dimensions of the given widgets
624             ## @param $parent - Refernce to parent widget
625             ## @return ($width, $height) - The width and height
626             ## @note
627             ##----------------------------------------------------------------------------
628             sub _determine_dimensions
629             {
630             my $parent = shift;
631             my @children = $parent->children;
632             my $max_width = 0;
633             my $max_height = 0;
634            
635             foreach my $widget (@children)
636             {
637             my ($width, $height, $x_pos, $y_pos) = split(/[x\+]/x, $widget->geometry());
638             $width += $x_pos;
639             $height += $y_pos;
640            
641             $max_width = $width if ($width > $max_width);
642             $max_height = $height if ($height > $max_height);
643            
644             }
645            
646             return($max_width, $max_height);
647             }
648            
649             ##----------------------------------------------------------------------------
650             ## @fn _calc_geometry($parent)
651             ## @brief Calculate window geometry to place the given window in the center
652             ## of the screen
653             ## @param $parent - Reference to the Main window widget
654             ## @return void
655             ## @note
656             ##----------------------------------------------------------------------------
657             sub _calc_geometry
658             {
659             my $self = shift;
660             my $parent = shift;
661            
662             return if (!defined($parent));
663             return if (ref($parent) ne "MainWindow");
664            
665             ## Allow the geometry manager to update all sizes
666             $parent->update();
667            
668             ## Determine the windows dimensions
669             my ($width, $height) = _determine_dimensions($parent);
670            
671             ## Determine the width and make sure it is at least $self->min_width
672             $width = $self->min_width if ($width < $self->min_width);
673            
674             ## Determine the height and make sure it is at least $self->min_height
675             $height = $self->min_height if ($height < $self->min_height);
676            
677             ## Calculate the X and Y to center on the screen
678             my $pos_x = int(($parent->screenwidth - $width) / 2);
679             my $pos_y = int(($parent->screenheight - $height) / 2);
680            
681             ## Update the geometry with the calculated values
682             $parent->geometry("${width}x${height}+${pos_x}+${pos_y}");
683            
684             return;
685             }
686            
687             ##----------------------------------------------------------------------------
688             ## @fn _set_key_bindings($win)
689             ## @brief Set key bindings for the given window
690             ## @param $win - Window to use for binding keyboard events
691             ## @return NONE
692             ## @note
693             ##----------------------------------------------------------------------------
694             sub _set_key_bindings
695             {
696             my $self = shift;
697             my $win = shift;
698            
699             ## Now add the "hot key"
700             my $number = 0;
701             foreach my $item (@{$self->items})
702             {
703             ## Skip disabled buttons
704             unless ($item->{disabled})
705             {
706             ## Look for an ampersand in the label
707             my $underline = index($item->{label}, qq{&});
708            
709             ## See if an ampersand was found
710             if ($underline >= 0)
711             {
712             $underline++;
713             ## Find the key within the string
714             my $keycap = lc(substr($item->{label}, $underline, 1));
715            
716             ## Bind the key
717             $win->bind(
718             qq{} => [
719             sub
720             {
721             my $widget = shift;
722             my $ref = shift;
723             my $val = shift;
724             ${$ref} = $val;
725             },
726             $self->_watch_variable,
727             $number,
728             ]
729             );
730             }
731             }
732             $number++;
733             }
734            
735             ## See if option set
736             if ($self->can_cancel and $self->cancel_on_escape)
737             {
738             $win->bind(qq{} => sub {${$self->_watch_variable} = -1;});
739             }
740            
741             return;
742             }
743            
744             ##****************************************************************************
745             ##****************************************************************************
746            
747             =head2 initialize($param)
748            
749             =over 2
750            
751             =item B
752            
753             initialize the form from a HASH reference, JSON string, or JSON file.
754             In all cases, the hash should have the following format
755            
756             {
757             title => 'My Menu',
758             can_cancel => 0,
759             items => [
760             {
761             label => '&Configure',
762             icon => 'settings.png',
763             },
764             {
765             label => '&Run',
766             icon => 'run.png',
767             },
768             {
769             label => 'E&xit',
770             icon => 'exit.png',
771             },
772             ]
773             }
774            
775             =item B
776            
777             $param - HASH reference, or scalar containin JSON string, or filename
778            
779             =item B
780            
781             NONE
782            
783             =back
784            
785             =cut
786            
787             ##----------------------------------------------------------------------------
788             sub initialize
789             {
790             my $self = shift;
791             my $param = shift;
792            
793             unless (defined($param))
794             {
795             cluck(qq{Parameter missing in call to initialize()\n});
796             return $self;
797             }
798             unless (ref($param))
799             {
800             my $str = qq{};
801             if (-f qq{$param})
802             {
803             if (open(my $fh, qq{<}, $param))
804             {
805             ## Read the file
806             while (my $line = <$fh>)
807             {
808             ## trim leading whitespace
809             $line =~ s/^\s+//x;
810             ## trim trailing whitespace
811             $line =~ s/\s+$//x;
812            
813             ## See if this is a comment and should be ignored
814             next if ($line =~ /^[#;]/x);
815            
816             ## Add this line to the option string
817             $str .= $line . qq{ };
818             }
819             close($fh);
820             }
821             }
822             else
823             {
824             $str = $param;
825             }
826            
827             try
828             {
829             $param = JSON->new->utf8(1)->relaxed->decode($str);
830             };
831             }
832            
833             $self->_import_hash($param);
834            
835             ## Return object to allow chaining
836             return $self;
837             }
838            
839             ##----------------------------------------------------------------------------
840             ## @fn _import_hash($hash)
841             ## @brief Load a form using the hash parameters
842             ## @param $param - Hash reference
843             ## @return NONE
844             ## @note
845             ##----------------------------------------------------------------------------
846             sub _import_hash
847             {
848             my $self = shift;
849             my $param = shift;
850            
851             ## Import the "simple" non-array attributes
852             foreach my $attr (@SIMPLE_ATTRIBUTES)
853             {
854             $self->$attr($param->{$attr}) if (exists($param->{$attr}));
855             }
856            
857             ## Import the items
858             if (exists($param->{items}) && (ref($param->{items}) eq qq{ARRAY}))
859             {
860             foreach my $entry (@{$param->{items}})
861             {
862             unless (my $field = $self->add_item($entry))
863             {
864             cluck(
865             qq{Unable to create an item\n},
866             Data::Dumper->Dump([$entry], [qw(entry)]),
867             qq{\n}
868             );
869             }
870             }
871             }
872            
873             if (exists($param->{icon_path}) && (ref($param->{icon_path}) eq qq{ARRAY}))
874             {
875             foreach my $entry (@{$param->{icon_path}})
876             {
877             unless (my $field = $self->add_icon_path($entry))
878             {
879             cluck(
880             qq{Unable to add to the icon path\n},
881             Data::Dumper->Dump([$entry], [qw(entry)]),
882             qq{\n}
883             );
884             }
885             }
886             }
887             return;
888             }
889            
890             ##----------------------------------------------------------------------------
891             ## @fn _next_row()
892             ## @brief Return the current grid row and increment
893             ## @param NONE
894             ## @return SCALAR containing the next grid row
895             ## @note
896             ##----------------------------------------------------------------------------
897             sub _next_row
898             {
899             my $self = shift;
900            
901             my $row = $self->_grid_row;
902            
903             $self->_grid_row($row + 1);
904            
905             return($row);
906             }
907            
908             ##----------------------------------------------------------------------------
909             ## @fn _insert_spacer($win)
910             ## @brief Insert a spacer (if needed) into the given window
911             ## @param $win - Tk window object
912             ## @return
913             ## @note
914             ##----------------------------------------------------------------------------
915             sub _insert_spacer
916             {
917             my $self = shift;
918             my $win = shift;
919            
920             return unless ($self->button_spacing);
921            
922             ## Use an empty frame as a spacer
923             $win->Frame(-height => $self->button_spacing)->grid(
924             -row => $self->_next_row,
925             );
926            
927            
928            
929             }
930            
931            
932            
933             ##****************************************************************************
934             ## Additional POD documentation
935             ##****************************************************************************
936            
937             =head1 AUTHOR
938            
939             Paul Durden Ealabamapaul AT gmail.comE
940            
941             =head1 COPYRIGHT & LICENSE
942            
943             Copyright (C) 2015 by Paul Durden.
944            
945             This program is free software; you can redistribute it and/or modify it
946             under the same terms as Perl itself.
947            
948             =cut
949            
950             1; ## End of module
951             __END__