#!/usr/bin/perl
# fvwm-desktop, the underdocumented Fvwm desktop!
#
# To use, PipeRead 'fvwm-desktop -init' at the top of your .fvwm2rc, and
# PipeRead 'fvwm-desktop -start' in StartFunction. The latter is not necessary
# if you use neither desktop icons nor a taskbar.
#
# A few gotchas and esoterica that you should be aware of:
#
# + The filename of a menu item becomes the identifier for that item. Several
#   fields can be extrapolated from this identifier if you don't specifically
#   provide them in the menu file. In particular, the id is also the default
#   icon name, and the default display name is generated by replacing
#   underscores with spaces and capitalizing words.
# + Each application identifier must be unique against all others--not just
#   those in the same directory.
# + A ".directory" file inside a directory can be used to set specific
#   key-value data for that submenu (e.g. display name).
# + A menu file can have comment lines, starting with '#'.
# + You can keep personal keybindings under $USER_DIR/keys. Each line has the
#   format <app name>=<keystroke>.
# + The icon "start" will be displayed to the left of "Start" on the taskbar.
# + The icon "add_icon" will be displayed on the taskbar button which allows
#   users to add taskbar launchers. I use a right arrow.
# + The icons "check" and "nocheck" are used to mark selected and deselected
#   desktop options, respectively.
# + fvwm-desktop uses colorset #1
# + You must restart before a change to text color will take effect.

use strict;

sub my_die;

# Constants

my $PROG_NAME = 'fvwm-desktop';

my ($UPPER_LEFT, $UPPER_RIGHT, $LOWER_LEFT, $LOWER_RIGHT) = (0..3);  # icon box positions
my @ICON_POS_NAMES = ("Upper Left", "Upper Right", "Lower Left", "Lower Right");

my %CL_OPTIONS = (
    init                  => \&init,
    start                 => \&start,
    toggle                => \&toggle,
    'set-icons-position'  => \&set_icons_position,
    'set-text-color'      => \&set_text_color,
    'add-desktop-icon'    => \&add_desktop_icon,
    'remove-desktop-icon' => \&remove_desktop_icon,
    'add-taskbar-icon'    => \&add_taskbar_icon,
    'remove-taskbar-icon' => \&remove_taskbar_icon,
);

my @TOGGLE_OPTIONS = (
    'enable_desktop_icons',
    'horizontal_icons',
    'enable_taskbar',
    'autohide_taskbar',
);
my %OPTION_DEFAULTS = (
    enable_desktop_icons => 1,
    horizontal_icons     => 0,
    enable_taskbar       => 1,
    autohide_taskbar     => 0,
    icons_pos            => $UPPER_LEFT,
    text_color           => '#D0D0D0'
);

my ($START_MENU, $ADD_DESKTOP_ICON_MENU, $ADD_TASKBAR_ICON_MENU) = (0..2);  #menu types
my @MENU_PREFIXES = ('sm', 'adi', 'ati');

# Configurable Constants

my $SHARE_DIR  = "/usr/local/share/$PROG_NAME";
my $USER_DIR   = "$ENV{HOME}/.$PROG_NAME";

# This program is launched for fvwm-desktop -set-text-color calls. It must
# take a 6-digit hex color on stdin preceded by '#', and output a value in the
# same format when exiting to indicate a color change. If the program outputs
# nothing when exiting, it will be assumed that the user cancelled out.
my $COLOR_PICKER_PROG = 'gtkcolor';

my $DESKTOP_FONT        = '-*-arial-medium-r-*-*-10-*-*-*-*-*-*-*';
my $ICON_EXTENSION      = 'png';

my $WINDOW_LIST         = 'WindowList NoGeometryWithInfo, NoDeskNum, NoNumInDeskTitle, SortClassName';

my $DESKTOP_GRID_WIDTH  = 100;
my $DESKTOP_GRID_HEIGHT = 75;
my $DESKTOP_WIDTH       = 1152;
my $DESKTOP_HEIGHT      = 830;

my $TASKBAR_THICKNESS   = 25;
my $AUTOHIDE_THICKNESS  = 1;

# Derived Constants

my $DESKTOP_MAX_ROWS = int($DESKTOP_HEIGHT / $DESKTOP_GRID_HEIGHT);
my $DESKTOP_MAX_COLS = int($DESKTOP_WIDTH  / $DESKTOP_GRID_WIDTH);

my $MENU_DIR = "$SHARE_DIR/menu";
my $ICON_DIR = "$SHARE_DIR/icons";

# Globals

my %config;
my $menu;
my %menu_flat;
my @desktop_icons;
my @taskbar_icons;

### Begin Main ###

my $option = shift;
$option =~ s/^-+//;
my $func = $CL_OPTIONS{$option} or usage_abort();
create_user_dir();
read_all();
&$func(@ARGV);

#### End Main ####

# Main Functions

sub init {
    print "ImagePath +:$ICON_DIR\n";
    print "Style * NoIcon\n";
    set_menu();
    set_options_menu();
    set_mini_icons();
    set_desktop_icons();
    set_taskbar();
    set_default_keybindings();
    get_and_set_user_keybindings();
}

sub start {
    print "FvwmButtons FvwmDesktop\n" if
        @desktop_icons && $config{enable_desktop_icons};
    print "FvwmTaskBar\n" if $config{enable_taskbar};
}

sub add_desktop_icon {
    my $name = shift;
    $name eq '' and usage_abort();
    return unless list_find($name, \@desktop_icons) < 0;
    push @desktop_icons, $name;
    write_desktop_icons();
    reload_desktop();
}

sub remove_desktop_icon {
    my $name = shift;
    $name eq '' and usage_abort();
    my $index = list_find($name, \@desktop_icons);
    $index > -1 or return;
    remove_list_element(\@desktop_icons, $index);
    write_desktop_icons();
    reload_desktop();
    print "DestroyMenu ${name}_dt_icon_menu\n";
}

sub add_taskbar_icon {
    my $name = shift;
    $name eq '' and usage_abort();
    return unless list_find($name, \@taskbar_icons) < 0;
    push @taskbar_icons, $name;
    write_taskbar_icons();
    reload_taskbar();
}

sub remove_taskbar_icon {
    my $name = shift;
    $name eq '' and usage_abort();
    my $index = list_find($name, \@taskbar_icons);
    $index > -1 or return;
    remove_list_element(\@taskbar_icons, $index);
    write_taskbar_icons();
    reload_taskbar();
    print "DestroyMenu ${name}_tb_icon_menu\n";
}

sub toggle {
    my $key = shift;
    my $new_val = ($config{$key} ? 0 : 1);
    $config{$key} = $new_val;
    if ($key eq 'enable_desktop_icons') {
        if ($new_val) {
            print "FvwmButtons FvwmDesktop\n" if @desktop_icons;
        }
        else {
            print "KillModule FvwmButtons FvwmDesktop\n";
        }
    }
    elsif ($key eq 'horizontal_icons') {
        reload_desktop();
    }
    elsif ($key eq 'enable_taskbar') {
        $new_val ? print "FvwmTaskBar\n" :
                   print "KillModule FvwmTaskBar\n";
    }
    elsif ($key eq 'autohide_taskbar') {
        print "KillModule FvwmTaskBar\n" if $config{enable_taskbar};
        $new_val ? print "*FvwmTaskBar: AutoHide $AUTOHIDE_THICKNESS\n" :
                   print "DestroyModuleConfig FvwmTaskBar: AutoHide\n";
        print "FvwmTaskBar\n" if $config{enable_taskbar};
    }
    else {
        my_die "Unrecognized property for $PROG_NAME -toggle ($key)";
    }
    write_config();
    set_options_menu();
}

sub set_icons_position {
    my $pos = shift;
    return if $pos == $config{icons_pos};
    $config{icons_pos} = $pos;
    write_config();
    reload_desktop();
}

sub set_text_color {
    my $cur_color = $config{text_color};
    $cur_color =~ s/^#//;
    my $color = `$COLOR_PICKER_PROG #$cur_color`;
    chomp $color;
    return unless $color =~ /^\#[0-9A-Fa-f]{6}$/;
    $config{text_color} = $color;
    write_config();
}

# Helper Functions

sub create_user_dir {
    -d $USER_DIR or mkdir $USER_DIR or my_die "Can't mkdir $USER_DIR ($!)";
}

sub read_all {
    read_config();
    read_menu();
    read_desktop_icons();
    read_taskbar_icons();
}

sub read_config {
    %config = %OPTION_DEFAULTS;
    read_hash_file("$USER_DIR/config", \%config);
}

sub write_config {
    write_hash_file("$USER_DIR/config", \%config);
}

sub read_menu {
    chdir $MENU_DIR or ($menu = [], return);
    $menu = read_submenu('.');
}

sub read_submenu {
    my $dir = shift;
    my @contents;
    opendir DIR, $dir or return [];
    my @files = grep(!/^\.\.?$/, readdir DIR);
    closedir DIR;
    for my $file (@files) {
        my $path = ($dir eq '.' ? "$file" : "$dir/$file");
        my $menu_item = read_menu_item($file, $path);
        -d $path and $menu_item->{contents} = read_submenu($path);
        push @contents, $menu_item;
        $menu_flat{$file} = $menu_item;
    }
    @contents = sort {$a->{section} <=> $b->{section} ||
                      submenus_last($a, $b)           ||
                      $a->{order} <=> $b->{order}     ||
                      $a->{label} cmp $b->{label}}
                @contents;
    return \@contents;
}

sub submenus_last {
    my ($a, $b) = @_;
    return ($a->{submenu} && !$b->{submenu} ?  1 :
            $b->{submenu} && !$a->{submenu} ? -1 :
                                               0);
}

sub read_menu_item {
    my ($file, $path) = @_;
    my %data = (name => $file);
    if (-d $path) {
        $data{submenu} = $path;
        $data{submenu} =~ tr|/|_|;
        $data{action} = "Popup sm_sub_$data{submenu}";
        $path .= '/.directory';
    }
    read_hash_file($path, \%data);
    $data{label} = make_display_name($file) unless exists $data{label};
    $data{icon} = "$file.${ICON_EXTENSION}" unless exists $data{icon};
    $data{large_icon} = "large/$file.${ICON_EXTENSION}" unless exists $data{large_icon};
    $data{action} = "Exec $data{exec}" if exists $data{exec};
    if (exists $data{window_id}) {
        my @window_ids = split(/\|/, $data{window_id});
        $data{window_id} = \@window_ids;
    }

    return \%data;
}

sub read_desktop_icons {
    read_array_file("$USER_DIR/desktop", \@desktop_icons);
}

sub write_desktop_icons {
    write_array_file("$USER_DIR/desktop", \@desktop_icons);
}

sub read_taskbar_icons {
    read_array_file("$USER_DIR/taskbar", \@taskbar_icons);
}

sub write_taskbar_icons {
    write_array_file("$USER_DIR/taskbar", \@taskbar_icons);
}

sub set_options_menu {
    print <<"END";
DestroyMenu recreate DesktopOptionsMenu
AddToMenu DesktopOptionsMenu "Desktop Options" Title
END
    for my $option (@TOGGLE_OPTIONS) {
        my $label = make_display_name($option);
        print "+ '$label";
        $config{$option} ? print "%check.png%" : print "%nocheck.png%";
        print "'  PipeRead '$PROG_NAME -toggle $option'\n";
    }
    print "+ '' Nop\n";
    print "+ 'Set Icons Position'  Popup icons_pos_submenu\n";
    print "+ 'Set Text Color...'   Exec $PROG_NAME -set-text-color\n";

    print "DestroyMenu recreate icons_pos_submenu\n";
    print "AddToMenu icons_pos_submenu 'Icons Position' Title\n";
    for my $i (0..3) {
        print "+ '$ICON_POS_NAMES[$i]";
        $i == $config{icons_pos} ? print "%check.png%" : print "%nocheck.png%";
        print "'  PipeRead '$PROG_NAME -set-icons-position $i'\n";
    }
}

sub set_menu {
    set_submenu('', 'Start Menu',         '', 'StartMenu',          $menu, $START_MENU);
    set_submenu('', 'Add A Desktop Icon', '', 'AddDesktopIconMenu', $menu, $ADD_DESKTOP_ICON_MENU);
    set_submenu('', 'Add A Taskbar Icon', '', 'AddTaskbarIconMenu', $menu, $ADD_TASKBAR_ICON_MENU);
    print <<"END";
Mouse 1 R A  Menu StartMenu
Mouse 2 R A  $WINDOW_LIST
Mouse 3 R A  Menu AddDesktopIconMenu
Key F15 A N  Menu StartMenu
END
}

sub set_submenu {
    my ($name, $label, $icon, $submenu_id, $contents, $menu_type) = @_;
    my $is_add_icon_menu = ($menu_type != $START_MENU);
    my @submenus;
    print "AddToMenu $submenu_id '$label' Title\n";
    if ($is_add_icon_menu && $name ne '') {
        print "+ \"[Add This Submenu]%$icon%\"  PipeRead '$PROG_NAME -add-desktop-icon $name'\n";
    }
    my $prev_section = 0;
    for (@$contents) {
        next if $is_add_icon_menu && $_->{no_add_icon};
        print "+ '' Nop\n" if $_->{section} != $prev_section;
        print "+ \"$_->{label}%$_->{icon}%\" ";
        if (exists $_->{submenu}) {
            my $submenu_id = "$MENU_PREFIXES[$menu_type]_sub_$_->{submenu}";
            push @submenus, [$_->{name}, $_->{label}, $_->{icon}, $submenu_id,
                             $_->{contents}, $menu_type];
            print "Popup $submenu_id";
        }
        elsif ($is_add_icon_menu) {
            my $opt = ($menu_type == $ADD_DESKTOP_ICON_MENU ?
                       'add-desktop-icon' : 'add-taskbar-icon');
            print "PipeRead '$PROG_NAME -$opt $_->{name}'";
        }
        else {
            print "$_->{action}";
        }
        print "\n";
        $prev_section = $_->{section};
    }
    set_submenu(@$_) for @submenus;
}

sub set_mini_icons {
    while (my ($name, $data) = each %menu_flat) {
        next unless exists $data->{window_id};
        print "Style \"$_\"  MiniIcon $data->{icon}\n"
            for @{$data->{window_id}};
    }
}

sub set_desktop_icons {
    my ($xpos, $ypos);

    my $num_icons = scalar @desktop_icons;
    my ($num_cols, $num_rows);
    if ($config{horizontal_icons}) {
        $num_rows = int($num_icons / $DESKTOP_MAX_COLS);
        $num_rows++ if $num_icons % $DESKTOP_MAX_COLS;
        $num_cols = ($num_rows == 1 ? $num_icons : $DESKTOP_MAX_COLS);
    } else {
        $num_cols = int($num_icons / $DESKTOP_MAX_ROWS);
        $num_cols++ if $num_icons % $DESKTOP_MAX_ROWS;
        $num_rows = ($num_cols == 1 ? $num_icons : $DESKTOP_MAX_ROWS);
    }

    if ($config{icons_pos} == $UPPER_LEFT) {
        $xpos = $ypos = 0;
    } elsif ($config{icons_pos} == $UPPER_RIGHT) {
        $xpos = $DESKTOP_WIDTH - $DESKTOP_GRID_WIDTH * $num_cols;
        $ypos = 0;
    } elsif ($config{icons_pos} == $LOWER_LEFT) {
        $xpos = 0;
        $ypos = $DESKTOP_HEIGHT - $DESKTOP_GRID_HEIGHT * $num_rows;
    } else {   # LOWER_RIGHT
        $xpos = $DESKTOP_WIDTH - $DESKTOP_GRID_WIDTH * $num_cols;
        $ypos = $DESKTOP_HEIGHT - $DESKTOP_GRID_HEIGHT * $num_rows;
    }

    print <<"END";
Colorset 1  Transparent, fg $config{text_color}
Style FvwmDesktop  BorderWidth 0, CirculateSkip, NeverFocus, NoHandles, NoTitle
Style FvwmDesktop  ParentalRelativity, StaysOnBottom, Sticky, WindowListSkip
*FvwmDesktop: Frame           0
*FvwmDesktop: Colorset        1
*FvwmDesktop: Font            "$DESKTOP_FONT"
*FvwmDesktop: BoxSize         fixed
*FvwmDesktop: ButtonGeometry  ${DESKTOP_GRID_WIDTH}x${DESKTOP_GRID_HEIGHT}+$xpos+$ypos
END

    return unless @desktop_icons;

    print <<"END";
*FvwmDesktop: Rows     $num_rows
*FvwmDesktop: Columns  $num_cols
END
    my ($filler_width, $filler_height);
    my $need_filler = 0;
    if ($config{horizontal_icons}) {
        $filler_width = ($num_rows * $num_cols) - $num_icons;
        $filler_height = 1;
        $need_filler = ($filler_width > 0);
    } else {
        $filler_width = 1;
        $filler_height = ($num_rows * $num_cols) - $num_icons;
        $need_filler = ($filler_height > 0);
    }
    if ($need_filler) {
        my ($filler_xpos, $filler_ypos);
        if ($config{horizontal_icons}) {
            $filler_ypos = $num_rows - 1;
            $filler_xpos = $num_icons % $num_cols;
        } else {
            $filler_xpos = $num_cols - 1;
            $filler_ypos = $num_icons % $num_rows;
        }
        print <<"END";
*FvwmDesktop: (${filler_width}x$filler_height+$filler_xpos+$filler_ypos \\
               Action (Mouse 1)  Menu StartMenu,          \\
               Action (Mouse 2)  "$WINDOW_LIST",          \\
               Action (Mouse 3)  Menu AddDesktopIconMenu)
END
    }

    my ($row, $col) = (0, 0);
    for my $name (@desktop_icons) {
        my $data = $menu_flat{$name} or next;
        print <<"END";
*FvwmDesktop: (+$col+$row  Title             "$data->{label}",       \\
                           Icon              $data->{large_icon},    \\
                           Action (Mouse 1)  $data->{action},        \\
                           Action (Mouse 3)  Popup ${name}_dt_icon_menu)
DestroyMenu recreate ${name}_dt_icon_menu
AddToMenu ${name}_dt_icon_menu  "$data->{label} Icon" Title
    + "Remove From Desktop"  PipeRead '$PROG_NAME -remove-desktop-icon $name'
END
        if ($config{horizontal_icons}) {
            $col++;
            $col == $num_cols and ($col = 0, $row++);
        } else {
            $row++;
            $row == $num_rows and ($row = 0, $col++);
        }
    }
}

sub config_taskbar_icons {
    print "*FvwmTaskBar: Button Icon add_icon.$ICON_EXTENSION, Action Menu AddTaskbarIconMenu\n";
    for my $name (@taskbar_icons) {
        my $data = $menu_flat{$name} or next;
        print <<"END";
*FvwmTaskBar: Button Icon $data->{icon}, Action (Mouse 1) $data->{action}, \\
                                         Action (Mouse 3) Popup ${name}_tb_icon_menu
DestroyMenu recreate ${name}_tb_icon_menu
AddToMenu ${name}_tb_icon_menu  "$data->{label} Icon" Title
    + "Remove From Taskbar"  PipeRead '$PROG_NAME -remove-taskbar-icon $name'
END
    }
}

sub set_taskbar {
    print <<"END";
Style FvwmTaskBar BorderWidth 3, CirculateSkip, NeverFocus, NoHandles, NoTitle
Style FvwmTaskBar StaysOnTop, Sticky, WindowListSkip
AddToFunc TBSelectWindow
    + I Iconify off
    + I Focus
    + I Raise
*FvwmTaskBar: Geometry     +0-0
*FvwmTaskBar: Font         -*-arial-medium-r-*-*-9-*-*-*-*-*-*-*
*FvwmTaskBar: SelFont      -*-arial-bold-r-*-*-9-*-*-*-*-*-*-*
*FvwmTaskBar: StatusFont   -*-arial-medium-r-*-*-9-*-*-*-*-*-*-*
*FvwmTaskBar: Fore         #000000
*FvwmTaskBar: Back         #B0B0B0
*FvwmTaskBar: IconBack     #808080
*FvwmTaskBar: StartIcon    start.$ICON_EXTENSION
*FvwmTaskBar: ClockFormat  %l:%H %p
*FvwmTaskBar: AutoStick
*FvwmTaskBar: UseSkipList
*FvwmTaskBar: ShowTips
*FvwmTaskBar: IgnoreOldMail
*FvwmTaskBar: Action Click1 TBSelectWindow
*FvwmTaskBar: Action Click2 Iconify
*FvwmTaskBar: Action Click3 Close
END
    if ($config{autohide_taskbar}) {
        print "*FvwmTaskBar: AutoHide $AUTOHIDE_THICKNESS\n";
    }
    config_taskbar_icons();
}

sub set_default_keybindings {
    while (my ($name, $data) = each %menu_flat) {
        set_keybinding($data->{key}, $data->{action}) if exists $data->{key};
    }
}

sub get_and_set_user_keybindings {
    my %keybindings;
    read_hash_file("$USER_DIR/keys", \%keybindings);
    while (my ($app, $key) = each %keybindings) {
        chomp;
        my $app_data = $menu_flat{$app} or next;
        set_keybinding($key, $app_data->{action});
    }
    close F;
}

sub set_keybinding {
    my ($key, $action) = @_;
    my $modifiers;
    if ($key =~ /^(.+)-(['"]?)([^-]*.)\2$/) {
        $modifiers = $1;
        $modifiers =~ tr/-//d;
        $key = $3;
    }
    else {
        $modifiers = 'N';
    }
    print "Key $key A $modifiers  $action\n";
}

sub reload_desktop {
    return unless $config{enable_desktop_icons};
    print "KillModule FvwmButtons FvwmDesktop\n";
    print "DestroyModuleConfig FvwmDesktop*\n";
    set_desktop_icons();
    print "FvwmButtons FvwmDesktop\n";
}

sub reload_taskbar {
    print <<"END";
KillModule FvwmTaskBar
DestroyModuleConfig FvwmTaskBar: Button *
END
    config_taskbar_icons();
    print "FvwmTaskBar\n";
}

sub read_array_file {
    my ($file, $array) = @_;
    open F, "<$file" or return 0;
    while (<F>) {
        chomp;
        next if /^#/ || /^\s*$/;
        push @$array, trim_whitespace($_);
    }
    close F;
    return 1;
}

sub write_array_file {
    my ($file, $array) = @_;
    open F, ">$file" or my_die "Can't open $file for writing ($!)";
    for (@$array) {
        print F "$_\n" or my_die "Write error on $file ($!)";
    }
    close F or my_die "Can't close $file after writing ($!)";
}

sub read_hash_file {
    my ($file, $hash) = @_;
    open F, "<$file" or return 0;
    while (<F>) {
        chomp;
        next if /^#/ || /^\s*$/;
        my ($key, $val);
        if (/=/) {
            ($key, $val) = split(/=/, $_, 2);
            $key = trim_whitespace($key);
            $val = trim_whitespace($val);
        }
        else {
            $key = trim_whitespace($_);
            $val = 1;
        }
        $$hash{$key} = $val;
    }
    close F;
    return 1;
}

sub write_hash_file {
    my ($file, $hash) = @_;
    open F, ">$file" or my_die "Can't open $file for writing ($!)";
    while (my ($key, $val) = each %$hash) {
        print F "$key=$val\n" or my_die "Write error on $file ($!)";
    }
    close F or my_die "Can't close $file after writing ($!)";
}

sub make_display_name {
    my $name = shift;
    $name =~ tr/_-/ /;            # convert underscores to spaces
    $name =~ s/\b([a-z])/\U$1/g;  # capitalize words
    return $name;
}

sub trim_whitespace {
    my $str = shift;
    $str =~ s/^\s+//;
    $str =~ s/\s+$//;
    return $str;
}

sub list_find {
    my ($item, $list) = @_;
    for (my $i=0; $i < @$list; $i++) {
        $$list[$i] eq $item and return $i;
    }
    return -1;
}

sub remove_list_element {
    my ($list, $index) = @_;
    splice(@$list, $index, 1, ());
}

sub usage_abort {
    print STDERR <<"END";
Usage: $PROG_NAME -init    (at the top of .fvwm2rc) or
       $PROG_NAME -start   (in StartFuncion)        or
       $PROG_NAME -toggle <option>                  or
       $PROG_NAME -set-icons-position <num>         or
       $PROG_NAME -set-text-color                   or
       $PROG_NAME -add-desktop-icon    <app>        or
       $PROG_NAME -remove-desktop-icon <app>        or
       $PROG_NAME -add-taskbar-icon    <app>        or
       $PROG_NAME -remove-taskbar-icon <app>
END
    exit(1);
}

sub my_die {
    my $msg = shift;
    die "$PROG_NAME: $msg\n";
}
