1011 lines
35 KiB
Bash
Executable File
1011 lines
35 KiB
Bash
Executable File
#!/bin/bash
|
|
# morc_menu
|
|
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
# ┃ Categorized menu of desktop applications ┃
|
|
# ┃ ┃
|
|
# ┃ This script simulates the functionality of an Openbox ┃
|
|
# ┃ / Fluxbox style menu without requiring those window ┃
|
|
# ┃ managers or associated dependencies. It was originally ┃
|
|
# ┃ written for the i3 window manager, and using 'dmenu' as ┃
|
|
# ┃ as its front-end, but it should also work in pretty much ┃
|
|
# ┃ any X11 environment, and has been tested with ┃
|
|
# ┃ alternative front-ends such as 'rofi', 'zenity' and ┃
|
|
# ┃ 'yada'. ┃
|
|
# ┃ ┃
|
|
# ┃ This script generates menus based upon the presence of ┃
|
|
# ┃ .desktop files in the system-wide definition folder ┃
|
|
# ┃ /usr/share/applications and the user-local definition ┃
|
|
# ┃ folder ${HOME}/.local/share/applications, per the ┃
|
|
# ┃ xfreedesktop and linux LSB standards. Your system ┃
|
|
# ┃ may have additional .desktop files in other locations. ┃
|
|
# ┃ That seems to be the case for 'optional' items. Linux's ┃
|
|
# ┃ expectation is that if a sysadmin would like entries for ┃
|
|
# ┃ those items system-wide, the sysadmin would copy them ┃
|
|
# ┃ to /usr/share/applications. If you want them for a ┃
|
|
# ┃ specific user, place them in that user's ┃
|
|
# ┃ ${HOME}/.local/share/applications folder. ┃
|
|
# ┃ ┃
|
|
# ┃ To find all system-wide desktop files: ┃
|
|
# ┃ 'find /usr -type f -name "*.desktop"' ┃
|
|
# ┃ ┃
|
|
# ┃ Requirements: dmenu or rofi or zenity or yada ┃
|
|
# ┃ ┃
|
|
# ┃ Setup: ┃
|
|
# ┃ If your distribution has a packaged version of this ┃
|
|
# ┃ script, it is advisable to install that package and ┃
|
|
# ┃ to follow any supplemental instructions of the ┃
|
|
# ┃ packager. ┃
|
|
# ┃ ┃
|
|
# ┃ Manually installing the script involves five steps: ┃
|
|
# ┃ Copy this script file to a convenient location, for ┃
|
|
# ┃ example, somewhere on your ${PATH}; Make it executable ┃
|
|
# ┃ by running 'chmod +x /path/to/file'; Optionally, copy ┃
|
|
# ┃ the script's associated config file to ┃
|
|
# ┃ ${HOME}/.config/morc_menu; Optionally, copy the ┃
|
|
# ┃ script's associated man page to somehwere your system ┃
|
|
# ┃ will recognize (run command 'manpath', and if you can ┃
|
|
# ┃ not place it in any of those places, run 'man manpath' ┃
|
|
# ┃ to see how to set $MANPATH), and; Create a keybinding ┃
|
|
# ┃ for the script. An example keybinding for use with the ┃
|
|
# ┃ i3 window manager would be to modify your ┃
|
|
# ┃ ${HOME}/.i3/config file to include a statement in the ┃
|
|
# ┃ form: ┃
|
|
# ┃ bindsym $mod+z \ ┃
|
|
# ┃ exec "${HOME}/path/to/morc_menu" ┃
|
|
# ┃ ┃
|
|
# ┃ Customization options: ┃
|
|
# ┃ This script offers many ways to customize the menu's ┃
|
|
# ┃ content and 'look' ('skin'). Refer to the script's ┃
|
|
# ┃ man page and configuration file for details. ┃
|
|
# ┃ ┃
|
|
# ┃ Copyright ©2016, Boruch Baum <boruch_baum@gmx.com> ┃
|
|
# ┃ ┃
|
|
# ┃ This program is free software; you can redistribute it ┃
|
|
# ┃ and/or modify it under the terms of the GNU General ┃
|
|
# ┃ Public License aspublished by the Free Software ┃
|
|
# ┃ Foundation; either version 3 of the License, or (at your ┃
|
|
# ┃ option) any later version. ┃
|
|
# ┃ ┃
|
|
# ┃ This program is distributed in the hope that it will be ┃
|
|
# ┃ useful, but WITHOUT ANY WARRANTY; without even the ┃
|
|
# ┃ implied warranty of MERCHANTABILITY or FITNESS FOR A ┃
|
|
# ┃ PARTICULAR PURPOSE. See the GNU General Public License ┃
|
|
# ┃ for more details. ┃
|
|
# ┃ ┃
|
|
# ┃ You should have received a copy of the GNU General ┃
|
|
# ┃ Public License along with this program; if not, write to ┃
|
|
# ┃ the Free Software Foundation, Inc., 59 Temple Place, ┃
|
|
# ┃ Suite 330, Boston, MA 02111-1307, USA ┃
|
|
# ┃ ┃
|
|
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
|
|
# PROGRAMMING NOTE: "Do what I say, not what I do". I really do
|
|
# strongly oppose the general use of global variables, but in
|
|
# this script, something just came over me, and some part of my
|
|
# brain insisted.
|
|
|
|
# TODO: hamdle arbitrary number of sub-menu levels
|
|
# TODO: do I want to set dynamic positioning for the display
|
|
# of error messages (ie. error_cmd) ?
|
|
|
|
readonly SCRIPT_VERSION="Version 1.0, 2016-03-18"
|
|
# Exit codes
|
|
readonly \
|
|
EC_NO_ERROR=0 \
|
|
EC_TRUE=0 \
|
|
EC_FALSE=1 \
|
|
EC_BAD_PARAMETER=1 \
|
|
EC_FILE_UNREADABLE=2 \
|
|
EC_IMPOSSIBLE_TO_REACH_HERE=99
|
|
|
|
# Text constants
|
|
readonly \
|
|
RETURN_TO_MAIN_MENU="Return to main menu" \
|
|
VERSION_MESSAGE="\n\
|
|
morc_menu\n\
|
|
${SCRIPT_VERSION}\n\
|
|
Copyright ©2016 Boruch Baum\n\
|
|
License GPLv3+: GNU GPL version 3 or later\n\
|
|
<http://gnu.org/licenses/gpl.html>\n\
|
|
This is free software: you are free to change and redistribute it.\n\
|
|
There is NO WARRANTY, to the extent permitted by law.\n" \
|
|
USAGE_MESSAGE="\n\
|
|
Categorized menu\n\
|
|
Usage: morc_menu [ [txt|xml] [in_file] ]\n\
|
|
[build [usr|loc|xml [in_file ] ] [out_file] ]\n\
|
|
[show [categories|desired|files] ]\n\
|
|
\n\
|
|
When run with no parameters, displays a dynamically-created,\n\
|
|
navigable, searchable and selectable menu of installed GUI\n\
|
|
applications.\n\
|
|
\n\
|
|
Options:\n\
|
|
txt Display a static menu from a txt file.\n\
|
|
xml Display a static menu from an xml file.\n\
|
|
build Build a static menu definition file, in text format.\n\
|
|
build usr Only use data from /usr/share/applications/\n\
|
|
build loc Only use data from ~/.local/share/applications/\n\
|
|
build xml Only use data from an xml file\n\
|
|
show Print to STDOUT the selected sub-option\n\
|
|
categories A complete list, from /usr/share/applications/\n\
|
|
desired Just those categories to be displayed\n\
|
|
files A list of existing .desktop files\n\
|
|
\n\
|
|
no-sys Ignore the system-wide pre-built morc_menu txt\n\
|
|
definition file. This option must precede the\n\
|
|
above options\n\
|
|
\n\
|
|
conf=[\"path/to/file\"|none]\n\
|
|
Over-ride the default conf file settings. This\n\
|
|
option must be listed first.\n" \
|
|
ABOUT_MESSAGE="\
|
|
morc_menu\n\
|
|
A highly customizable script to display your programs,\n\
|
|
organized by categories. For details see 'man morc_menu'\n"
|
|
|
|
|
|
# Field delimiter for our menu definition file. The choice of
|
|
# delimiter turned out to be trickier than expected. OTOH, we need
|
|
# something that won't be confusing to some form of shell expansion
|
|
# or other misinterpretation. OTOH, we need something that won't
|
|
# ever possibly be confused with the first or last character of an
|
|
# element. OTOH, we need something that will sort prior to all
|
|
# alphabetical characters so that favorites, which have no first
|
|
# (category) field show up first. For example: underscores fail,
|
|
# because they sort after capital letters.
|
|
readonly delim="---"
|
|
|
|
# Favorites category identifier: This is a modification, in order make
|
|
# the format of all records uniform in format, and to alleviate any
|
|
# sorting issues related to having records beginning with a delimiter.
|
|
# We choose a string for the 'Favorites' that will sort at the
|
|
# beginning.
|
|
readonly fav="000"
|
|
|
|
# I/O files and paths
|
|
readonly menu_file="morc_menu"
|
|
[ -d "${MORC_MENU_DIR}" ] \
|
|
&& readonly default_dir="${MORC_MENU_DIR}" \
|
|
|| readonly default_dir="${HOME}/.config/morc_menu/"
|
|
readonly default_path="${default_dir}${menu_file}"
|
|
readonly \
|
|
system_txt_path="/etc/morc_menu.txt" \
|
|
usr_shar_app_path="/usr/share/applications/" \
|
|
loc_shar_app_path="${HOME}/.local/share/applications/" \
|
|
conf_path=( "/usr/share/morc_menu/" "/usr/local/share/morc_menu/" "${HOME}/.local/share/morc_menu/" "${default_dir}" )
|
|
|
|
# Settings for positioning menus
|
|
# those defined only inside the script
|
|
declare -i \
|
|
main_menu_lines=1 \
|
|
sub_menu_lines=1 \
|
|
menu_lines=1
|
|
# those subject to modification by conf files
|
|
declare use_mouse_position=FALSE
|
|
declare -i \
|
|
max_main_line_len=0 \
|
|
max_sub_line_len=0 \
|
|
avg_char_width=9 \
|
|
avg_err_char_width=10 \
|
|
menu_width=350 \
|
|
err_menu_width=350 \
|
|
line_height=20 # Both 'menu_width' and 'line_height' are measured
|
|
# in pixels. Until we learn how to directly measure
|
|
# them, we need to set them manually by trial and
|
|
# error.
|
|
# those subject to modification by either conf files or the script
|
|
declare -i \
|
|
x_position=300 \
|
|
y_position=20
|
|
|
|
# Settings subject to modification at the command line
|
|
declare \
|
|
conf_name="morc_menu_v1.conf" \
|
|
only_conf_file="" \
|
|
ignore_system_txt="FALSE"
|
|
|
|
# Settings of dmenu, subject to modification by conf files
|
|
# refer to conf file for examples
|
|
menu_cmd="dmenu -i -l MENU_LINES "
|
|
error_cmd="dmenu -i -l 40 -nb red -nf black -fn DejaVu"
|
|
|
|
# Settings of morc_menu, subject to modification by conf files
|
|
menu_prefix=" [menu]: "
|
|
menu_suffix=" >"
|
|
align_suffix="TRUE"
|
|
desired_categories="Favorites Settings Development Documentation Education System Network Utility Graphics Office AudioVideo"
|
|
declare -A category_aliases=( [Utility]=Accessories [AudioVideo]=Multimedia [Network]=Internet )
|
|
unwanted_names=( )
|
|
unwanted_execs=( )
|
|
unwanted_specifics=( )
|
|
max_num_backups=5
|
|
exclude_about="FALSE"
|
|
exclude_from_static="FALSE"
|
|
exclude_from_dynamic="TRUE"
|
|
add_to_static="FALSE"
|
|
add_to_dynamic="TRUE"
|
|
# Variable 'apply_exclusions' should not exist in the configuration
|
|
# file. It is set by the script based upon variables
|
|
# exclude_from_static and exclude_from_dynamic, so that a single
|
|
# variable can be used by script functions to toggle this feature. A
|
|
# corresponding variable for add_to_static and add_to_dynamic was not
|
|
# necessary.
|
|
apply_exclusions="FALSE"
|
|
|
|
# Philosophically, these next should NOT be declared globally
|
|
# in_file, out_file: paths reading/writing static menus
|
|
declare in_file=""
|
|
declare out_file=""
|
|
# Lists and their indices:
|
|
# list[*] - the data
|
|
# ii - the recurringly restarted working index into list
|
|
declare -a list
|
|
declare -i ii=0
|
|
# max_cat_len: used to right-pad the categories with spaces so we can
|
|
# have nicely vertically aligned menu suffixes
|
|
declare -i max_cat_len=0
|
|
# response: text returned from the chosen menu front-end (eg. 'dmenu')
|
|
declare response=""
|
|
|
|
function version_message() {
|
|
echo -e "${VERSION_MESSAGE}"
|
|
}
|
|
|
|
function usage_message() {
|
|
echo -e "${USAGE_MESSAGE}"
|
|
}
|
|
|
|
function input_file_error() {
|
|
if [ ${avg_err_char_width} -gt 0 ] ; then
|
|
in_file_width=$(( ${avg_err_char_width} * ${#in_file} ))
|
|
[ ${err_menu_width} -lt ${in_file_width} ] \
|
|
&& err_menu_width=${in_file_width}
|
|
fi
|
|
error_cmd="${error_cmd/MENU_WIDTH/${err_menu_width}}"
|
|
printf "%s\n" \
|
|
"ERROR: morc_menu" \
|
|
"could not read input file" \
|
|
"${in_file}" \
|
|
| ${error_cmd}
|
|
exit $EC_FILE_UNREADABLE
|
|
}
|
|
|
|
function parse_external_conf_file () {
|
|
while read -r || [[ -n "${REPLY}" ]] ; do
|
|
# The purpose of this case statement is to ignore attempts by
|
|
# external configuration files to set variables needed to be
|
|
# protected by the script. Originally all variables were accounted
|
|
# for in this list (thank you emacs!), but for efficiency I
|
|
# removed read-only variables (the resulting errors won't abort
|
|
# the script).
|
|
case ${REPLY} in
|
|
external_skin_definition_file=*) ;&
|
|
x_position=*) ;&
|
|
y_position=*) ;&
|
|
menu_width=*) ;&
|
|
err_menu_width=*) ;&
|
|
line_height=*) ;&
|
|
main_menu_lines=*) ;&
|
|
sub_menu_lines=*) ;&
|
|
menu_lines=*) ;&
|
|
use_mouse_position=*) ;&
|
|
max_main_line_len=*) ;&
|
|
max_sub_line_len=*) ;&
|
|
avg_char_width=*) ;&
|
|
avg_err_char_width=*) ;&
|
|
menu_cmd=*) ;&
|
|
error_cmd=*) ;&
|
|
menu_prefix=*) ;&
|
|
menu_suffix=*) ;&
|
|
align_suffix=*) ;&
|
|
desired_categories=*) ;&
|
|
category_aliases=*) ;&
|
|
unwanted_names=*) ;&
|
|
unwanted_execs=*) ;&
|
|
unwanted_specifics=*) ;&
|
|
max_num_backups=*) ;&
|
|
exclude_about=*) ;&
|
|
ignore_system_txt=*) ;&
|
|
additional_entries=*) ;&
|
|
exclude_from_static=*) ;&
|
|
exclude_from_dynamic=*) ;&
|
|
add_to_static=*) ;&
|
|
add_to_dynamic=*) ;&
|
|
apply_exclusions=*) ;&
|
|
in_file=*) ;&
|
|
out_file=*) ;&
|
|
list=*) ;&
|
|
ii=*) ;&
|
|
max_cat_len=*) ;&
|
|
response=*)
|
|
;;
|
|
*)
|
|
# basic sanity test to only read lines that seem like variable
|
|
# assignments, to ensure that an assignment takes place, and to
|
|
# prevent simple code-injection
|
|
[[ "${REPLY}" =~ ^\ *[[:alpha:]]+[[:alnum:]_-]*=.*$ ]] \
|
|
&& eval "${REPLY%%=*}"="${REPLY#*=}"
|
|
;;
|
|
esac
|
|
done < "$1"
|
|
}
|
|
|
|
function parse_one_conf_file() {
|
|
while read -r || [[ -n "${REPLY}" ]] ; do
|
|
case ${REPLY} in
|
|
external_skin_definition_file=*)
|
|
eval "${REPLY%%=*}"="${REPLY#*=}"
|
|
[ -r "${external_skin_definition_file}" ] \
|
|
&& parse_external_conf_file "${external_skin_definition_file}"
|
|
# || {
|
|
# error_cmd="${error_cmd/MENU_WIDTH/${err_menu_width}}"
|
|
# printf "%s\n" \
|
|
# "ERROR: morc_menu" \
|
|
# "could not read file" \
|
|
# "${external_skin_definition_file}" \
|
|
# "referred to by file" $1 \
|
|
# "press <return> to continue" \
|
|
# | ${error_cmd}
|
|
# }
|
|
;;
|
|
use_mouse_position=*) ;&
|
|
menu_prefix=*) ;&
|
|
menu_suffix=*) ;&
|
|
align_suffix=*) ;&
|
|
menu_cmd=*) ;&
|
|
error_cmd=*) ;&
|
|
desired_categories=*) ;&
|
|
category_aliases=*) ;&
|
|
unwanted_names=*) ;&
|
|
unwanted_execs=*) ;&
|
|
unwanted_specifics=*) ;&
|
|
additional_entries=*) ;&
|
|
exclude_from_static=*) ;&
|
|
exclude_from_dynamic=*) ;&
|
|
add_to_static=*) ;&
|
|
add_to_dynamic=*) ;&
|
|
apply_exclusions=*) ;&
|
|
max_num_backups=*) ;&
|
|
exclude_about=*) ;&
|
|
ignore_system_txt=*)
|
|
eval "${REPLY%%=*}"="${REPLY#*=}"
|
|
;;
|
|
x_position=*) ;&
|
|
y_position=*) ;&
|
|
avg_char_width=*) ;&
|
|
avg_err_char_width=*) ;&
|
|
menu_width=*) ;&
|
|
err_menu_width=*) ;&
|
|
line_height=*)
|
|
if [ "${REPLY#*=}" -lt 0 ] ; then
|
|
eval "${REPLY%%=*}"=0
|
|
else
|
|
eval "${REPLY%%=*}"="${REPLY#*=}"
|
|
fi
|
|
;;
|
|
esac
|
|
done < "$1"
|
|
}
|
|
|
|
function parse_conf_files() {
|
|
if [ -n "${only_conf_file}" ] ; then
|
|
[[ "${only_conf_file}" == "none" ]] && return
|
|
if [ ! -r "${only_conf_file}" ] ; then
|
|
in_file="${only_conf_file}"
|
|
input_file_error
|
|
else
|
|
parse_one_conf_file "${only_conf_file}"
|
|
fi
|
|
else
|
|
for this_conf_path in "${conf_path[@]}" ; do
|
|
[ -r "${this_conf_path}${conf_name}" ] \
|
|
&& parse_one_conf_file "${this_conf_path}${conf_name}"
|
|
done
|
|
fi
|
|
# If the user doesn't have a personal copy of the configuration file
|
|
# in ${HOME}/.config/morc_menu, put it there.
|
|
if [ ! -e "${default_dir}${conf_name}" ] ; then
|
|
[ ! -e "${default_dir}" ] \
|
|
&& mkdir -p "${default_dir}"
|
|
for (( ii=(( ${#conf_path[@]}-1 )); ii-- ; )) ; do
|
|
if [ -r "${conf_path[ $ii ]}${conf_name}" ] ; then
|
|
cp "${conf_path[ $ii ]}${conf_name}" "${default_dir}"
|
|
break
|
|
fi
|
|
done
|
|
ii=0
|
|
fi
|
|
}
|
|
|
|
function pad_right() {
|
|
# This function is a late-entrant, and I'm not sure I really want
|
|
# the extra fluff. The idea is to support a vertically aligned
|
|
# suffix (eg. 'menu_suffix=" >"') to sub-menu labels to indicate
|
|
# that they aren't executables. For this we now have function
|
|
# shar_app keep a running tally of the longest sub-menu category
|
|
# string, so if this function gets removed, remove that snippet as
|
|
# well. This function is designed to align flush to the longest
|
|
# entry, so if you want whitespace, add it in variable
|
|
# 'menu_suffix'.
|
|
# If there's no suffix, there's no reason to pad right
|
|
[ -z "${menu_suffix}" ] || [[ "${align_suffix}" != "TRUE" ]] && return
|
|
list_len=${#list[@]}
|
|
for (( ii=0; $ii-$list_len; ii++ )) ; do
|
|
e_cat="${list[$ii]%%${delim}*}"
|
|
e_rest="${list[$ii]#*${delim}}"
|
|
printf -v "list[$ii]" "%-*s%s%s" ${max_cat_len} \
|
|
"${e_cat}" "${delim}" "${e_rest}"
|
|
done
|
|
}
|
|
|
|
function position_menu_at_mouse_point() {
|
|
# function must receive two parameters
|
|
# $1 = the number of lines in the menu
|
|
# $2 = the pixel width of the longest menu line
|
|
type -f xdotool wattr lsw &> /dev/null || return
|
|
menu_height=$(( $1 * ${line_height} ))
|
|
# the following assigns values to $X and $Y
|
|
eval $(xdotool getmouselocation --shell)
|
|
root_window_id=$(lsw -r)
|
|
monitor_width=$(wattr w ${root_window_id})
|
|
monitor_height=$(wattr h ${root_window_id})
|
|
maxx=$(( ${monitor_width} - $2 ))
|
|
maxy=$(( ${monitor_height} - ${menu_height} ))
|
|
x_position=${X}
|
|
[ ${x_position} -gt ${maxx} ] && x_position=${maxx}
|
|
y_position=${Y}
|
|
[ ${y_position} -gt ${maxy} ] && y_position=${maxy}
|
|
}
|
|
|
|
function backup_a_file() {
|
|
# if there's no file to backup, we're done
|
|
[ ! -f $1 ] \
|
|
&& return ${EC_NO_ERROR}
|
|
# check for a positive integer
|
|
[[ "${max_num_backups}" =~ ^[0-9]+$ ]] \
|
|
|| return ${EC_BAD_PARAMETER} # TODO: log this error!
|
|
# if nothing matches pattern "$1_*" we want null
|
|
shopt -s nullglob
|
|
backup_file_list=( $1_* )
|
|
if [ ${max_num_backups} -eq 0 ] ; then
|
|
# if the user just now reduced the value to zero
|
|
# then remove all existing backups
|
|
[ -n "${backup_file_list[*]}" ] \
|
|
&& rm "${backup_file_list[@]}"
|
|
return ${EC_NO_ERROR}
|
|
elif [ -n "${backup_file_list[*]}" ] ; then
|
|
final=$(( ${#backup_file_list[@]}-1 ))
|
|
if cmp -s "$1" "${backup_file_list[$final]}" ; then
|
|
touch "${backup_file_list[$final]}"
|
|
else
|
|
mv $1 $1"_$(printf '%(%y-%m-%d-%T)T')"
|
|
fi
|
|
rm_these=$(( ${final}+1-${max_num_backups} ))
|
|
while [ ${rm_these} -gt 0 ] ; do
|
|
# This works because bash claims that it produces pathname
|
|
# expansion in an alphabetically sorted list, so we will be
|
|
# removing the oldest backup files, starting from the newest
|
|
# back to the oldest
|
|
rm "${backup_file_list[ $((--rm_these)) ]}"
|
|
done
|
|
else
|
|
mv $1 $1"_$(printf '%(%y-%m-%d-%T)T')"
|
|
fi
|
|
}
|
|
|
|
function build_sort() {
|
|
printf "%s\n" "${list[@]}" \
|
|
| LC_COLLATE=POSIX sort --ignore-case --unique
|
|
}
|
|
|
|
function build_xml() {
|
|
menu_text="${fav}" # They put Favorites at the head, with no category
|
|
item_text=""
|
|
executable=""
|
|
while read -r || [[ -n "${REPLY}" ]] ; do
|
|
if [[ "$REPLY" =~ ^\ *\<item.*$ ]] ; then
|
|
REPLY="${REPLY/#*label=\"/}"
|
|
item_text="${REPLY/\"*/}"
|
|
if [[ "$REPLY" == *CDATA* ]] ; then
|
|
executable="${REPLY/*CDATA\[/}"
|
|
executable="${executable/\]*/}"
|
|
else
|
|
executable=""
|
|
fi
|
|
list[ $((ii++)) ]="${menu_text}${delim}${item_text}${delim}${executable}"
|
|
elif [[ "$REPLY" =~ ^\ *\<menu.*$ ]] ; then
|
|
menu_text="${REPLY/*label=\"/}"
|
|
menu_text="${menu_text/\">/}"
|
|
fi
|
|
done < "${in_file}"
|
|
list_len=$((--ii))
|
|
}
|
|
|
|
function is_this_entry_unwanted() {
|
|
# compare categories, names and executables against the config
|
|
# file's black-list.
|
|
category="$1"
|
|
name="$2"
|
|
exec_="$3"
|
|
for unwanted in "${unwanted_names[@]}" ; do
|
|
[[ "${name}" =~ ^"${unwanted}"$ ]] \
|
|
&& return $EC_TRUE
|
|
done
|
|
for unwanted in "${unwanted_execs[@]}" ; do
|
|
[[ "${exec_}" =~ ^"${unwanted}"$ ]] \
|
|
&& return $EC_TRUE
|
|
done
|
|
for unwanted in "${unwanted_specifics[@]}" ; do
|
|
[[ "${category}${delim}${name}${delim}${exec_}" == "${unwanted}" ]] \
|
|
&& return $EC_TRUE
|
|
done
|
|
return $EC_FALSE
|
|
}
|
|
|
|
function shar_app() {
|
|
# Process the .desktop files in a particular directory ($1)
|
|
in_path="$1"
|
|
# $2, if existing, should only have a value "local", meaning
|
|
# that we are processing a user-local folder,
|
|
# eg. ${HOME}/.local/share/applications
|
|
for in_file in ${in_path}*.desktop ; do
|
|
[ -r "${in_file}" ] || continue
|
|
# We don't want to set the index ${ii} here because we want
|
|
# to accumulate data in $list[] from multiple calls to this
|
|
# function.
|
|
# ii=0
|
|
declare -c name="" # option '-c' auto-capitalizes future content
|
|
exec_=""
|
|
categories=""
|
|
terminal=""
|
|
nodisplay=""
|
|
while read -r || [[ -n "${REPLY}" ]] ; do
|
|
case ${REPLY} in
|
|
# The following -z checks became necessary when it was observed
|
|
# that some .desktop files had multiple and alternative
|
|
# secondary forms
|
|
Name=*)
|
|
[ -z "${name}" ] \
|
|
&& {
|
|
name="${REPLY#Name=}"
|
|
name="${name/% /}"
|
|
}
|
|
;;
|
|
Exec=*)
|
|
[ -z "${exec_}" ] \
|
|
&& {
|
|
exec_="${REPLY#Exec=}"
|
|
exec_="${exec_// %[fFuU]}"
|
|
}
|
|
;;
|
|
Categories=*)
|
|
[ -z "${categories}" ] \
|
|
&& categories="${REPLY#Categories=}"
|
|
;;
|
|
Terminal=*)
|
|
[ -z "${terminal}" ] \
|
|
&& terminal="${REPLY#Terminal=}"
|
|
;;
|
|
NoDisplay=*)
|
|
[ -z "${nodisplay}" ] \
|
|
&& nodisplay="${REPLY#NoDisplay=}"
|
|
[[ "${nodisplay}" == "true" ]] \
|
|
&& continue 2
|
|
# The 'continue 2' aborts processing this '.desktop' file
|
|
;;
|
|
esac
|
|
done < "${in_file}"
|
|
# 1] if a .desktop definition applies itself to more than
|
|
# one category, create an entry for each.
|
|
# 2] if the definition's category has been aliased, apply
|
|
# that now
|
|
# 3] if the definition specifies a cli environment, apply
|
|
# that now (prefix "terminal -e ")
|
|
# 4] if the sysadmin has added "Favorites" to the list of
|
|
# categories, treat this as a "Favorite"
|
|
for category in ${categories//;/ } ; do
|
|
if [[ ${desired_categories} =~ ${category} ]] ; then
|
|
[[ "${apply_exclusions}" = "TRUE" ]] \
|
|
&& is_this_entry_unwanted "${category}" "${name}" "${exec_}" \
|
|
&& continue
|
|
[ -n "${category_aliases[${category}]}" ] \
|
|
&& category=${category_aliases[${category}]}
|
|
[[ "${category}" == "Favorites" ]] \
|
|
|| [ -z "${category}" ] \
|
|
&& category="${fav}"
|
|
[[ ${terminal} == "true" ]] \
|
|
&& exec_="terminal -e ${exec_}"
|
|
# keep track here of the maximum length of the categories so
|
|
# we can later easily right pad them with spaces in order to
|
|
# enable vertically aligned menu suffixes.
|
|
this_cat_len=${#category}
|
|
[ ${this_cat_len} -gt ${max_cat_len} ] \
|
|
&& max_cat_len=${this_cat_len}
|
|
list[ $((ii++)) ]="${category}${delim}${name}${delim}${exec_}"
|
|
fi
|
|
done
|
|
if [[ "$2" == "local" ]] \
|
|
&& [ -z "${categories}" ] ; then
|
|
# Well, the user put it here for a reason,
|
|
# so treat it as a favorite
|
|
[[ ${terminal} == "true" ]] \
|
|
&& exec_="terminal -e ${exec_}"
|
|
list[ $((ii++)) ]="${fav}${delim}${name}${delim}${exec_}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
function build_usr_shar_app() {
|
|
if [[ "${ignore_system_txt}" == "TRUE" ]] ; then
|
|
shar_app "${usr_shar_app_path}"
|
|
return
|
|
fi
|
|
in_file="${system_txt_path}"
|
|
load_txt_file "${apply_exclusions}"
|
|
}
|
|
|
|
function build_loc_shar_app() {
|
|
# Yes, at this point, this function is nothing more than a useless
|
|
# and wasteful redirect, but its here for readability to keep
|
|
# parallel sounding function names for similar purposes.
|
|
shar_app "${loc_shar_app_path}" "local"
|
|
}
|
|
|
|
function add_custom_entries() {
|
|
# Had we wanted to be kind and charitable to users who erred in
|
|
# formatting the array 'additional_entries', we would perform sanity
|
|
# checks here, exclude improperly formatted entries, and possibly
|
|
# warn the user.
|
|
list=( "${list[@]}" "${additional_entries[@]}" )
|
|
}
|
|
|
|
function build_all() {
|
|
[[ "${exclude_from_static}" == "TRUE" ]] \
|
|
&& apply_exclusions="TRUE" \
|
|
|| apply_exclusions="FALSE"
|
|
build_usr_shar_app
|
|
build_loc_shar_app
|
|
[[ "${add_to_static}" == "TRUE" ]] \
|
|
&& add_custom_entries
|
|
}
|
|
|
|
function display_a_menu() {
|
|
# function must receive three parameters:
|
|
# $1 = number of lines in the menu
|
|
# $2 = maximum number of character in a menu line.
|
|
# this is ignored if $avg_char_width == 0
|
|
# $3 = pre-formatted menu text
|
|
[ ${avg_char_width} -gt 0 ] \
|
|
&& menu_width=$(( ${avg_char_width} * $2 ))
|
|
[[ "${use_mouse_position}" == "TRUE" ]] \
|
|
&& position_menu_at_mouse_point $1 ${menu_width}
|
|
display_cmd="${menu_cmd/X_POSITION/${x_position}}"
|
|
display_cmd="${display_cmd/Y_POSITION/${y_position}}"
|
|
display_cmd="${display_cmd/MENU_LINES/$1}"
|
|
display_cmd="${display_cmd/MENU_WIDTH/${menu_width}}"
|
|
# prompt the user and receive the response
|
|
# for the main menu
|
|
response=$( echo -e "$3" | ${display_cmd} )
|
|
}
|
|
|
|
function exec_about() {
|
|
display_a_menu "11" "63" "${ABOUT_MESSAGE}${VERSION_MESSAGE}"
|
|
exit EC_NO_ERROR
|
|
}
|
|
|
|
function produce_menu() {
|
|
# produce the menu, prompt the user and launch the request:
|
|
# + prepare an initial display of favorites and submenu labels
|
|
# + if a submenu label is selected, prepare its own display
|
|
# + allow the user to navigate back to the main menu or to quit
|
|
# + determine the executable to launch, and do so
|
|
#
|
|
# Include here a menu entry 'about' the script instead
|
|
# of in 'shar_app' when building the menu because we want
|
|
# the run-time, not build-time version and other info.
|
|
[[ "${exclude_about}" != "TRUE" ]] \
|
|
&& list[ $(( list_len++ )) ]="${fav}${delim}About${delim}"
|
|
# prepare an initial display of favorites and submenu labels
|
|
dmenu_item=""
|
|
# the first menu element is always...
|
|
main_menu_text="Quit this menu"
|
|
[ $avg_char_width -gt 0 ] \
|
|
&& max_main_line_len=14
|
|
category=""
|
|
for (( ii=0; $ii-$list_len; ii++ )) ; do
|
|
if [ -z "${list[$ii]}" ] ; then
|
|
:
|
|
elif [[ ${list[$ii]} =~ ^${fav}.*$ ]] ; then
|
|
# 'favorite' item, one not under a menu
|
|
dmenu_item="${list[$ii]#*${delim}}"
|
|
dmenu_item="${dmenu_item/${delim}*/}"
|
|
main_menu_text="${main_menu_text}\n${dmenu_item}"
|
|
let $(( main_menu_lines++ ))
|
|
[ ${avg_char_width} -gt 0 ] \
|
|
&& [ ${#dmenu_item} -gt ${max_main_line_len} ] \
|
|
&& max_main_line_len=${#dmenu_item}
|
|
else
|
|
# sub-menu label
|
|
dmenu_item=${list[$ii]/${delim}*/}
|
|
if [[ "${category}" != "${dmenu_item}" ]] ; then
|
|
category=${dmenu_item}
|
|
# add sub-menu prefix and suffix
|
|
dmenu_item="${dmenu_item/#/${menu_prefix}}"
|
|
dmenu_item="${dmenu_item/%/${menu_suffix}}"
|
|
main_menu_text="${main_menu_text}\n${dmenu_item}"
|
|
let $(( main_menu_lines++ ))
|
|
[ ${avg_char_width} -gt 0 ] \
|
|
&& [ ${#dmenu_item} -gt ${max_main_line_len} ] \
|
|
&& max_main_line_len=${#dmenu_item}
|
|
fi
|
|
fi
|
|
done
|
|
# compose a shell command to prompt the user
|
|
response="${RETURN_TO_MAIN_MENU}"
|
|
while [[ "${response}" == "${RETURN_TO_MAIN_MENU}" ]] ; do
|
|
display_a_menu ${main_menu_lines} ${max_main_line_len} "${main_menu_text}"
|
|
if [[ "${response}" =~ ^"${menu_prefix}".*"${menu_suffix}"$ ]] ; then
|
|
# prepare a sub-menu display
|
|
#
|
|
# In order to identify the selection, we must
|
|
# first remove sub-menu prefix and suffix
|
|
response="${response:${#menu_prefix}}"
|
|
response="${response/%${menu_suffix}}"
|
|
dmenu_item=""
|
|
# the first sub-menu element is always...
|
|
sub_menu_text="${RETURN_TO_MAIN_MENU}"
|
|
sub_menu_lines=1
|
|
[ $avg_char_width -gt 0 ] \
|
|
&& max_sub_line_len=19
|
|
for (( ii=0; $ii-$list_len; ii++ )) ; do
|
|
if [[ "${list[$ii]}" =~ ^${response}${delim}.*${delim}.*$ ]] ; then
|
|
dmenu_item="${list[$ii]/${response}${delim}/}"
|
|
dmenu_item="${dmenu_item/${delim}*/}"
|
|
sub_menu_text="${sub_menu_text}\n${dmenu_item}"
|
|
let $(( sub_menu_lines++ ))
|
|
[ $avg_char_width -gt 0 ] \
|
|
&& [ ${#dmenu_item} -gt ${max_sub_line_len} ] \
|
|
&& max_sub_line_len=${#dmenu_item}
|
|
fi
|
|
done
|
|
display_a_menu ${sub_menu_lines} ${max_sub_line_len} "${sub_menu_text}"
|
|
fi
|
|
done
|
|
# determine the executable to launch
|
|
[[ "${response}" == "About" ]] \
|
|
&& exec_about # exits morc_memu there
|
|
# we need to escape all occurrences of any regex character that
|
|
# might be in the menu text. Unfortunately, bash doesn't have
|
|
# group referencing so each character needs its own statement
|
|
# (still much more efficient than calling an external program).
|
|
response=${response//\\/\\\\}
|
|
response=${response//\(/\\\(}
|
|
response=${response//\)/\\\)}
|
|
response=${response//\[/\\\[}
|
|
response=${response//\]/\\\]}
|
|
response=${response//\{/\\\{}
|
|
response=${response//\}/\\\}}
|
|
response=${response//\./\\\.}
|
|
response=${response//\*/\\\*}
|
|
response=${response//\+/\\\+}
|
|
response=${response//\?/\\\?}
|
|
response=${response//\^/\\\^}
|
|
response=${response//\$/\\\$}
|
|
for (( ii=0; $ii-$list_len; ii++ )) ; do
|
|
if [[ ${list[$ii]} =~ ^.*${delim}${response}${delim}.*$ ]] ; then
|
|
executable="${list[$ii]##*${delim}}"
|
|
break
|
|
fi
|
|
done
|
|
# launch!
|
|
echo "categorized menu:"
|
|
echo -e "response = ${response}\nexecutable = ${executable}"
|
|
exec $executable
|
|
}
|
|
|
|
function load_txt_file() {
|
|
# $1 = variable 'exclude_from_static' or 'exclude_from_dynamic', or
|
|
# their aggregator variable 'apply_exclusions'
|
|
if [[ "$1" != "TRUE" ]] ; then
|
|
exec 3< "${in_file}"
|
|
mapfile -u3 list
|
|
list_len=$(( ${#list[@]} ))
|
|
else
|
|
while read -r || [[ -n "${REPLY}" ]] ; do
|
|
a_cat="${REPLY%%${delim}*}"
|
|
a_name="${REPLY#*${delim}}"
|
|
a_name="${a_name/${delim}*/}"
|
|
a_exec="${REPLY##*${delim}}"
|
|
is_this_entry_unwanted "${a_cat}" "${a_name}" "${a_exec}" \
|
|
|| list[ $((ii++)) ]="${REPLY}"
|
|
done < "${in_file}"
|
|
list_len=${ii}
|
|
fi
|
|
}
|
|
|
|
function load_and_produce_menu_from_txt() {
|
|
[ ! -r ${in_file} ] \
|
|
&& input_file_error
|
|
load_txt_file "${exclude_from_static}"
|
|
if [[ "${add_to_static}" != "TRUE" ]] ; then
|
|
produce_menu
|
|
else
|
|
add_custom_entries
|
|
pad_right
|
|
# re-sort and de-duplicate
|
|
build_sort \
|
|
| {
|
|
mapfile list
|
|
list_len=${#list[*]}
|
|
produce_menu
|
|
}
|
|
fi
|
|
}
|
|
|
|
function show_categories() {
|
|
grep Categories /usr/share/applications/*.desktop \
|
|
| cut -d"=" -f2 \
|
|
| sed 's/;/\n/g' \
|
|
| LC_COLLATE=POSIX sort --ignore-case --unique
|
|
}
|
|
|
|
function show_desktop_file_names() {
|
|
in_path="/usr/share/applications/"
|
|
for in_file in ${in_path}*.desktop ; do
|
|
in_file=${in_file##*\/}
|
|
echo ${in_file:0:-8}
|
|
done }
|
|
|
|
function show_desired() {
|
|
printf "\ndesired categories:\n"
|
|
{ for category in ${desired_categories} ; do
|
|
if [ -n "${category_aliases[${category}]}" ] ; then
|
|
alias_msg="|(will display as \"${category_aliases[${category}]}\")"
|
|
else
|
|
alias_msg=""
|
|
fi
|
|
printf " %s%s\n" ${category} "${alias_msg}"
|
|
done
|
|
} | column -s"|" -t
|
|
echo ""
|
|
}
|
|
|
|
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
# ┃ MAIN: The script begins running here ┃
|
|
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
shopt -s nocasematch # do not be case sensitive
|
|
if [[ "$1" =~ ^(-)?(-)?(usage|help)$ ]] ; then
|
|
usage_message
|
|
exit $EC_NO_ERROR
|
|
elif [[ "$1" =~ ^(-)?(-)?(version)$ ]] ; then
|
|
version_message
|
|
exit $EC_NO_ERROR
|
|
fi
|
|
if [[ "$1" =~ ^conf=* ]] ; then
|
|
only_conf_file="${1#*=}"
|
|
shift
|
|
fi
|
|
parse_conf_files
|
|
if [[ "$1" == "no-sys" ]] ; then
|
|
ignore_system_txt="TRUE"
|
|
shift
|
|
elif [ ! -r "${system_txt_path}" ] ; then
|
|
ignore_system_txt="TRUE"
|
|
fi
|
|
if [[ $# == 0 ]] ; then
|
|
[[ "${exclude_from_dynamic}" == "TRUE" ]] \
|
|
&& apply_exclusions="TRUE" \
|
|
|| apply_exclusions="FALSE"
|
|
build_usr_shar_app
|
|
build_loc_shar_app
|
|
[[ "${add_to_dynamic}" == "TRUE" ]] \
|
|
&& add_custom_entries
|
|
pad_right
|
|
build_sort \
|
|
| {
|
|
mapfile list
|
|
list_len=${#list[*]}
|
|
produce_menu
|
|
}
|
|
exit $EC_NO_ERROR
|
|
fi
|
|
case "$1" in
|
|
xml)
|
|
[ -z "$2" ] \
|
|
&& in_file=${default_path}".xml" \
|
|
|| in_file="$2"
|
|
[ ! -r "${in_file}" ] \
|
|
&& input_file_error
|
|
build_xml
|
|
produce_menu
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
txt)
|
|
# this option is only necessary to save the user
|
|
# from typing in the default filepath
|
|
[ -z "$2" ] \
|
|
&& in_file=${default_path}".txt" \
|
|
|| in_file="$2"
|
|
load_and_produce_menu_from_txt
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
build)
|
|
if [ -z "$2" ] ; then
|
|
out_file=${default_path}".txt"
|
|
build_all
|
|
else
|
|
case $2 in
|
|
usr)
|
|
[ -z "$3" ] \
|
|
&& out_file=${default_path}"_usr.txt" \
|
|
|| out_file="$3"
|
|
ignore_system_txt="TRUE"
|
|
[[ "${exclude_from_static}" == "TRUE" ]] \
|
|
&& apply_exclusions="TRUE" \
|
|
|| apply_exclusions="FALSE"
|
|
build_usr_shar_app
|
|
;;
|
|
loc)
|
|
[ -z "$3" ] \
|
|
&& out_file=${default_path}"_usr.txt" \
|
|
|| out_file="$3"
|
|
out_file=${default_path}"_loc.txt"
|
|
build_loc_shar_app
|
|
;;
|
|
xml)
|
|
[ -z "$3" ] \
|
|
&& in_file=${default_path}".xml" \
|
|
|| in_file="$3"
|
|
[ ! -r "${in_file}" ] \
|
|
&& input_file_error
|
|
[ -z "$4" ] \
|
|
&& out_file=${default_path}"_xml.txt" \
|
|
|| out_file="$4"
|
|
build_xml
|
|
;;
|
|
*)
|
|
out_file="$2"
|
|
build_all
|
|
;;
|
|
esac # End of sub-cases of option 'build'
|
|
fi
|
|
pad_right
|
|
[ ! -d "${default_dir}" ] \
|
|
&& mkdir -p "${default_dir}"
|
|
backup_a_file "${out_file}"
|
|
build_sort > "${out_file}"
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
show)
|
|
case $2 in
|
|
categories)
|
|
show_categories
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
desired)
|
|
show_desired
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
files)
|
|
show_desktop_file_names
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
*)
|
|
usage_message
|
|
exit $EC_BAD_PARAMETER
|
|
;;
|
|
esac
|
|
;; # End of sub-cases of option 'show'
|
|
*) # presume file name and request is to produce a static menu
|
|
in_file="$1"
|
|
load_and_produce_menu_from_txt
|
|
exit $EC_NO_ERROR
|
|
;;
|
|
esac
|
|
echo "ERROR: It should be impossible to reach this message"
|
|
exit $EC_IMPOSSIBLE_TO_REACH_HERE
|
|
|
|
# TODO
|
|
# 1) add examples for zenity, rofi, yada in config file
|
|
# 2) more testing of parsing of external config files!
|