5 Commits
v3.0 ... master

6 changed files with 586 additions and 104 deletions

2
I38.md
View File

@ -151,7 +151,7 @@ If you've enabled braille display support during setup, I38 will start XBrlAPI a
### OCR (Optical Character Recognition) ### OCR (Optical Character Recognition)
If installed, you can use OCR to read text from images or inaccessible applications: If required dependencies are installed, you can use OCR to read text from images or inaccessible applications:
- `MODKEY` + `F5`: Perform OCR on the entire screen and speak the content - `MODKEY` + `F5`: Perform OCR on the entire screen and speak the content
- In Ratpoison mode: `Print` or `MODKEY` + `r`: Perform OCR and save to clipboard - In Ratpoison mode: `Print` or `MODKEY` + `r`: Perform OCR and save to clipboard

132
README.md
View File

@ -25,16 +25,19 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht
- lxsession: [optional] For GUI power options like shutdown - lxsession: [optional] For GUI power options like shutdown
- magic-wormhole: [optional] for file sharing with magic-wormhole GUI - magic-wormhole: [optional] for file sharing with magic-wormhole GUI
- notification-daemon: To handle notifications - notification-daemon: To handle notifications
- ocrdesktop: For getting contents of the current window with OCR.
- pamixer: for the mute-unmute script - pamixer: for the mute-unmute script
- pandoc or markdown: To generate html files. - pandoc or markdown: To generate html files.
- pcmanfm: [optional] Graphical file manager. - pcmanfm: [optional] Graphical file manager.
- playerctl: music controls - playerctl: music controls
- python-gobject: for applications menu. - python-gobject: for applications menu.
- python-i3ipc: for sounds etc. - python-i3ipc: for sounds etc.
- python-pillow: For OCR
- python-pytesseract: For OCR
- remind: [optional] For reminder notifications, Requires notify-daemon and notify-send for automatic reminders. - remind: [optional] For reminder notifications, Requires notify-daemon and notify-send for automatic reminders.
scrot: For OCR
- sox: for sounds. - sox: for sounds.
- transfersh: [optional] for file sharing GUI - tesseract: For OCR
- tesseract-data-eng: For OCR
- udiskie: [optional] for automatically mounting removable storage - udiskie: [optional] for automatically mounting removable storage
- x11bell: [optional] Bell support if you do not have a PC speaker. Available from https://github.com/jovanlanik/x11bell - x11bell: [optional] Bell support if you do not have a PC speaker. Available from https://github.com/jovanlanik/x11bell
- xbacklight: [optional] for screen brightness adjustment - xbacklight: [optional] for screen brightness adjustment
@ -43,9 +46,11 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht
- xorg-setxkbmap: [optional] for multiple keyboard layouts - xorg-setxkbmap: [optional] for multiple keyboard layouts
- yad: For screen reader accessible dialogs - yad: For screen reader accessible dialogs
I38 will try to detect your browser, file manager, and text editor and present you with a list of options to bind to their launch keys. It will also create bindings for pidgin and mumble if they are installed. To use the bindings, press your ratpoison mode key which is set when you run the i38.sh script. Next, press the binding for the application you want; w for web browser, e for text editor, f for file manager, m for mumble, etc. To learn all the bindings, find and read the mode ratpoison section of ~/.config/i3/config or use the help binding key, alt or super depending on your settings with Shift+F1. I38 will try to detect your browser, file manager, and text editor and present you with a list of options to bind to their launch keys. It will also create bindings for pidgin and mumble if they are installed. Additionally, you can now add custom applications with your own keybindings during the setup process. To use the bindings, press your ratpoison mode key which is set when you run the i38.sh script. Next, press the binding for the application you want; w for web browser, e for text editor, f for file manager, m for mumble, etc. To learn all the bindings, find and read the mode ratpoison section of ~/.config/i3/config or use the help binding key, alt or super depending on your settings with Shift+F1.
The login sound uses the GTK sound theme. Configure this using GTK configuration files or gsettings. Replace "name" with the name of the theme you want to use. Ratpoison mode is now enabled by default for better accessibility and ease of use.
Sound themes can be configured using GTK configuration files or gsettings. Replace "name" with the name of the theme you want to use.
Note that if you enable all sound events as shown below, you'll also hear GTK sounds when moving around menus, buttons, etc, if the sound theme has sounds for those events. Note that if you enable all sound events as shown below, you'll also hear GTK sounds when moving around menus, buttons, etc, if the sound theme has sounds for those events.
@ -90,6 +95,125 @@ I38 is an adaptation of the old Strychnine project which was based on the Ratpoi
When creating I38, I really wanted to port that functionality over, because it is very powerful and allows for lots and lots of shortcuts while minimizing collisions between shortcuts. So, for example, if you have chosen brave as your web browser, and selected alt+escape as your ratpoison mode key, you can quickly launch brave by pressing alt+escape followed by the letter w. When creating I38, I really wanted to port that functionality over, because it is very powerful and allows for lots and lots of shortcuts while minimizing collisions between shortcuts. So, for example, if you have chosen brave as your web browser, and selected alt+escape as your ratpoison mode key, you can quickly launch brave by pressing alt+escape followed by the letter w.
## Custom Applications in Ratpoison Mode
I38 now includes a powerful system for adding custom applications to ratpoison mode with user-defined keybindings. During the configuration process, after setting up window event sounds, you'll be prompted to add custom applications that will be accessible through ratpoison mode.
### How It Works
The system will prompt you through a series of questions for each application you want to add:
1. **Application Name**: A descriptive name for the application
2. **Execution Command**: The full path or command to run the application
3. **Command Flags**: Optional command-line arguments
4. **Keybinding**: A custom key combination using I38's notation system
### Keybinding Notation System
I38 uses a simple notation system to define keybindings that supports modifiers and special keys:
#### Modifiers
- `^` = Control (Ctrl)
- `!` = Alt
- `#` = Super (Windows/Cmd key)
- `m` = Your chosen mod key (configured during setup)
- Uppercase letters automatically add Shift (e.g., `C` = Shift+c)
#### Special Keys
- **Function Keys**: `f1`, `f2`, ... `f12`
- **Arrow Keys**: `up`, `down`, `left`, `right`
- **Navigation Keys**: `home`, `end`, `pageup`, `pagedown`
- **Editing Keys**: `insert`, `delete`, `backspace`
- **Other Keys**: `space`, `tab`, `return`, `escape`, `print`
#### Example Keybindings
- `c` = Just the 'c' key
- `^c` = Control+c
- `!f1` = Alt+F1
- `mspace` = Your mod key + Space
- `^!up` = Control+Alt+Up arrow
- `#pagedown` = Super+Page Down
- `C` = Shift+c
- `^C` = Control+Shift+c
### Example Configuration Session
Here's what the configuration prompts look like:
```
Enter application name (or press enter when finished): Discord
Enter execution path/command for Discord: discord
Enter command line flags for Discord (optional): --no-sandbox
Enter keybinding for Discord (Examples: c, ^c, !f1, mspace, ^!up) or ? for help: d
```
Another example:
```
Enter application name (or press enter when finished): Terminal Calculator
Enter execution path/command for Terminal Calculator: gnome-calculator
Enter command line flags for Terminal Calculator (optional):
Enter keybinding for Terminal Calculator (Examples: c, ^c, !f1, mspace, ^!up) or ? for help: ^#c
```
### Getting Help During Configuration
If you type `?` when prompted for a keybinding, I38 will display a comprehensive help screen with:
- Complete modifier notation reference
- List of all supported special keys
- Multiple examples of different keybinding combinations
- Rules about uppercase letters and Shift
### Conflict Detection
I38 automatically prevents keybinding conflicts by:
- Maintaining a list of all existing ratpoison mode bindings
- Checking your custom keybinding against reserved keys
- Showing an error message if you try to use a taken keybinding
- Asking you to choose a different key combination
### Reserved Keybindings
The following keys are already used in ratpoison mode and cannot be assigned to custom applications:
- `c` = Terminal
- `e` = Text editor
- `f` = File manager
- `w` = Web browser
- `k` = Kill window
- `m` = Mumble (if installed)
- `p` = Pidgin (if installed)
- `g` = Game controller status
- Various modifier combinations for volume, music controls, etc.
### Using Your Custom Applications
Once configured, access your custom applications by:
1. Press your ratpoison mode key (e.g., Alt+Escape)
2. Press your custom keybinding (e.g., `d` for Discord)
3. The application launches and ratpoison mode exits
### Advanced Examples
**Media Applications:**
- `vlc` bound to `v` for VLC media player
- `spotify` bound to `^s` for Spotify with Control+s
**Development Tools:**
- `code` bound to `mf2` for VS Code with mod+F2
- `gimp` bound to `!g` for GIMP with Alt+g
**System Tools:**
- `htop` bound to `^#h` for system monitor with Control+Super+h
- `pavucontrol` bound to `!space` for audio control with Alt+Space
### Tips for Choosing Keybindings
1. **Keep it memorable**: Use the first letter of the application name when possible
2. **Group related apps**: Use similar modifiers for related applications
3. **Consider frequency**: Use simpler keys for frequently used applications
4. **Avoid conflicts**: The system will warn you, but plan ahead for a logical layout
## Panel Mode ## Panel Mode
Very similar to ratpoison, accessed with Alt+Control+Tab. It contains items that would normally be found in a traditional desktop's panel. For example, it has a simple note app, system information, weather, bluetooth and power options, and remind. Very similar to ratpoison, accessed with Alt+Control+Tab. It contains items that would normally be found in a traditional desktop's panel. For example, it has a simple note app, system information, weather, bluetooth and power options, and remind.

204
i38.sh
View File

@ -178,6 +178,162 @@ yesno() {
echo $? echo $?
} }
# Custom application keybinding functions
declare -A usedKeys
declare -a customApps
showKeybindingHelp() {
dialog --title "I38 Keybinding Help" --msgbox \
"Keybinding Notation:
Modifiers: ^ = Ctrl, ! = Alt, # = Super, m = your mod key
Special keys: f1-f12, up/down/left/right, space, tab, return
home/end/insert/delete/pageup/pagedown, print
backspace, escape
Examples:
c = just 'c' key
^c = Ctrl+c
!f1 = Alt+F1
mspace = mod+Space
^!up = Ctrl+Alt+Up
#pagedown = Super+Page_Down
Uppercase letters imply Shift (e.g., C = Shift+c)" 0 0
}
convertKeybinding() {
local input="$1"
local result=""
local baseKey=""
# Handle modifiers
if [[ "$input" == *"^"* ]]; then
result+="Control+"
input="${input//^/}"
fi
if [[ "$input" == *"!"* ]]; then
result+="Mod1+"
input="${input//!/}"
fi
if [[ "$input" == *"#"* ]]; then
result+="Mod4+"
input="${input//#/}"
fi
if [[ "$input" == *"m"* ]]; then
result+="\$mod+"
input="${input//m/}"
fi
# Handle shift for uppercase letters
if [[ "$input" =~ [A-Z] ]]; then
result+="Shift+"
input="${input,,}"
fi
# Convert special keys
case "$input" in
f[1-9]|f1[0-2]) baseKey="F${input#f}" ;;
up|down|left|right) baseKey="$input" ;;
space) baseKey="space" ;;
tab) baseKey="Tab" ;;
return) baseKey="Return" ;;
escape) baseKey="Escape" ;;
backspace) baseKey="BackSpace" ;;
print) baseKey="Print" ;;
home) baseKey="Home" ;;
end) baseKey="End" ;;
insert) baseKey="Insert" ;;
delete) baseKey="Delete" ;;
pageup) baseKey="Page_Up" ;;
pagedown) baseKey="Page_Down" ;;
*) baseKey="$input" ;;
esac
echo "${result}${baseKey}"
}
populateUsedKeys() {
# Populate with existing ratpoison mode bindings
usedKeys["Shift+slash"]=1
usedKeys["c"]=1
usedKeys["e"]=1
usedKeys["f"]=1
usedKeys["\$mod+e"]=1
usedKeys["w"]=1
usedKeys["k"]=1
usedKeys["m"]=1
usedKeys["Print"]=1
usedKeys["\$mod+r"]=1
usedKeys["p"]=1
usedKeys["\$mod+s"]=1
usedKeys["Mod1+Shift+0"]=1
usedKeys["Mod1+Shift+9"]=1
usedKeys["Mod1+Shift+equal"]=1
usedKeys["Mod1+Shift+minus"]=1
usedKeys["Mod1+Shift+z"]=1
usedKeys["Mod1+Shift+c"]=1
usedKeys["Mod1+Shift+x"]=1
usedKeys["Mod1+Shift+v"]=1
usedKeys["Mod1+Shift+b"]=1
usedKeys["Mod1+Shift+u"]=1
usedKeys["Mod1+b"]=1
usedKeys["g"]=1
usedKeys["apostrophe"]=1
usedKeys["Shift+c"]=1
usedKeys["Shift+o"]=1
usedKeys["Shift+t"]=1
usedKeys["Control+semicolon"]=1
usedKeys["Control+Shift+semicolon"]=1
usedKeys["Shift+exclam"]=1
usedKeys["Control+q"]=1
usedKeys["Escape"]=1
usedKeys["Control+g"]=1
}
inputText() {
# Args: prompt text
dialog --title "I38" --inputbox "$1" 0 0 --stdout
}
addCustomApplication() {
local appName appCommand appFlags keybinding convertedKey
populateUsedKeys
while true; do
appName="$(inputText "Custom Applications:\n\nEnter application name (or press enter when finished):")"
[[ -z "$appName" ]] && break
appCommand="$(inputText "Enter execution path/command for $appName:")"
[[ -z "$appCommand" ]] && continue
appFlags="$(inputText "Enter command line flags for $appName (optional):")"
while true; do
keybinding="$(inputText "Enter keybinding for $appName (Examples: c, ^c, !f1, mspace, ^!up) or ? for help:")"
if [[ "$keybinding" == "?" ]]; then
showKeybindingHelp
continue
fi
[[ -z "$keybinding" ]] && break
convertedKey="$(convertKeybinding "$keybinding")"
if [[ -n "${usedKeys[$convertedKey]}" ]]; then
dialog --title "I38" --msgbox "Keybinding '$keybinding' ($convertedKey) is already in use. Please choose another." 0 0
continue
fi
# Add to arrays
customApps+=("$appName|$appCommand|$appFlags|$convertedKey")
usedKeys["$convertedKey"]=1
break
done
done
}
help() { help() {
echo "${0##*/}" echo "${0##*/}"
echo "Released under the terms of the GPL V3 License: https://www.gnu.org/licenses/" echo "Released under the terms of the GPL V3 License: https://www.gnu.org/licenses/"
@ -281,14 +437,13 @@ done
# Mod2 and Mod3 not usually defined. # Mod2 and Mod3 not usually defined.
# Configuration questions # Configuration questions
export i3Mode=$(yesno "Would you like to use ratpoison mode? This behaves more like strychnine, with an escape key followed by keybindings. (Recommended)") # Ratpoison mode is enabled by default
export i3Mode=0
# Prevent setting ratpoison mode key to the same as default mode key # Prevent setting ratpoison mode key to the same as default mode key
while [[ "$escapeKey" == "$mod" ]]; do while [[ "$escapeKey" == "$mod" ]]; do
if [[ $i3Mode -eq 0 ]]; then escapeKey="$(menulist "Ratpoison mode key:" Control+t Control+z Control+Escape Alt+Escape Control+Space Super)"
escapeKey="$(menulist "Ratpoison mode key:" Control+t Control+z Control+Escape Alt+Escape Control+Space Super)" escapeKey="${escapeKey//Alt/Mod1}"
escapeKey="${escapeKey//Alt/Mod1}" escapeKey="${escapeKey//Super/Mod4}"
escapeKey="${escapeKey//Super/Mod4}"
fi
mod="$(menulist "I3 mod key, for top level bindings:" Alt Control Super)" mod="$(menulist "I3 mod key, for top level bindings:" Alt Control Super)"
mod="${mod//Alt/Mod1}" mod="${mod//Alt/Mod1}"
mod="${mod//Super/Mod4}" mod="${mod//Super/Mod4}"
@ -412,11 +567,8 @@ brlapi=1
brlapi=$(yesno "Do you want to use a braille display with ${screenReader##*/}?") brlapi=$(yesno "Do you want to use a braille display with ${screenReader##*/}?")
sounds=1 sounds=1
sounds=$(yesno "Do you want window event sounds?") sounds=$(yesno "Do you want window event sounds?")
# Play Login Sound # Custom applications for ratpoison mode
loginSound=1 addCustomApplication
if command -v canberra-gtk-play &> /dev/null ; then
export loginSound=$(yesno "Would you like to play the default desktop-login sound according to your GTK sound theme upon login?")
fi
if [[ -d "${i3Path}" ]]; then if [[ -d "${i3Path}" ]]; then
yesno "This will replace your existing configuration at ${i3Path}. Do you want to continue?" || exit 0 yesno "This will replace your existing configuration at ${i3Path}. Do you want to continue?" || exit 0
@ -516,9 +668,13 @@ bindsym Mod1+Tab focus right
bindsym \$mod+BackSpace fullscreen toggle bindsym \$mod+BackSpace fullscreen toggle
# move the currently focused window to the scratchpad # Move the currently focused window to the scratchpad
bindsym \$mod+Shift+minus move scratchpad bindsym \$mod+Shift+minus move scratchpad
# Bind the currently focused window to the scratchpad
# This means it will always open in the scratchpad
bindsym \$mod+Shift+equal exec --no-startup-id ${i3Path}/scripts/bind_to_scratchpad.sh
# Show the next scratchpad window or hide the focused scratchpad window. # Show the next scratchpad window or hide the focused scratchpad window.
# If there are multiple scratchpad windows, this command cycles through them. # If there are multiple scratchpad windows, this command cycles through them.
bindsym \$mod+minus scratchpad show bindsym \$mod+minus scratchpad show
@ -572,10 +728,8 @@ bindsym $mod+Shift+BackSpace mode "default"
EOF EOF
# ocrdesktop through speech-dispatcher # ocr through speech-dispatcher
if command -v ocrdesktop &> /dev/null ; then echo "bindsym ${mod}+F5 exec ${i3Path}/scripts/ocr.py" >> ${i3Path}/config
echo "bindsym ${mod}+F5 exec bash -c 'spd-say -Cw \"performing O C R\" && ocrdesktop -cnog | spd-say -e --'" >> ${i3Path}/config
fi
# Interrupt speech-dispatcher output # Interrupt speech-dispatcher output
echo "bindsym ${mod}+Shift+F5 exec spd-say -C" >> ${i3Path}/config echo "bindsym ${mod}+Shift+F5 exec spd-say -C" >> ${i3Path}/config
@ -660,10 +814,6 @@ $(if command -v xrandr &> /dev/null ; then
echo "# alt+s bound to brightness control" echo "# alt+s bound to brightness control"
echo "bindsym \$mod+s exec --no-startup-id ${i3Path}/scripts/screen_controller.sh, mode \"default\"" echo "bindsym \$mod+s exec --no-startup-id ${i3Path}/scripts/screen_controller.sh, mode \"default\""
fi) fi)
$(if command -v transfersh &> /dev/null ; then
echo "# t bound to share file with transfer.sh"
echo 'bindsym t exec bash -c '"'"'fileName="$(yad --title "I38 Upload File" --file)" && url="$(transfersh "${fileName}" | tee >(yad --title "I38 - Uploading ${fileName##*/} ..." --progress --pulsate --auto-close))" && echo "${url#*saved at: }" | tee >(yad --title "I38 - Upload URL" --show-cursor --show-uri --button yad-close --sticky --text-info) >(xclip -selection clipboard)'"', mode \"default\""
fi)
#Keyboard based volume Controls with pulseaudio #Keyboard based volume Controls with pulseaudio
bindsym Mod1+Shift+0 exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ +${volumeJump}% & play -qnG synth 0.03 sin 440 bindsym Mod1+Shift+0 exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ +${volumeJump}% & play -qnG synth 0.03 sin 440
@ -700,6 +850,16 @@ else
echo "# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)" echo "# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)"
echo "bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default"" echo "bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default""
fi) fi)
# Custom applications
$(for app in "${customApps[@]}"; do
IFS='|' read -r appName appCommand appFlags appKey <<< "$app"
echo "# $appName bound to $appKey"
if [[ -n "$appFlags" ]]; then
echo "bindsym $appKey exec $appCommand $appFlags, mode \"default\""
else
echo "bindsym $appKey exec $appCommand, mode \"default\""
fi
done)
# Run dialog with exclamation # Run dialog with exclamation
bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default" bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default"
# exit i3 (logs you out of your X session) # exit i3 (logs you out of your X session)
@ -723,9 +883,6 @@ $(if [[ $sounds -eq 0 ]]; then
echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py" echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py"
fi fi
fi fi
if [[ $loginSound -eq 0 ]]; then
echo 'exec --no-startup-id canberra-gtk-play -i desktop-login'
fi
if [[ $brlapi -eq 0 ]]; then if [[ $brlapi -eq 0 ]]; then
echo 'exec --no-startup-id xbrlapi --quiet' echo 'exec --no-startup-id xbrlapi --quiet'
fi fi
@ -770,6 +927,7 @@ exec --no-startup-id bash -c 'if [[ -f "${i3Path}/firstrun" ]]; then ${webBrowse
include "${i3Path}/customizations" include "${i3Path}/customizations"
EOF EOF
touch "${i3Path}/customizations" touch "${i3Path}/customizations"
touch "${i3Path}/scratchpad"
# Check for markdown or pandoc for converting the welcome document # Check for markdown or pandoc for converting the welcome document
if command -v pandoc &> /dev/null ; then if command -v pandoc &> /dev/null ; then
pandoc -f markdown -t html "I38.md" -so "${i3Path}/I38.html" --metadata title="Welcome to I38" pandoc -f markdown -t html "I38.md" -so "${i3Path}/I38.html" --metadata title="Welcome to I38"

32
scripts/bind_to_scratchpad.sh Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Find out if we're using i3
if ! [[ -n "${WAYLAND_DISPLAY}" ]]; then
cmd="i3-msg"
scratchConfig="${XDG_CONFIG_HOME:-$HOME/.config}/i3"
else
cmd="swaymsg"
scratchConfig="${XDG_CONFIG_HOME:-$HOME/.config}/sway"
fi
scratchConfig+="/scratchpad"
touch "${scratchConfig}"
# Get the focused window ID
windowId=$(xdotool getactivewindow)
# Get the class name of the window
class=$(xprop -id "$windowId" WM_CLASS | awk -F '"' '{print $4}')
if [[ -z "$class" ]]; then
notify-send "Unable to move to scratchpad."
exit 1
fi
# Check if it's already in the config
if ! grep -q "class=\"$class\"" "$scratchConfig"; then
echo "for_window [class=\"$class\"] move to scratchpad" >> "$scratchConfig"
notify-send "Added window class $class to scratchpad"
fi
# Move the window to scratchpad now
$cmd "[class=\"$class\"] move to scratchpad"

View File

@ -22,10 +22,15 @@ from gi.repository import Gtk, Gdk, GLib, Atk
def read_desktop_files(paths): def read_desktop_files(paths):
desktopEntries = [] desktopEntries = []
for path in paths: for path in paths:
if not Path(path).exists():
continue
for file in Path(path).rglob('*.desktop'): for file in Path(path).rglob('*.desktop'):
config = configparser.ConfigParser(interpolation=None) try:
config.read(file) config = configparser.ConfigParser(interpolation=None)
desktopEntries.append(config) config.read(file, encoding='utf-8')
desktopEntries.append(config)
except (UnicodeDecodeError, configparser.Error):
continue
return desktopEntries return desktopEntries
userApplicationsPath = Path.home() / '.local/share/applications' userApplicationsPath = Path.home() / '.local/share/applications'
@ -93,34 +98,24 @@ subcategories = defaultdict(set)
for entry in desktopEntries: for entry in desktopEntries:
try: try:
# Check if NoDisplay=true is set # Check if NoDisplay=true is set
try: if entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False):
noDisplay = entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False) continue
if noDisplay:
continue name = entry.get('Desktop Entry', 'Name', fallback=None)
except: execCommand = entry.get('Desktop Entry', 'Exec', fallback=None)
pass
if not name or not execCommand:
continue
name = entry.get('Desktop Entry', 'Name')
execCommand = entry.get('Desktop Entry', 'Exec')
entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';') entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';')
# For applications with categories # For applications with categories
mainCategory = None mainCategory = None
for category in entryCategories: validCategories = [cat for cat in entryCategories if cat.strip()]
if category: # Skip empty strings
mappedCategory = categoryMap.get(category, category)
if mainCategory is None:
mainCategory = mappedCategory
# Check if this might be a subcategory if validCategories:
for other in entryCategories: # Use first valid category as main
if other and other != category: mainCategory = categoryMap.get(validCategories[0], validCategories[0])
mappedOther = categoryMap.get(other, other)
if mappedCategory != mappedOther:
subcategories[mappedOther].add(mappedCategory)
# If we found a category, add the application
if mainCategory:
categoryApps[mainCategory][name] = execCommand categoryApps[mainCategory][name] = execCommand
else: else:
# If no category was found, add to "Other" # If no category was found, add to "Other"
@ -194,58 +189,17 @@ class I38_Tab_Menu(Gtk.Window):
sortedCategories.remove("All Applications") sortedCategories.remove("All Applications")
sortedCategories.insert(0, "All Applications") sortedCategories.insert(0, "All Applications")
# Create tabs # Create tabs - defer TreeView creation for performance
for category in sortedCategories: self.tabCategories = sortedCategories
self.createdTabs = set() # Track which tabs have been created
for i, category in enumerate(sortedCategories):
if not categoryApps[category]: # Skip empty categories if not categoryApps[category]: # Skip empty categories
continue continue
# Create a TreeStore for this category # Create placeholder scrolled window
store = Gtk.TreeStore(str, str) # Columns: Name, Exec
self.stores[category] = store
# Add applications to this category's store
sortedApps = sorted(categoryApps[category].items())
# Check for potential subcategories within this category
categorySubcategories = {}
for appName, appExec in sortedApps:
subcatFound = False
for subcat in subcategories.get(category, []):
if appName in categoryApps.get(subcat, {}):
if subcat not in categorySubcategories:
categorySubcategories[subcat] = []
categorySubcategories[subcat].append((appName, appExec))
subcatFound = True
break
if not subcatFound:
# Add directly to the category's root
store.append(None, [appName, appExec])
# Add any subcategories
for subcat, subcatApps in sorted(categorySubcategories.items()):
subcatIter = store.append(None, [subcat, None])
for appName, appExec in sorted(subcatApps):
store.append(subcatIter, [appName, appExec])
# Create TreeView for this category
treeView = Gtk.TreeView(model=store)
treeView.set_headers_visible(False)
self.treeViews[category] = treeView
# Add column for application names
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn("Applications", renderer, text=0)
treeView.append_column(column)
# Set up scrolled window
scrolledWindow = Gtk.ScrolledWindow() scrolledWindow = Gtk.ScrolledWindow()
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolledWindow.add(treeView)
# Connect signals
treeView.connect("row-activated", self.on_row_activated)
treeView.connect("key-press-event", self.on_key_press)
# Create tab label # Create tab label
tabLabel = Gtk.Label(label=category) tabLabel = Gtk.Label(label=category)
@ -254,13 +208,14 @@ class I38_Tab_Menu(Gtk.Window):
self.notebook.append_page(scrolledWindow, tabLabel) self.notebook.append_page(scrolledWindow, tabLabel)
# Set tab accessibility properties for screen readers # Set tab accessibility properties for screen readers
tabChild = self.notebook.get_nth_page(self.notebook.get_n_pages() - 1) accessible = scrolledWindow.get_accessible()
# Get the accessible object and set properties on it instead
accessible = tabChild.get_accessible()
accessible.set_name(f"{category} applications") accessible.set_name(f"{category} applications")
# Use Atk role instead of Gtk.AccessibleRole which isn't available in GTK 3.0
accessible.set_role(Atk.Role.LIST) accessible.set_role(Atk.Role.LIST)
# Create first tab immediately
if sortedCategories:
self.create_tab_content(0)
# Connect notebook signals # Connect notebook signals
self.notebook.connect("switch-page", self.on_switch_page) self.notebook.connect("switch-page", self.on_switch_page)
@ -276,6 +231,48 @@ class I38_Tab_Menu(Gtk.Window):
windowAccessible.set_name("I38 Application Menu") windowAccessible.set_name("I38 Application Menu")
windowAccessible.set_description("Tab-based application launcher menu. Press slash to search, type app name and use down arrow to navigate results. Type letters to incrementally navigate to matching applications.") windowAccessible.set_description("Tab-based application launcher menu. Press slash to search, type app name and use down arrow to navigate results. Type letters to incrementally navigate to matching applications.")
def create_tab_content(self, tabIndex):
"""Create TreeView content for a tab on demand"""
if tabIndex in self.createdTabs or tabIndex >= len(self.tabCategories):
return
category = self.tabCategories[tabIndex]
if not categoryApps[category]:
return
# Create a TreeStore for this category
store = Gtk.TreeStore(str, str) # Columns: Name, Exec
self.stores[category] = store
# Add applications to this category's store (simplified - no subcategories)
sortedApps = sorted(categoryApps[category].items())
for appName, appExec in sortedApps:
store.append(None, [appName, appExec])
# Create TreeView for this category
treeView = Gtk.TreeView(model=store)
treeView.set_headers_visible(False)
self.treeViews[category] = treeView
# Add column for application names
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn("Applications", renderer, text=0)
treeView.append_column(column)
# Get the scrolled window for this tab
scrolledWindow = self.notebook.get_nth_page(tabIndex)
scrolledWindow.add(treeView)
# Connect signals
treeView.connect("row-activated", self.on_row_activated)
treeView.connect("key-press-event", self.on_key_press)
# Show the new content
treeView.show()
# Mark this tab as created
self.createdTabs.add(tabIndex)
def populate_completion_store(self): def populate_completion_store(self):
"""Populate completion store with all available applications""" """Populate completion store with all available applications"""
self.completionStore.clear() self.completionStore.clear()
@ -286,6 +283,9 @@ class I38_Tab_Menu(Gtk.Window):
self.completionStore.append([appName, execCommand]) self.completionStore.append([appName, execCommand])
def on_switch_page(self, notebook, page, pageNum): def on_switch_page(self, notebook, page, pageNum):
# Create tab content if not already created
self.create_tab_content(pageNum)
# Focus the TreeView in the newly selected tab # Focus the TreeView in the newly selected tab
tab = notebook.get_nth_page(pageNum) tab = notebook.get_nth_page(pageNum)
for child in tab.get_children(): for child in tab.get_children():

168
scripts/ocr.py Executable file
View File

@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This file is part of I38.
# I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation,
# either version 3 of the License, or (at your option) any later version.
# I38 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 I38. If not, see <https://www.gnu.org/licenses/>.
"""
Simple OCR Screen Reader
A lightweight tool that performs OCR on the screen and speaks the results
"""
import os
import sys
import time
import subprocess
from PIL import Image, ImageOps
import pytesseract
import pyperclip
def capture_screen(max_retries=3, initial_delay=0.2):
"""
Capture the screen using scrot with robust checking and retries
Args:
max_retries: Maximum number of attempts to read the image
initial_delay: Initial delay in seconds (will increase with retries)
"""
temp_file = "/tmp/ocr_capture.png"
try:
# Capture the screen
subprocess.run(["scrot", temp_file], check=True)
# Wait and retry approach with validity checking
delay = initial_delay
for attempt in range(max_retries):
time.sleep(delay)
# Check if file exists and has content
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0:
try:
# Try to verify the image is valid
with Image.open(temp_file) as test_img:
# Just accessing a property forces PIL to validate the image
test_img.size
# If we get here, the image is valid
return Image.open(temp_file)
except (IOError, OSError) as e:
# Image exists but isn't valid yet
if attempt < max_retries - 1:
# Increase delay exponentially for next attempt
delay *= 2
continue
else:
raise Exception(f"Image file exists but is not valid after {max_retries} attempts")
# File doesn't exist or is empty
if attempt < max_retries - 1:
# Increase delay exponentially for next attempt
delay *= 2
else:
raise Exception(f"Screenshot file not created properly after {max_retries} attempts")
except Exception as e:
print(f"Error capturing screen: {e}")
raise
finally:
# Ensure file is removed even if an error occurs
if os.path.exists(temp_file):
os.remove(temp_file)
def process_image(img, scale_factor=1.5):
"""Process the image to improve OCR accuracy"""
# Scale the image to improve OCR
if scale_factor != 1:
width, height = img.size
img = img.resize((int(width * scale_factor), int(height * scale_factor)),
Image.Resampling.BICUBIC)
# Convert to grayscale for faster processing
img = ImageOps.grayscale(img)
# Improve contrast for better text recognition
img = ImageOps.autocontrast(img)
return img
def perform_ocr(img, lang='eng'):
"""Perform OCR on the image"""
# Use tessaract with optimized settings
# --oem 1: Use LSTM OCR Engine
# --psm 6: Assume a single uniform block of text
text = pytesseract.image_to_string(img, lang=lang, config='--oem 1 --psm 6')
return text
def copy_to_clipboard(text):
"""Copy text to clipboard using pyperclip"""
try:
# Filter out empty lines and clean up the text
lines = [line.strip() for line in text.split('\n') if line.strip()]
cleaned_text = '\n'.join(lines) # Preserve line breaks for clipboard
if cleaned_text:
pyperclip.copy(cleaned_text)
return True
else:
return False
except Exception as e:
print(f"Error copying to clipboard: {e}")
return False
def speak_text(text):
"""Speak the text using speech-dispatcher"""
# Filter out empty lines and clean up the text
lines = [line.strip() for line in text.split('\n') if line.strip()]
cleaned_text = ' '.join(lines)
# Use speech-dispatcher to speak the text
if cleaned_text:
subprocess.run(["spd-say", "-Cw", cleaned_text])
else:
subprocess.run(["spd-say", "-Cw", "No text detected"])
def main():
# Limit tesseract thread usage to improve performance on Pi
os.environ["OMP_THREAD_LIMIT"] = "4"
try:
# Announce start
subprocess.run(["spd-say", "-Cw", "performing OCR"])
# Capture screen
img = capture_screen()
# Process image
processed_img = process_image(img, scale_factor=1.5)
# Perform OCR
text = perform_ocr(processed_img)
# Copy to clipboard
clipboard_success = copy_to_clipboard(text)
# Speak the results
speak_text(text)
except Exception as e:
# Let the user know something went wrong
error_msg = f"Error during OCR: {str(e)}"
print(error_msg)
try:
subprocess.run(["spd-say", "-Cw", "OCR failed"])
except:
# If even speech fails, at least we tried
pass
if __name__ == "__main__":
main()