Compare commits
No commits in common. "master" and "v2.2" have entirely different histories.
@ -1,5 +0,0 @@
|
||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((nil . ((vc-prepare-patches-separately . nil)
|
||||
(vc-default-patch-addressee . "billy@wolfe.casa"))))
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +0,0 @@
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
325
I38.md
325
I38.md
@ -1,325 +0,0 @@
|
||||
# Welcome to I38 - Accessible i3 Window Manager
|
||||
|
||||
> **Note:** This help guide has been tailored to your specific configuration. You've chosen **BROWSER** as your web browser, **MODKEY** as your mod key, and you're using the **SCREENREADER** screen reader.
|
||||
|
||||
## Introduction to I38
|
||||
|
||||
I38 is a configuration for the i3 window manager that makes it more accessible for blind people. It features audio feedback, screen reader integration, and keyboard shortcuts designed for non-visual navigation.
|
||||
|
||||
Unlike traditional desktop environments like GNOME or MATE, i3 is a tiling window manager, which means windows are arranged in a non-overlapping layout. This can be more efficient to navigate by keyboard, as windows are organized in a predictable structure.
|
||||
|
||||
### Coming from GNOME or MATE?
|
||||
|
||||
If you're transitioning from GNOME or MATE, here are some key differences to understand:
|
||||
|
||||
- **Window Management**: In GNOME/MATE, windows can overlap freely and are typically manipulated with a mouse. In i3/I38, windows tile automatically and are primarily controlled with keyboard shortcuts.
|
||||
- **Panels and Indicators**: Instead of persistent panels with menus and indicators, I38 uses keyboard shortcuts to access functionality.
|
||||
- **Workspace Navigation**: While GNOME/MATE have workspaces that you can switch between, I38's workspaces are more central to the workflow and are accessed via dedicated keyboard shortcuts.
|
||||
- **Application Launching**: Rather than using a start menu or activities overview, I38 provides keyboard shortcuts for launching applications.
|
||||
|
||||
I38 has been configured to make this transition easier by providing a tabbed layout (similar to browser tabs) and shortcuts that may feel somewhat familiar.
|
||||
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### Workspaces
|
||||
|
||||
Workspaces act like virtual desktops, allowing you to organize applications. You have 10 workspaces available.
|
||||
|
||||
- Switch to workspace: `Control` + `F1` through `F10`
|
||||
- Move window to workspace: `Control` + `Shift` + `F1` through `F10`
|
||||
|
||||
*GNOME/MATE comparison:* Similar to workspaces in GNOME/MATE, but with dedicated keyboard shortcuts rather than overview modes or workspace switchers.
|
||||
|
||||
### Window Management
|
||||
|
||||
Windows in I38 are arranged in a tabbed layout by default, which means windows take up the entire screen and you can switch between them like browser tabs.
|
||||
|
||||
- Switch between windows: `Alt` + `Tab` (next) or `Alt` + `Shift` + `Tab` (previous)
|
||||
- Launch terminal: `MODKEY` + `Return`
|
||||
- Close window: `MODKEY` + `F4`
|
||||
- Toggle fullscreen: `MODKEY` + `BackSpace`
|
||||
- List windows in current workspace: `RATPOISONKEY` then `'` (apostrophe)
|
||||
|
||||
*GNOME/MATE comparison:* Alt+Tab works similarly to GNOME/MATE, but window placement is automatic rather than manual.
|
||||
|
||||
|
||||
## Modes in I38
|
||||
|
||||
### Default Mode
|
||||
|
||||
This is the standard mode for working with applications. Most commands start with your mod key (`MODKEY`).
|
||||
|
||||
### Ratpoison Mode
|
||||
|
||||
Ratpoison mode allows quick access to common actions using shorter key combinations. To enter Ratpoison mode, press `RATPOISONKEY`. After pressing this key, you can execute commands with single keystrokes.
|
||||
|
||||
Common Ratpoison mode commands:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `c` | Launch a terminal |
|
||||
| `e` | Open text editor (TEXTEDITOR) |
|
||||
| `w` | Launch web browser (BROWSER) |
|
||||
| `k` | Kill (close) the current window |
|
||||
| `?` | Show I38 help |
|
||||
| `Escape` or `Control` + `g` | Exit Ratpoison mode without taking action |
|
||||
| `Shift` + `c` | Restart Cthulhu screen reader |
|
||||
| `Shift` + `o` | Restart Orca screen reader |
|
||||
| `Shift` + `t` | Toggle screen reader |
|
||||
| `Control` + `;` | Reload I38 configuration |
|
||||
| `Control` + `q` | Exit i3 (log out) |
|
||||
| `!` | Open run dialog |
|
||||
| `Alt` + `b` | Check battery status |
|
||||
| `g` | Check game controller status |
|
||||
|
||||
*GNOME/MATE comparison:* This mode has no direct equivalent in GNOME/MATE. Think of it as a command palette or quick launcher activated by a single key.
|
||||
|
||||
### Bypass Mode
|
||||
|
||||
Bypass mode passes all keys directly to the application, which is useful for applications that need many keyboard shortcuts. To enter bypass mode, press `MODKEY` + `Shift` + `BackSpace`. Use the same key combination to exit bypass mode.
|
||||
|
||||
*GNOME/MATE comparison:* In GNOME/MATE, applications always receive keyboard input directly. Bypass mode simulates this behavior within i3.
|
||||
|
||||
## Panel Mode
|
||||
|
||||
Panel Mode provides quick access to information displays and utility panels. To enter Panel Mode, press `Alt` + `Control` + `Tab`. A distinctive sound will play when Panel Mode is active.
|
||||
|
||||
In Panel Mode, single keypresses launch different information panels:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` | Display weather information |
|
||||
| `Shift` + `w` | Open Magic Wormhole file transfer GUI |
|
||||
| `s` | Show system information |
|
||||
| `r` | Open reminder panel |
|
||||
| `n` | Launch notes application |
|
||||
| `b` | Open bluetooth. *requires blueman be installed at the time of your i3 config generation* |
|
||||
| `Escape` or `Control` + `g` | Exit Panel Mode without taking action |
|
||||
|
||||
Just like Ratpoison Mode, Panel Mode automatically returns you to Default Mode after a selection is made or when you press Escape/Control+g to cancel.
|
||||
|
||||
*GNOME/MATE comparison:* Panel Mode replaces the persistent system tray and status indicators used in GNOME/MATE. Instead of having always-visible panels with clickable icons, I38 provides keyboard shortcuts to access this information on demand.
|
||||
|
||||
### System Information Panel
|
||||
|
||||
The system information panel (`s` key in Panel Mode) displays vital system statistics such as:
|
||||
- CPU usage
|
||||
- Memory usage
|
||||
- Disk space
|
||||
- Network status
|
||||
- Battery level (if applicable)
|
||||
|
||||
### Weather Panel
|
||||
|
||||
The weather panel (`w` key in Panel Mode) provides current weather conditions and forecast information for your configured location.
|
||||
|
||||
### File Transfer with Magic Wormhole
|
||||
|
||||
The Magic Wormhole panel (`Shift` + `w` in Panel Mode) provides a graphical user interface to the Magic Wormhole command-line application, allowing you to securely share files with others. This offers a more accessible way to use Magic Wormhole's secure file transfer capabilities.
|
||||
|
||||
### Notes Application
|
||||
|
||||
The notes panel (`n` key in Panel Mode) provides a simple application for creating single-line notes with automatic expiration functionality:
|
||||
- Create text notes quickly
|
||||
- Set notes to automatically delete after a specified time period
|
||||
- Lock important notes to prevent automatic deletion
|
||||
- Temporary notes will expire after their set time limit
|
||||
|
||||
### Reminder Panel
|
||||
|
||||
The reminder panel (`r` key in Panel Mode) offers the same reminder functionality described in the Reminders and Notifications section, It was previously in ratpoison mode but has been moved to panel mode because it is a better fit.
|
||||
|
||||
*Note:* Because Panel Mode uses a custom implementation rather than a traditional system tray, applications that require a system tray to run may not work with I38.
|
||||
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### Screen Reader
|
||||
|
||||
I38 is configured to work with your screen reader (SCREENREADER). The screen reader will provide spoken feedback about what's happening on screen so long as there is a window. If you don't have a window open and need to change something SCREENREADER related, press Control+Alt+d to bring up the desktop, then screen reader keys should work.
|
||||
|
||||
- Toggle screen reader: `RATPOISONKEY` then `Shift` + `t`
|
||||
- Restart screen reader: `RATPOISONKEY` then `Shift` + `o` (for Orca) or `Shift` + `c` (for Cthulhu)
|
||||
- Interrupt speech: `MODKEY` + `Shift` + `F5`
|
||||
|
||||
*GNOME/MATE comparison:* GNOME uses Orca by default with its own keyboard shortcuts. I38 integrates screen readers more deeply with the window manager.
|
||||
|
||||
### Braille Display Support
|
||||
|
||||
If you've enabled braille display support during setup, I38 will start XBrlAPI automatically to provide braille output from your screen reader.
|
||||
|
||||
### OCR (Optical Character Recognition)
|
||||
|
||||
If 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
|
||||
- In Ratpoison mode: `Print` or `MODKEY` + `r`: Perform OCR and save to clipboard
|
||||
|
||||
*GNOME/MATE comparison:* OCR features are typically not integrated into GNOME/MATE by default.
|
||||
|
||||
### Sound Effects
|
||||
|
||||
I38 provides audio feedback for many actions:
|
||||
|
||||
- Window open/close: Ascending/descending tones
|
||||
- Mode changes: Distinctive sounds for each mode
|
||||
- Workspace changes: Subtle audio cues
|
||||
- Fullscreen toggle: Special sound effect
|
||||
|
||||
This audio feedback provides non-visual confirmation of actions and state changes.
|
||||
|
||||
*GNOME/MATE comparison:* GNOME/MATE typically have fewer sound effects for window management actions.
|
||||
|
||||
## Application Menu and Running Programs
|
||||
|
||||
Access applications in multiple ways:
|
||||
|
||||
- Applications menu: `MODKEY` + `F1`
|
||||
- Run dialog (enter a command): `MODKEY` + `F2` or in Ratpoison mode, `!` (exclamation mark)
|
||||
- Common applications have dedicated shortcuts in Ratpoison mode (see table above)
|
||||
|
||||
The applications menu is organized by categories similar to traditional desktop environments.
|
||||
|
||||
*GNOME/MATE comparison:* Instead of clicking on application icons or using a start menu, I38 provides keyboard shortcuts to access applications.
|
||||
|
||||
|
||||
## Reminders and Notifications
|
||||
|
||||
I38 includes integration with the `remind` program for managing reminders:
|
||||
|
||||
- Access the reminder tool: `Alt+Control+Tab` then `r`
|
||||
- Create various types of reminders (one-time, daily, weekly, monthly)
|
||||
- Get notification alerts for your reminders
|
||||
|
||||
The reminder tool provides the following features:
|
||||
|
||||
- **One-time Reminders**: Set for a specific date and time
|
||||
- **Daily Reminders**: Occur every day at the specified time
|
||||
- **Weekly Reminders**: Occur on specific days of the week
|
||||
- **Monthly Reminders**: Occur on a specific day each month or the last day of each month
|
||||
- **Custom Reminders**: Create complex reminder patterns
|
||||
|
||||
*GNOME/MATE comparison:* Similar to calendar applications in GNOME/MATE but with a simplified interface optimized for keyboard navigation.
|
||||
|
||||
|
||||
## Volume and Media Controls
|
||||
|
||||
### System Volume
|
||||
|
||||
- Increase volume: `MODKEY` + `XF86AudioRaiseVolume`
|
||||
- Decrease volume: `MODKEY` + `XF86AudioLowerVolume`
|
||||
- Mute/unmute: `MODKEY` + `XF86AudioMute`
|
||||
|
||||
### Media Player Controls
|
||||
|
||||
- Play/Pause: `XF86AudioPlay`
|
||||
- Next track: `XF86AudioNext`
|
||||
- Previous track: `XF86AudioPrev`
|
||||
- Stop: `XF86AudioStop`
|
||||
- Media info: `MODKEY` + `XF86AudioPlay`
|
||||
|
||||
In Ratpoison mode, these are also available with Alt+Shift combinations:
|
||||
|
||||
- Increase volume: `Alt` + `Shift` + `=`
|
||||
- Decrease volume: `Alt` + `Shift` + `-`
|
||||
- Previous track: `Alt` + `Shift` + `z`
|
||||
- Pause: `Alt` + `Shift` + `c`
|
||||
- Play: `Alt` + `Shift` + `x`
|
||||
- Stop: `Alt` + `Shift` + `v`
|
||||
- Next track: `Alt` + `Shift` + `b`
|
||||
- Media info: `Alt` + `Shift` + `u`
|
||||
|
||||
*GNOME/MATE comparison:* Media controls are similar to those in GNOME/MATE, with the addition of audio feedback.
|
||||
|
||||
|
||||
## File Management
|
||||
|
||||
I38 uses FILEBROWSER for file management. Launch it in Ratpoison mode with the `f` key.
|
||||
|
||||
*GNOME/MATE comparison:* Similar functionality to Nautilus (GNOME) or Caja (MATE), but launched via keyboard shortcut rather than from a desktop icon or menu.
|
||||
|
||||
|
||||
## System Operations
|
||||
|
||||
- Reload I38 configuration: In Ratpoison mode, `Control` + `;` (semicolon)
|
||||
- Exit i3 (log out): In Ratpoison mode, `Control` + `q`
|
||||
- Check battery status: In Ratpoison mode, `Alt` + `b`
|
||||
- Check game controller status: In Ratpoison mode, `g`
|
||||
- Adjust screen brightness (if xrandr is available): In Ratpoison mode, `Alt` + `s`
|
||||
|
||||
*GNOME/MATE comparison:* These functions are typically available through system menus or indicators in GNOME/MATE.
|
||||
|
||||
|
||||
## Keyboard Layouts
|
||||
|
||||
Switch between layouts: `Super` + `Space`
|
||||
|
||||
This is only available if you chose multiple keyboard layouts during setup.
|
||||
|
||||
*GNOME/MATE comparison:* Similar to keyboard layout switching in GNOME/MATE, but with a different default shortcut.
|
||||
|
||||
|
||||
## Desktop and Window Decorations
|
||||
|
||||
Unlike GNOME or MATE, i3 uses minimal window decorations. Windows don't have title bars with minimize/maximize buttons. Instead, windows fill their available space automatically, and interactions are performed through keyboard shortcuts.
|
||||
|
||||
- Show desktop icons: `MODKEY` + `Control` + `d`
|
||||
|
||||
## Clipboard Management
|
||||
|
||||
I38 includes clipboard management features:
|
||||
|
||||
- Access clipboard history: `MODKEY` + `Control` + `c`
|
||||
|
||||
## Bookmark Management
|
||||
|
||||
- Access bookmarks: `MODKEY` + `Control` + `b`
|
||||
|
||||
*GNOME/MATE comparison:* Bookmarks are typically managed within applications in GNOME/MATE. I38 provides a system-wide bookmark manager.
|
||||
|
||||
## Tips for New Users
|
||||
|
||||
- **Use the window list**: When you're lost, use `RATPOISONKEY` then `'` to show all windows in the current workspace.
|
||||
- **Bookmark important websites**: Use `MODKEY` + `Control` + `b` to access bookmarks.
|
||||
- **Remember the help shortcut**: `MODKEY` + `Shift` + `F1` is your friend when you need guidance.
|
||||
- **Let the sound effects guide you**: Pay attention to the audio cues to understand what's happening.
|
||||
- **Take advantage of OCR**: If an application isn't accessible, try the OCR function.
|
||||
|
||||
### Transitioning from GNOME/MATE
|
||||
|
||||
- Start by learning the basic navigation shortcuts before exploring advanced features
|
||||
- The tabbed layout should feel somewhat familiar if you're used to browser tabs
|
||||
- Alt+Tab works similarly to GNOME/MATE for switching between windows
|
||||
- Focus on keyboard commands rather than looking for visual elements like panels or docks
|
||||
|
||||
## Customization
|
||||
|
||||
You can customize I38 by editing the file `~/.config/i3/customizations`. This file will not be overwritten when you update I38.
|
||||
|
||||
Example customizations:
|
||||
|
||||
```
|
||||
# Change background color
|
||||
exec_always --no-startup-id xsetroot -solid "#2E3440"
|
||||
|
||||
# Add custom keybinding
|
||||
bindsym $mod+F12 exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||
```
|
||||
|
||||
To reconfigure I38 completely, run the `i38.sh` script again.
|
||||
|
||||
*GNOME/MATE comparison:* Much more text-based configuration compared to the graphical settings dialogs in GNOME/MATE.
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you need assistance with I38, you can:
|
||||
|
||||
- Press `MODKEY` + `Shift` + `F1` to view the help documentation
|
||||
- Visit the Stormux website at [stormux.org](https://stormux.org)
|
||||
- Join the Stormux irc at irc.stormux.org #stormux or #a11y
|
||||
- Check the i3 documentation at [i3wm.org/docs/userguide.html](https://i3wm.org/docs/userguide.html)
|
||||
|
||||
---
|
||||
|
||||
*I38 - Making i3 accessible. A Stormux project. License: GPL v3*
|
35
README.md
35
README.md
@ -3,7 +3,7 @@
|
||||
Accessibility setup script for the i3 window manager.
|
||||
|
||||
## i38.sh
|
||||
Released under the terms of the GPL License Version 3: https://www.gnu.org/licenses/
|
||||
Released under the terms of the GPL License Version 3: http://www.wtfpl.net
|
||||
This is a Stormux project: https://stormux.org
|
||||
|
||||
|
||||
@ -14,42 +14,36 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht
|
||||
|
||||
## Requirements
|
||||
|
||||
- acpi: [optional] for battery status. It will still work even without this package, but uses it if it is installed. Required for the battery monitor with sound alerts.
|
||||
- bc: For the information panel.
|
||||
- clipster: clipboard manager
|
||||
- dex: [optional] Alternative method for auto starting applications.
|
||||
- i3-wm: The i3 window manager.
|
||||
- acpi: [optional] for battery status. It will still work even without this package, but uses it if it is installed. Required for the battery monitor with sound alerts.
|
||||
- dex: [optional] Alternative method for auto starting applications.
|
||||
- clipster: clipboard manager
|
||||
- jq: for getting the current workspace
|
||||
- libcanberra: [optional] To play the desktop login sound.
|
||||
- libcanberra: [optional]To play the desktop login sound.
|
||||
- libnotify: For sending notifications
|
||||
- lxsession: [optional] For GUI power options like shutdown
|
||||
- magic-wormhole: [optional] for file sharing with magic-wormhole GUI
|
||||
- notification-daemon: To handle notifications
|
||||
- xfce4-notifyd: For sending notifications Replaces notification-daemon requires Orca from git.
|
||||
- ocrdesktop: For getting contents of the current window with OCR.
|
||||
- pamixer: for the mute-unmute script
|
||||
- pandoc or markdown: To generate html files.
|
||||
- pcmanfm: [optional] Graphical file manager.
|
||||
- playerctl: music controls
|
||||
- python-gobject: for applications menu.
|
||||
- python-i3ipc: for sounds etc.
|
||||
- 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.
|
||||
- sox: for sounds.
|
||||
- transfersh: [optional] for file sharing GUI
|
||||
- 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
|
||||
- xbacklight: [optional] for screen brightness adjustment
|
||||
- xclip: Clipboard support
|
||||
- xfce4-notifyd: For sending notifications. Replaces notification-daemon (Sway users will need to install the customized variant at <https://github.com/icasdri/xfce4-notifyd-layer-shell>)
|
||||
- xbacklight: [optional] for screen brightness adjustment
|
||||
- xorg-setxkbmap: [optional] for multiple keyboard layouts
|
||||
- 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 web browser 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
To configure the theme name with gsettings, do as follows:
|
||||
To configure the theme name with gsettings, do as follows.
|
||||
|
||||
gsettings set org.gnome.desktop.sound theme-name name
|
||||
|
||||
@ -67,7 +61,7 @@ To configure with a config file, edit or create ~/.config/gtk-3.0/settings.ini
|
||||
gtk-sound-theme-name=name
|
||||
gtk-modules=canberra-gtk-module
|
||||
|
||||
You can apply the same configuration to GTK2 apps. Create or edit ~/.gtkrc-2.0
|
||||
You can apply the same configuration to GTK2 appss. Create or edit ~/.gtkrc-2.0
|
||||
|
||||
gtk-enable-event-sounds=1
|
||||
gtk-enable-input-feedback-sounds=1
|
||||
@ -85,16 +79,11 @@ You can apply the same configuration to GTK2 apps. Create or edit ~/.gtkrc-2.0
|
||||
|
||||
## Ratpoison Mode
|
||||
|
||||
I38 is an adaptation of the old Strychnine project which was based on the Ratpoison window manager. Ratpoison is a screen-like window manager, and the important concept from that, which applies to I38, is adding keyboard shortcuts without conflicting application shortcuts. This is done with an "escape key".
|
||||
I38 is an adaptation of the old Strychnine project which was based on the Ratpoison window manager. Ratpoison is a screen like window manager, and the important concept from that, which applies to I38, is adding keyboard shortcuts without conflicting application shortcuts. This is done with an "escape key".
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## I38 Help
|
||||
|
||||
To get help for I38, you can press the top level keybinding alt+shift+F1. It is also available by pressing the ratpoison mode key followed by question mark. A limitation of yad, which is used to display the help text means that the cursor starts at the bottom of the text. Please press control+home to jump to the top. You can navigate with the arrow keys, and use control+f to find text within the document.
|
||||
|
196
i38.sh
196
i38.sh
@ -11,11 +11,10 @@
|
||||
# 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, see <https://www.gnu.org/licenses/>.
|
||||
# Flag for sway configurations
|
||||
usingSway=1 # Not by default.
|
||||
|
||||
i3Path="${XDG_CONFIG_HOME:-$HOME/.config}/i3"
|
||||
i3msg="i3-msg"
|
||||
|
||||
sensibleTerminal="i3-sensible-terminal"
|
||||
# Dialog accessibility
|
||||
export DIALOGOPTS='--no-lines --visit-items'
|
||||
|
||||
@ -33,11 +32,6 @@ if [[ -n "${missing}" ]]; then
|
||||
echo "${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v pandoc &> /dev/null && ! command -v markdown &> /dev/null ; then
|
||||
echo "Please install either pandoc or markdown."
|
||||
echo "The markdown command may be provided by the package discount."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keyboard_menu() {
|
||||
keyboardMenu=("us" "English (US)"
|
||||
@ -180,7 +174,7 @@ yesno() {
|
||||
|
||||
help() {
|
||||
echo "${0##*/}"
|
||||
echo "Released under the terms of the GPL V3 License: https://www.gnu.org/licenses/"
|
||||
echo "Released under the terms of the WTFPL License: http://www.wtfpl.net"
|
||||
echo -e "This is a Stormux project: https://stormux.org\n"
|
||||
echo -e "Usage:\n"
|
||||
echo "With no arguments, create the i3 configuration."
|
||||
@ -233,7 +227,6 @@ fi
|
||||
cat << 'EOF' > ~/.xprofile
|
||||
# Accessibility variables
|
||||
export ACCESSIBILITY_ENABLED=1
|
||||
export CHROME_FLAGS="--force-renderer-accessibility"
|
||||
export GTK_MODULES=gail:atk-bridge
|
||||
export GNOME_ACCESSIBILITY=1
|
||||
export QT_ACCESSIBILITY=1
|
||||
@ -265,10 +258,9 @@ while getopts "${args}" i ; do
|
||||
case "$i" in
|
||||
h) help;;
|
||||
s)
|
||||
swaySystemIncludesPath="/etc/sway/config.d"
|
||||
usingSway=0
|
||||
i3msg="swaymsg"
|
||||
i3Path="${XDG_CONFIG_HOME:-$HOME/.config}/sway"
|
||||
sensibleTerminal="sway --sensible-terminal"
|
||||
;;
|
||||
u) update_scripts;;
|
||||
x) write_xinitrc ;&
|
||||
@ -276,10 +268,6 @@ while getopts "${args}" i ; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Mod1 alt
|
||||
# Mod4 super
|
||||
# Mod2 and Mod3 not usually defined.
|
||||
|
||||
# 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)")
|
||||
# Prevent setting ratpoison mode key to the same as default mode key
|
||||
@ -305,23 +293,6 @@ if [[ $(yesno "Do you want to use multiple keyboard layouts?") -eq 0 ]]; then
|
||||
fi
|
||||
# Volume jump
|
||||
volumeJump=$(rangebox "How much should pressing the volume keys change the volume?" 1 15 5)
|
||||
# Screen Reader
|
||||
unset programList
|
||||
for i in cthulhu orca ; do
|
||||
if command -v ${i/#-/} &> /dev/null ; then
|
||||
if [ -n "$programList" ]; then
|
||||
programList="$programList $i"
|
||||
else
|
||||
programList="$i"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$programList" != "${programList// /}" ]; then
|
||||
screenReader="$(menulist ":Screen Reader" $programList)"
|
||||
else
|
||||
screenReader="${programList/#-/}"
|
||||
fi
|
||||
export screenReader="$(command -v $screenReader)"
|
||||
# Email client
|
||||
unset programList
|
||||
for i in betterbird evolution thunderbird ; do
|
||||
@ -338,10 +309,10 @@ if [ "$programList" != "${programList// /}" ]; then
|
||||
else
|
||||
emailClient="${programList/#-/}"
|
||||
fi
|
||||
export emailClient="$(command -v $emailClient)"
|
||||
emailClient="$(command -v $emailClient)"
|
||||
# Web browser
|
||||
unset programList
|
||||
for i in brave chromium epiphany firefox google-chrome-stable google-chrome-unstable microsoft-edge-stable microsoft-edge-beta microsoft-edge-dev midori seamonkey vivaldi ; do
|
||||
for i in brave chromium epiphany firefox google-chrome-stable google-chrome-unstable microsoft-edge-stable microsoft-edge-beta microsoft-edge-dev midori seamonkey ; do
|
||||
if command -v ${i/#-/} &> /dev/null ; then
|
||||
if [ -n "$programList" ]; then
|
||||
programList="$programList $i"
|
||||
@ -355,7 +326,7 @@ if [ "$programList" != "${programList// /}" ]; then
|
||||
else
|
||||
webBrowser="${programList/#-/}"
|
||||
fi
|
||||
export webBrowser="$(command -v $webBrowser)"
|
||||
webBrowser="$(command -v $webBrowser)"
|
||||
# Text editor
|
||||
unset programList
|
||||
for i in emacs geany gedit kate kwrite l3afpad leafpad libreoffice mousepad pluma ; do
|
||||
@ -372,7 +343,7 @@ textEditor="$(menulist "Text editor:" $programList)"
|
||||
else
|
||||
textEditor="${programList/#-/}"
|
||||
fi
|
||||
export textEditor="$(command -v $textEditor)"
|
||||
textEditor="$(command -v $textEditor)"
|
||||
# File browser
|
||||
# Configure file browser
|
||||
unset programList
|
||||
@ -390,7 +361,7 @@ if [ "$programList" != "${programList// /}" ]; then
|
||||
else
|
||||
fileBrowser="${programList/#-/}"
|
||||
fi
|
||||
export fileBrowser="$(command -v $fileBrowser)"
|
||||
fileBrowser="$(command -v $fileBrowser)"
|
||||
# Auto mount removable media
|
||||
udiskie=1
|
||||
if command -v udiskie &> /dev/null ; then
|
||||
@ -402,14 +373,14 @@ if command -v dex &> /dev/null ; then
|
||||
export dex=$(yesno "Would you like to autostart applications with dex?")
|
||||
fi
|
||||
if [[ $dex -eq 0 ]]; then
|
||||
dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c $(command -v $screenReader)
|
||||
dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c $(command -v orca)
|
||||
fi
|
||||
if command -v acpi &> /dev/null ; then
|
||||
batteryAlert=1
|
||||
batteryAlert=$(yesno "Do you want low battery notifications?")
|
||||
fi
|
||||
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 Orca?")
|
||||
sounds=1
|
||||
sounds=$(yesno "Do you want window event sounds?")
|
||||
# Play Login Sound
|
||||
@ -431,18 +402,8 @@ cp -rv scripts/ "${i3Path}/" | dialog --backtitle "I38" --progressbox "Moving sc
|
||||
cat << EOF > ${i3Path}/config
|
||||
# Generated by I38 (${0##*/}) https://git.stormux.org/storm/I38
|
||||
# $(date '+%A, %B %d, %Y at %I:%M%p')
|
||||
EOF
|
||||
|
||||
# If we are using Sway, we need to load in the system configuration
|
||||
# Usually, this is for system specific dBus things that the distro knows how to manage; we should trust their judgment with that
|
||||
if [[ $usingSway ]] && [[ -d "${swaySystemIncludesPath}" ]]; then
|
||||
cat << EOF >> ${i3Path}/config
|
||||
# Include your distribution Sway configuration files.
|
||||
include ${swaySystemIncludesPath}/*
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat << EOF >> ${i3Path}/config
|
||||
# i3 config file (v4)
|
||||
#
|
||||
# Please see https://i3wm.org/docs/userguide.html for a complete reference!
|
||||
@ -482,7 +443,7 @@ bindsym \$mod+Control+Delete exec --no-startup-id sgtk-bar
|
||||
# Use pactl to adjust volume in PulseAudio.
|
||||
bindsym \$mod+XF86AudioRaiseVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ +${volumeJump}% & play -qnG synth 0.03 sin 440
|
||||
bindsym \$mod+XF86AudioLowerVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ -${volumeJump}% & play -qnG synth 0.03 sin 440
|
||||
bindsym \$mod+XF86AudioMute exec --no-startup-id ${i3Path}/scrip/ts/mute-unmute.sh
|
||||
bindsym \$mod+XF86AudioMute exec --no-startup-id ${i3Path}/scripts/mute-unmute.sh
|
||||
|
||||
# Music player controls
|
||||
# Requires playerctl.
|
||||
@ -496,7 +457,7 @@ bindsym XF86AudioStop exec --no-startup-id play -qV0 "| sox -np synth 0.03 sin 2
|
||||
bindsym XF86AudioNext exec --no-startup-id play -qV0 "| sox -np synth 0.03 sin 2000 pad 0 .02" "| sox -np synth 0.03 sin 2000" norm 1.0 vol 0.4 & ${i3Path}/scripts/music_controler.sh next
|
||||
|
||||
# start a terminal
|
||||
bindsym \$mod+Return exec ${i3Path}/scripts/i3-sensible-terminal.sh
|
||||
bindsym \$mod+Return exec $sensibleTerminal
|
||||
|
||||
# kill focused window
|
||||
bindsym \$mod+F4 kill
|
||||
@ -572,56 +533,11 @@ bindsym $mod+Shift+BackSpace mode "default"
|
||||
|
||||
EOF
|
||||
|
||||
# ocrdesktop through speech-dispatcher
|
||||
if command -v ocrdesktop &> /dev/null ; then
|
||||
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
|
||||
echo "bindsym ${mod}+Shift+F5 exec spd-say -C" >> ${i3Path}/config
|
||||
|
||||
# Multiple keyboard layouts if requested.
|
||||
if [[ ${#kbd[@]} -gt 1 ]]; then
|
||||
echo "bindsym Mod4+space exec ${i3Path}/scripts/keyboard.sh cycle ${kbd[@]}" >> ${i3Path}/config
|
||||
fi
|
||||
|
||||
# Create panel mode
|
||||
cat << EOF >> ${i3Path}/config
|
||||
# Panel mode configuration
|
||||
bindsym Control+Mod1+Tab mode "panel"
|
||||
mode "panel" {
|
||||
# Weather information bound to w
|
||||
bindsym w exec --no-startup-id ${i3Path}/scripts/weather.sh, mode "default"
|
||||
|
||||
# Magic wormhole bound to shift+W
|
||||
bindsym Shift+w exec --no-startup-id ${i3Path}/scripts/wormhole.py, mode "default"
|
||||
|
||||
# System information bound to s
|
||||
bindsym s exec --no-startup-id ${i3Path}/scripts/sysinfo.sh, mode "default"
|
||||
|
||||
$(if command -v remind &> /dev/null ; then
|
||||
echo "# Reminders bound to r"
|
||||
echo "bindsym r exec --no-startup-id ${i3Path}/scripts/reminder.sh, mode \"default\""
|
||||
fi)
|
||||
|
||||
# Simple notes system bound to n
|
||||
bindsym n exec --no-startup-id ${i3Path}/scripts/notes.py, mode "default"
|
||||
|
||||
$(if command -v blueman-manager &> /dev/null ; then
|
||||
echo "# Bluetooth bound to b"
|
||||
echo "bindsym b exec --no-startup-id blueman-manager, mode \"default\""
|
||||
fi)
|
||||
|
||||
$(if command -v lxsession-logout &> /dev/null ; then
|
||||
echo "# Power options bound to p"
|
||||
echo "bindsym p exec --no-startup-id lxsession-logout, mode \"default\""
|
||||
fi)
|
||||
|
||||
# Exit panel mode without any action
|
||||
bindsym Escape mode "default"
|
||||
bindsym Control+g mode "default"
|
||||
}
|
||||
EOF
|
||||
|
||||
# Create ratpoison mode if requested.
|
||||
if [[ -n "${escapeKey}" ]]; then
|
||||
cat << EOF >> ${i3Path}/config
|
||||
@ -647,6 +563,10 @@ $(if command -v mumble &> /dev/null ; then
|
||||
echo "# Mumble bound to m"
|
||||
echo "bindsym m exec $(command -v mumble), mode \"default\""
|
||||
fi)
|
||||
$(if command -v remind &> /dev/null ; then
|
||||
echo "# Reminders bound to r"
|
||||
echo "bindsym r exec --no-startup-id ${i3Path}/scripts/reminder.sh, mode \"default\""
|
||||
fi)
|
||||
$(if command -v ocrdesktop &> /dev/null ; then
|
||||
echo "# OCR desktop bound to print screen alternative \$mod+r"
|
||||
echo "bindsym Print exec $(command -v ocrdesktop) -b, mode \"default\""
|
||||
@ -681,25 +601,15 @@ bindsym Mod1+Shift+u exec --no-startup-id play -qV0 "| sox -np synth 0.03 sin 20
|
||||
#Check battery status
|
||||
bindsym Mod1+b exec --no-startup-id ${i3Path}/scripts/battery_status.sh, mode "default"
|
||||
#Check controller battery status
|
||||
bindsym g exec ${i3Path}/scripts/game_controller.sh -s, mode "default"
|
||||
bindsym g exec ${i3Path}/scripts/game_controler.sh -s, mode "default"
|
||||
# Get a list of windows in the current workspace
|
||||
bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default"
|
||||
# Restart Cthulhu
|
||||
bindsym Shift+c exec $(command -v cthulhu) --replace, mode "default"
|
||||
# Restart Orca
|
||||
# Restart orca
|
||||
bindsym Shift+o exec $(command -v orca) --replace, mode "default"
|
||||
# Toggle screen reader
|
||||
bindsym Shift+t exec ${i3Path}/scripts/toggle_screenreader.sh, mode "default"
|
||||
$(if [[ $usingSway -eq 0 ]]; then
|
||||
echo "# reload the configuration file"
|
||||
echo "bindsym Control+semicolon exec bash -c '$i3msg -t command reload && spd-say -P important -Cw "I38 Configuration reloaded."', mode "default""
|
||||
|
||||
else
|
||||
echo "# reload the configuration file"
|
||||
echo "bindsym Control+semicolon exec bash -c '$i3msg -t run_command reload && spd-say -P important -Cw "I38 Configuration reloaded."', mode "default""
|
||||
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""
|
||||
fi)
|
||||
# reload the configuration file
|
||||
bindsym Control+semicolon exec bash -c '$i3msg -t run_command reload && spd-say -P important -Cw "I38 Configuration reloaded."', mode "default"
|
||||
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
|
||||
bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default"
|
||||
# Run dialog with exclamation
|
||||
bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default"
|
||||
# exit i3 (logs you out of your X session)
|
||||
@ -717,11 +627,7 @@ fi
|
||||
cat << EOF >> ${i3Path}/config
|
||||
# Auto start section
|
||||
$(if [[ $sounds -eq 0 ]]; then
|
||||
if [[ $usingSway -eq 0 ]]; then
|
||||
echo "exec --no-startup-id ${i3Path}/scripts/sound.py"
|
||||
else
|
||||
echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py"
|
||||
fi
|
||||
echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py"
|
||||
fi
|
||||
if [[ $loginSound -eq 0 ]]; then
|
||||
echo 'exec --no-startup-id canberra-gtk-play -i desktop-login'
|
||||
@ -754,63 +660,13 @@ if [[ $dex -eq 0 ]]; then
|
||||
echo 'exec --no-startup-id dex --autostart --environment i3'
|
||||
else
|
||||
echo '# Startup applications'
|
||||
if command -v x11bell &> /dev/null ; then
|
||||
echo 'exec --no-startup-id x11bell play -nqV0 synth .1 sq norm -12'
|
||||
fi
|
||||
echo 'exec --no-startup-id clipster -d'
|
||||
echo "exec $screenReader"
|
||||
echo 'exec orca'
|
||||
echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.sh"
|
||||
fi)
|
||||
|
||||
# First run help documentation
|
||||
exec --no-startup-id bash -c 'if [[ -f "${i3Path}/firstrun" ]]; then ${webBrowser} "${i3Path}/I38.html"& rm "${i3Path}/firstrun"; fi'
|
||||
|
||||
# If you want to add personal customizations to i3, add them in ${i3Path}/customizations
|
||||
# It is not overwritten when the config file is recreated.
|
||||
# It is not overwritten with the config file is recreated.
|
||||
include "${i3Path}/customizations"
|
||||
EOF
|
||||
touch "${i3Path}/customizations"
|
||||
# Check for markdown or pandoc for converting the welcome document
|
||||
if command -v pandoc &> /dev/null ; then
|
||||
pandoc -f markdown -t html "I38.md" -so "${i3Path}/I38.html" --metadata title="Welcome to I38"
|
||||
elif command -v markdown &> /dev/null ; then
|
||||
cat << EOF > "${i3Path}/I38.html"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to I38</title>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
EOF
|
||||
|
||||
# Convert markdown to html and append to the file
|
||||
markdown "I38.md" >> "${i3Path}/I38.html"
|
||||
|
||||
# Close the HTML tags using heredoc
|
||||
cat << EOF >> "${i3Path}/I38.html"
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
|
||||
# More readable version of variables.
|
||||
escapeKey="${escapeKey//Mod1/Alt}"
|
||||
escapeKey="${escapeKey//Mod4/Super}"
|
||||
mod="${mod//Mod1/Alt}"
|
||||
mod="${mod//Mod4/Super}"
|
||||
webBrowser="${webBrowser##*/}"
|
||||
screenReader="${screenReader##*/}"
|
||||
textEditor="${textEditor##*/}"
|
||||
fileBrowser="${fileBrowser##*/}"
|
||||
|
||||
# Customize the html file to the user's choices.
|
||||
sed -i -e "s|BROWSER|${webBrowser}|g" \
|
||||
-e "s|MODKEY|${mod}|g" \
|
||||
-e "s|SCREENREADER|${screenReader}|g" \
|
||||
-e "s|RATPOISONKEY|${escapeKey}|g" \
|
||||
-e "s|TEXTEDITOR|${textEditor}|g" \
|
||||
-e "s|FILEBROWSER|${fileBrowser}|g" "${i3Path}/I38.html"
|
||||
|
||||
# Create the firstrun file
|
||||
touch "${i3Path}/firstrun"
|
||||
|
@ -1,2 +0,0 @@
|
||||
assign [class="Solaar"] workspace number 11
|
||||
assign [class="qjoypad"] workspace number 11
|
@ -26,11 +26,6 @@ fi
|
||||
left=9
|
||||
right=0
|
||||
msg="Workspace ${workSpace}"
|
||||
if [[ "${workSpace}" == "11" ]]; then
|
||||
play -qnV0 synth 1.5 pl A4 pl E5 pl C5 delay 0.0 0.1 0.2 remix - fade p 0 1.5 .5
|
||||
spd-say -P important -Cw "I38 panel"
|
||||
exit 0
|
||||
fi
|
||||
if ! [[ "${workSpace}" =~ ^[0-9]+$ ]]; then
|
||||
right=9
|
||||
else
|
||||
|
@ -19,7 +19,7 @@
|
||||
batteryName=""
|
||||
|
||||
if [[ "$batteryName" == "" ]]; then
|
||||
batteryName="$(find /sys/class/power_supply -name 'sony_controller_battery_*' -or -name 'ps-controller-battery-*' | cut -d/ -f5)"
|
||||
batteryName="$(find /sys/class/power_supply -name 'sony_controller_battery_*' | cut -d/ -f5)"
|
||||
fi
|
||||
|
||||
# If there's no file, we don't check it.
|
@ -1,23 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# This code is released in public domain by Han Boetes <han@mijncomputer.nl>
|
||||
#
|
||||
# This script tries to exec a terminal emulator by trying some known terminal
|
||||
# emulators.
|
||||
#
|
||||
# We welcome patches that add distribution-specific mechanisms to find the
|
||||
# preferred terminal emulator. On Debian, there is the x-terminal-emulator
|
||||
# symlink for example.
|
||||
#
|
||||
# Invariants:
|
||||
# 1. $TERMINAL must come first
|
||||
# 2. Distribution-specific mechanisms come next, e.g. x-terminal-emulator
|
||||
# 3. The terminal emulator with best accessibility comes first.
|
||||
# 4. No order is guaranteed/desired for the remaining terminal emulators.
|
||||
for terminal in "$TERMINAL" x-terminal-emulator mate-terminal gnome-terminal terminator xfce4-terminal urxvt rxvt termit Eterm aterm uxterm xterm roxterm termite lxterminal terminology st qterminal lilyterm tilix terminix konsole kitty guake tilda alacritty hyper wezterm; do
|
||||
if command -v "$terminal" > /dev/null 2>&1; then
|
||||
exec "$terminal" "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
i3-nagbar -m 'i3-sensible-terminal could not find a terminal emulator. Please install one.'
|
614
scripts/menu.py
614
scripts/menu.py
@ -16,8 +16,7 @@ from pathlib import Path
|
||||
from collections import defaultdict
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Atk', '1.0')
|
||||
from gi.repository import Gtk, Gdk, GLib, Atk
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
def read_desktop_files(paths):
|
||||
desktopEntries = []
|
||||
@ -30,9 +29,8 @@ def read_desktop_files(paths):
|
||||
|
||||
userApplicationsPath = Path.home() / '.local/share/applications'
|
||||
systemApplicationsPath = Path('/usr/share/applications')
|
||||
userFlatpakApplicationsPath = Path.home() / '.local/share/flatpak/exports/share/applications'
|
||||
systemFlatpakApplicationsPath = Path('/var/lib/flatpak/exports/share/applications')
|
||||
desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath, userFlatpakApplicationsPath, systemFlatpakApplicationsPath])
|
||||
|
||||
desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath])
|
||||
|
||||
# Combine some of the categories
|
||||
categoryMap = {
|
||||
@ -86,574 +84,90 @@ categoryMap = {
|
||||
"X-Xfce-Toplevel": "XFCE",
|
||||
}
|
||||
|
||||
# First, gather all applications by category
|
||||
categoryApps = defaultdict(dict)
|
||||
subcategories = defaultdict(set)
|
||||
|
||||
categories = defaultdict(set)
|
||||
for entry in desktopEntries:
|
||||
try:
|
||||
# Check if NoDisplay=true is set
|
||||
try:
|
||||
noDisplay = entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False)
|
||||
if noDisplay:
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
name = entry.get('Desktop Entry', 'Name')
|
||||
execCommand = entry.get('Desktop Entry', 'Exec')
|
||||
entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';')
|
||||
|
||||
# For applications with categories
|
||||
mainCategory = None
|
||||
entryCategories = entry.get('Desktop Entry', 'Categories').split(';')
|
||||
for category in entryCategories:
|
||||
if category: # Skip empty strings
|
||||
mappedCategory = categoryMap.get(category, category)
|
||||
if mainCategory is None:
|
||||
mainCategory = mappedCategory
|
||||
|
||||
# Check if this might be a subcategory
|
||||
for other in entryCategories:
|
||||
if other and other != category:
|
||||
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
|
||||
else:
|
||||
# If no category was found, add to "Other"
|
||||
categoryApps["Other"][name] = execCommand
|
||||
|
||||
except (configparser.NoOptionError, KeyError):
|
||||
combinedCategory = categoryMap.get(category, category)
|
||||
name = entry.get('Desktop Entry', 'Name')
|
||||
execCommand = entry.get('Desktop Entry', 'Exec')
|
||||
# Use a tuple of name and execCommand as a unique identifier
|
||||
if (name, execCommand) not in categories[combinedCategory]:
|
||||
categories[combinedCategory].add((name, execCommand))
|
||||
except configparser.NoOptionError:
|
||||
continue
|
||||
|
||||
# Ensure we have an "All Applications" category that contains everything
|
||||
allApps = {}
|
||||
for category, apps in categoryApps.items():
|
||||
allApps.update(apps)
|
||||
categoryApps["All Applications"] = allApps
|
||||
|
||||
class I38_Tab_Menu(Gtk.Window):
|
||||
class Xdg_Menu_Window(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="I38 Menu")
|
||||
self.set_default_size(500, 400)
|
||||
self.set_default_size(400, 300)
|
||||
self.set_border_width(10)
|
||||
|
||||
# Main container
|
||||
self.mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
self.add(self.mainBox)
|
||||
self.store = Gtk.TreeStore(str, str) # Columns: Category/Application Name, Exec Command
|
||||
|
||||
# Add search box at the top
|
||||
self.searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
sortedCategories = sorted(categories.items()) # Sort categories alphabetically
|
||||
|
||||
# Create completion for the search entry
|
||||
self.completionStore = Gtk.ListStore(str, str) # Name, Exec
|
||||
self.completion = Gtk.EntryCompletion()
|
||||
self.completion.set_model(self.completionStore)
|
||||
self.completion.set_text_column(0)
|
||||
self.completion.set_minimum_key_length(1)
|
||||
self.completion.set_popup_completion(True)
|
||||
self.completion.set_inline_completion(False)
|
||||
self.completion.connect("match-selected", self.on_completion_match)
|
||||
|
||||
self.searchEntry = Gtk.Entry()
|
||||
self.searchEntry.set_completion(self.completion)
|
||||
self.searchEntry.set_placeholder_text("Search applications...")
|
||||
self.searchEntry.connect("changed", self.on_search_changed)
|
||||
self.searchEntry.connect("key-press-event", self.on_search_key_press)
|
||||
self.searchBox.pack_start(self.searchEntry, True, True, 0)
|
||||
|
||||
# Add search button for visual users
|
||||
self.searchButton = Gtk.Button.new_from_icon_name("search", Gtk.IconSize.BUTTON)
|
||||
self.searchButton.connect("clicked", self.on_search_activated)
|
||||
self.searchBox.pack_start(self.searchButton, False, False, 0)
|
||||
|
||||
self.mainBox.pack_start(self.searchBox, False, False, 0)
|
||||
|
||||
# Create notebook (tabbed interface)
|
||||
self.notebook = Gtk.Notebook()
|
||||
self.notebook.set_tab_pos(Gtk.PositionType.TOP)
|
||||
self.mainBox.pack_start(self.notebook, True, True, 0)
|
||||
|
||||
# Flag for search mode
|
||||
self.inSearchMode = False
|
||||
|
||||
# For incremental letter navigation
|
||||
self.typedText = ""
|
||||
self.typedTextTimer = None
|
||||
|
||||
# Add a tab for each major category
|
||||
self.treeViews = {} # Store TreeViews for each tab
|
||||
self.stores = {} # Store models for each tab
|
||||
|
||||
# Sort categories alphabetically, but ensure "All Applications" is first
|
||||
sortedCategories = sorted(categoryApps.keys())
|
||||
if "All Applications" in sortedCategories:
|
||||
sortedCategories.remove("All Applications")
|
||||
sortedCategories.insert(0, "All Applications")
|
||||
|
||||
# Create tabs
|
||||
for category in sortedCategories:
|
||||
if not categoryApps[category]: # Skip empty categories
|
||||
for category, entries in sortedCategories:
|
||||
if category == "":
|
||||
continue
|
||||
categoryIter = self.store.append(parent=None, row=[category, None])
|
||||
sortedEntries = sorted(entries, key=lambda e: e[0]) # Sort entries by name
|
||||
for name, execCommand in sortedEntries:
|
||||
self.store.append(parent=categoryIter, row=[name, execCommand])
|
||||
|
||||
# Create a TreeStore for this category
|
||||
store = Gtk.TreeStore(str, str) # Columns: Name, Exec
|
||||
self.stores[category] = store
|
||||
self.treeview = Gtk.TreeView(model=self.store)
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn("Applications", renderer, text=0)
|
||||
self.treeview.append_column(column)
|
||||
self.treeview.set_headers_visible(False)
|
||||
|
||||
# Add applications to this category's store
|
||||
sortedApps = sorted(categoryApps[category].items())
|
||||
self.treeview.connect("row-activated", self.on_row_activated)
|
||||
self.treeview.connect("key-press-event", self.on_key_press)
|
||||
|
||||
# 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
|
||||
self.add(self.treeview)
|
||||
self.connect("key-press-event", self.on_key_press)
|
||||
self.treeview.connect("focus-out-event", self.on_focus_out)
|
||||
|
||||
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.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
|
||||
tabLabel = Gtk.Label(label=category)
|
||||
|
||||
# Add the tab
|
||||
self.notebook.append_page(scrolledWindow, tabLabel)
|
||||
|
||||
# Set tab accessibility properties for screen readers
|
||||
tabChild = self.notebook.get_nth_page(self.notebook.get_n_pages() - 1)
|
||||
# Get the accessible object and set properties on it instead
|
||||
accessible = tabChild.get_accessible()
|
||||
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)
|
||||
|
||||
# Connect notebook signals
|
||||
self.notebook.connect("switch-page", self.on_switch_page)
|
||||
|
||||
# Connect window signals
|
||||
self.connect("key-press-event", self.on_window_key_press)
|
||||
self.connect("focus-out-event", self.on_focus_out)
|
||||
|
||||
# Add all applications to the completion store
|
||||
self.populate_completion_store()
|
||||
|
||||
# Set accessibility properties
|
||||
windowAccessible = self.get_accessible()
|
||||
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.")
|
||||
|
||||
def populate_completion_store(self):
|
||||
"""Populate completion store with all available applications"""
|
||||
self.completionStore.clear()
|
||||
|
||||
# Add all applications from all categories
|
||||
for tabName, appDict in categoryApps.items():
|
||||
for appName, execCommand in sorted(appDict.items()):
|
||||
self.completionStore.append([appName, execCommand])
|
||||
|
||||
def on_switch_page(self, notebook, page, pageNum):
|
||||
# Focus the TreeView in the newly selected tab
|
||||
tab = notebook.get_nth_page(pageNum)
|
||||
for child in tab.get_children():
|
||||
if isinstance(child, Gtk.TreeView):
|
||||
child.grab_focus()
|
||||
break
|
||||
|
||||
def on_row_activated(self, treeView, path, column):
|
||||
model = treeView.get_model()
|
||||
treeIter = model.get_iter(path)
|
||||
execCommand = model.get_value(treeIter, 1)
|
||||
self.treeview.grab_focus()
|
||||
self.show_all()
|
||||
|
||||
def on_row_activated(self, treeview, path, column):
|
||||
model = treeview.get_model()
|
||||
iter = model.get_iter(path)
|
||||
execCommand = model.get_value(iter, 1)
|
||||
if execCommand:
|
||||
# Launch the application
|
||||
cmdParts = execCommand.split()
|
||||
# Remove field codes like %f, %F, %u, %U
|
||||
cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)]
|
||||
cleanCmd = ' '.join(cleanCmd)
|
||||
os.system(execCommand)
|
||||
|
||||
# Use GLib.spawn_command_line_async for better process handling
|
||||
try:
|
||||
GLib.spawn_command_line_async(cleanCmd)
|
||||
Gtk.main_quit()
|
||||
except GLib.Error as e:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=f"Failed to launch application: {e.message}"
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def on_completion_match(self, completion, model, iterator):
|
||||
"""Handle when a completion item is selected"""
|
||||
appName = model[iterator][0]
|
||||
execCommand = model[iterator][1]
|
||||
|
||||
if execCommand:
|
||||
# Launch the application
|
||||
cmdParts = execCommand.split()
|
||||
# Remove field codes like %f, %F, %u, %U
|
||||
cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)]
|
||||
cleanCmd = ' '.join(cleanCmd)
|
||||
|
||||
# Use GLib.spawn_command_line_async for better process handling
|
||||
try:
|
||||
GLib.spawn_command_line_async(cleanCmd)
|
||||
Gtk.main_quit()
|
||||
except GLib.Error as e:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=f"Failed to launch application: {e.message}"
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
return True
|
||||
|
||||
def on_search_changed(self, entry):
|
||||
"""Handle search text changes"""
|
||||
searchText = entry.get_text().lower()
|
||||
self.inSearchMode = bool(searchText)
|
||||
|
||||
# If entering search mode, make sure "All Applications" tab is active
|
||||
if self.inSearchMode and not self.notebook.get_current_page() == 0:
|
||||
self.notebook.set_current_page(0)
|
||||
|
||||
currentTab = self.notebook.get_current_page()
|
||||
tabName = list(self.treeViews.keys())[currentTab]
|
||||
treeView = self.treeViews[tabName]
|
||||
store = self.stores[tabName]
|
||||
|
||||
self.filter_applications(store, searchText)
|
||||
|
||||
# Update the completion model with filtered results
|
||||
self.update_completion_results(searchText)
|
||||
|
||||
# Set accessibility announcement
|
||||
if self.inSearchMode:
|
||||
treeView.get_accessible().set_name(f"Search results for {searchText}")
|
||||
else:
|
||||
treeView.get_accessible().set_name(f"{tabName} applications")
|
||||
|
||||
def update_completion_results(self, searchText):
|
||||
"""Update completion dropdown with matching applications"""
|
||||
if not searchText:
|
||||
# If empty, restore all applications
|
||||
self.populate_completion_store()
|
||||
return
|
||||
|
||||
# Clear and repopulate with filtered results
|
||||
self.completionStore.clear()
|
||||
|
||||
for tabName, appDict in categoryApps.items():
|
||||
for appName, execCommand in sorted(appDict.items()):
|
||||
if searchText.lower() in appName.lower():
|
||||
self.completionStore.append([appName, execCommand])
|
||||
|
||||
def filter_applications(self, store, searchText):
|
||||
"""Show/hide applications based on search text"""
|
||||
def filterRow(model, iterator):
|
||||
if not self.inSearchMode:
|
||||
return True
|
||||
|
||||
appName = model.get_value(iterator, 0).lower()
|
||||
|
||||
# Always show categories (rows with no exec command)
|
||||
execCommand = model.get_value(iterator, 1)
|
||||
if execCommand is None:
|
||||
# Check if any children match
|
||||
childIter = model.iter_children(iterator)
|
||||
while childIter:
|
||||
childName = model.get_value(childIter, 0).lower()
|
||||
if searchText in childName:
|
||||
return True
|
||||
childIter = model.iter_next(childIter)
|
||||
return False
|
||||
|
||||
# Show app if it matches search
|
||||
return searchText in appName
|
||||
|
||||
for tabName, treeStore in self.stores.items():
|
||||
# Replace store with filtered version
|
||||
filterStore = treeStore.filter_new()
|
||||
filterStore.set_visible_func(filterRow)
|
||||
|
||||
treeView = self.treeViews[tabName]
|
||||
treeView.set_model(filterStore)
|
||||
|
||||
# Expand all categories in search mode
|
||||
if self.inSearchMode:
|
||||
treeView.expand_all()
|
||||
else:
|
||||
treeView.collapse_all()
|
||||
|
||||
def on_search_key_press(self, entry, event):
|
||||
"""Handle keyboard input in search box"""
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval == Gdk.KEY_Down:
|
||||
# Check if we can navigate to completion results
|
||||
if self.completion.get_popup_completion() and self.inSearchMode:
|
||||
# Try to activate completion popup and navigate to it
|
||||
self.completion.complete()
|
||||
return True
|
||||
|
||||
# If not in completion mode, move focus to the treeview
|
||||
currentTab = self.notebook.get_current_page()
|
||||
tabName = list(self.treeViews.keys())[currentTab]
|
||||
treeView = self.treeViews[tabName]
|
||||
treeView.grab_focus()
|
||||
|
||||
# Set cursor to first item
|
||||
model = treeView.get_model()
|
||||
iterator = model.get_iter_first()
|
||||
if iterator:
|
||||
path = model.get_path(iterator)
|
||||
treeView.set_cursor(path)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_Escape:
|
||||
if self.searchEntry.get_text():
|
||||
# Clear search if there's text
|
||||
self.searchEntry.set_text("")
|
||||
return True
|
||||
else:
|
||||
# Otherwise close the app
|
||||
Gtk.main_quit()
|
||||
return True
|
||||
|
||||
# Other keys pass through
|
||||
return False
|
||||
|
||||
def on_search_activated(self, widget):
|
||||
"""Process search when search button is clicked"""
|
||||
searchText = self.searchEntry.get_text()
|
||||
if searchText:
|
||||
# Focus on the first result if any
|
||||
currentTab = self.notebook.get_current_page()
|
||||
tabName = list(self.treeViews.keys())[currentTab]
|
||||
treeView = self.treeViews[tabName]
|
||||
model = treeView.get_model()
|
||||
|
||||
# Find first visible row
|
||||
iterator = model.get_iter_first()
|
||||
if iterator:
|
||||
path = model.get_path(iterator)
|
||||
treeView.set_cursor(path)
|
||||
treeView.grab_focus()
|
||||
def on_focus_out(self, widget, event):
|
||||
Gtk.main_quit()
|
||||
|
||||
def on_key_press(self, widget, event):
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
# Reset incremental typing and close app if no ongoing search
|
||||
if self.typedText:
|
||||
self.typedText = ""
|
||||
# Announce reset
|
||||
current_tab = self.notebook.get_current_page()
|
||||
tab_name = list(self.treeViews.keys())[current_tab]
|
||||
treeView = self.treeViews[tab_name]
|
||||
treeView.get_accessible().set_name(f"Type reset. {tab_name} applications")
|
||||
return True
|
||||
else:
|
||||
Gtk.main_quit()
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_slash:
|
||||
# Forward slash activates search
|
||||
self.searchEntry.grab_focus()
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_Left:
|
||||
# If in a TreeView and Left key is pressed
|
||||
path = widget.get_cursor()[0]
|
||||
if path:
|
||||
model = widget.get_model()
|
||||
treeIter = model.get_iter(path)
|
||||
|
||||
if widget.row_expanded(path):
|
||||
# Collapse the current row if it's expanded
|
||||
widget.collapse_row(path)
|
||||
return True
|
||||
else:
|
||||
# Move to parent if possible
|
||||
parentIter = model.iter_parent(treeIter)
|
||||
if parentIter:
|
||||
parentPath = model.get_path(parentIter)
|
||||
widget.set_cursor(parentPath)
|
||||
return True
|
||||
|
||||
# If we couldn't handle it in the TreeView, try to switch tabs
|
||||
currentPage = self.notebook.get_current_page()
|
||||
if currentPage > 0 and not self.inSearchMode:
|
||||
self.notebook.set_current_page(currentPage - 1)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_Right:
|
||||
# If in a TreeView and Right key is pressed
|
||||
path = widget.get_cursor()[0]
|
||||
if path:
|
||||
model = widget.get_model()
|
||||
treeIter = model.get_iter(path)
|
||||
|
||||
# Check if this row has children
|
||||
if model.iter_has_child(treeIter):
|
||||
if not widget.row_expanded(path):
|
||||
widget.expand_row(path, False)
|
||||
# Move to the first child
|
||||
childPath = model.get_path(model.iter_children(treeIter))
|
||||
widget.set_cursor(childPath)
|
||||
return True
|
||||
|
||||
# If we couldn't handle it in the TreeView, try to switch tabs
|
||||
currentPage = self.notebook.get_current_page()
|
||||
if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode:
|
||||
self.notebook.set_current_page(currentPage + 1)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_Tab:
|
||||
# Control tab navigation
|
||||
currentPage = self.notebook.get_current_page()
|
||||
if event.state & Gdk.ModifierType.SHIFT_MASK:
|
||||
# Shift+Tab -> previous tab
|
||||
if currentPage > 0 and not self.inSearchMode:
|
||||
self.notebook.set_current_page(currentPage - 1)
|
||||
else:
|
||||
self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
|
||||
else:
|
||||
# Tab -> next tab
|
||||
if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode:
|
||||
self.notebook.set_current_page(currentPage + 1)
|
||||
else:
|
||||
self.notebook.set_current_page(0)
|
||||
return True
|
||||
|
||||
# Incremental letter navigation
|
||||
elif Gdk.KEY_a <= keyval <= Gdk.KEY_z or Gdk.KEY_A <= keyval <= Gdk.KEY_Z:
|
||||
# Cancel any pending timer
|
||||
if self.typedTextTimer:
|
||||
GLib.source_remove(self.typedTextTimer)
|
||||
self.typedTextTimer = None
|
||||
|
||||
# Add the new letter to the typed text
|
||||
letter = chr(keyval).lower()
|
||||
self.typedText += letter
|
||||
|
||||
# Find item matching typed text
|
||||
found = self.find_incremental_match(widget, self.typedText)
|
||||
|
||||
# Set timer to reset typed text after 1.5 seconds of inactivity
|
||||
self.typedTextTimer = GLib.timeout_add(1500, self.reset_typed_text)
|
||||
|
||||
# Announce the letters being typed
|
||||
current_tab = self.notebook.get_current_page()
|
||||
tab_name = list(self.treeViews.keys())[current_tab]
|
||||
treeView = self.treeViews[tab_name]
|
||||
treeView.get_accessible().set_name(f"Typed: {self.typedText}")
|
||||
|
||||
return True if found else False
|
||||
|
||||
return False
|
||||
|
||||
def reset_typed_text(self):
|
||||
"""Reset the typed text after timeout"""
|
||||
self.typedText = ""
|
||||
self.typedTextTimer = None
|
||||
return False # Don't call again
|
||||
|
||||
def find_incremental_match(self, treeView, text):
|
||||
"""Find items matching the incrementally typed text"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
model = treeView.get_model()
|
||||
found = False
|
||||
|
||||
def search_tree(model, path, treeIter, user_data):
|
||||
nonlocal found
|
||||
if found:
|
||||
return True # Stop iteration if already found
|
||||
|
||||
name = model.get_value(treeIter, 0)
|
||||
if name and name.lower().startswith(text.lower()):
|
||||
# Found a match
|
||||
treeView.set_cursor(path)
|
||||
treeView.scroll_to_cell(path, None, True, 0.5, 0.5)
|
||||
found = True
|
||||
return True # Stop iteration
|
||||
return False # Continue iteration
|
||||
|
||||
# Search the entire model
|
||||
model.foreach(search_tree, None)
|
||||
return found
|
||||
|
||||
def on_window_key_press(self, widget, event):
|
||||
# Handle window-level key events
|
||||
keyval = event.keyval
|
||||
|
||||
state = event.state
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
Gtk.main_quit()
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.KEY_slash:
|
||||
# Forward slash activates search
|
||||
self.searchEntry.grab_focus()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_focus_out(self, widget, event):
|
||||
# Quit when the window loses focus
|
||||
Gtk.main_quit()
|
||||
|
||||
window = I38_Tab_Menu()
|
||||
window.connect("destroy", Gtk.main_quit)
|
||||
window.show_all()
|
||||
|
||||
# Focus the first TreeView
|
||||
firstTab = window.notebook.get_nth_page(0)
|
||||
for child in firstTab.get_children():
|
||||
if isinstance(child, Gtk.TreeView):
|
||||
child.grab_focus()
|
||||
break
|
||||
elif keyval == Gdk.KEY_Left:
|
||||
path = self.treeview.get_cursor()[0]
|
||||
if path:
|
||||
iter = self.store.get_iter(path)
|
||||
if self.treeview.row_expanded(path):
|
||||
self.treeview.collapse_row(path)
|
||||
else:
|
||||
parent_iter = self.store.iter_parent(iter)
|
||||
if parent_iter:
|
||||
parent_path = self.store.get_path(parent_iter)
|
||||
self.treeview.collapse_row(parent_path)
|
||||
else:
|
||||
self.treeview.collapse_row(path)
|
||||
elif keyval == Gdk.KEY_Right:
|
||||
path = self.treeview.get_cursor()[0]
|
||||
if path:
|
||||
if not self.treeview.row_expanded(path):
|
||||
self.treeview.expand_row(path, open_all=False)
|
||||
|
||||
win = Xdg_Menu_Window()
|
||||
win.connect("destroy", Gtk.main_quit)
|
||||
win.show_all()
|
||||
Gtk.main()
|
||||
|
||||
|
@ -16,6 +16,6 @@ if [ $(pamixer --get-mute) = false ]; then
|
||||
pamixer -t
|
||||
else
|
||||
pamixer -t
|
||||
play -qnGV0 synth 0.05 sin 440
|
||||
play -qnG synth 0.05 sin 440
|
||||
spd-say -P important -Cw 'Unmuted!'
|
||||
fi
|
||||
|
823
scripts/notes.py
823
scripts/notes.py
@ -1,823 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, GLib, Gio, Gdk
|
||||
import os
|
||||
import datetime
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
class JotApp(Gtk.Application):
|
||||
def __init__(self):
|
||||
super().__init__(application_id="com.example.jot",
|
||||
flags=Gio.ApplicationFlags.FLAGS_NONE)
|
||||
|
||||
# Set up data structures
|
||||
self.dbPath = self.get_db_path()
|
||||
self.conn = None
|
||||
self.init_database()
|
||||
|
||||
# Initialize settings
|
||||
self.expirationDays = self.get_setting("expirationDays", 0)
|
||||
self.confirmDelete = self.get_setting("confirmDelete", 1)
|
||||
|
||||
def do_activate(self):
|
||||
# Create main window when app is activated
|
||||
self.window = Gtk.ApplicationWindow(application=self, title="I38 Notes")
|
||||
self.window.set_default_size(500, 400)
|
||||
|
||||
# Connect the delete-event signal (for window close button)
|
||||
self.window.connect("delete-event", self.on_window_close)
|
||||
|
||||
# Set up keyboard shortcuts
|
||||
self.setup_actions()
|
||||
|
||||
# Build the main interface
|
||||
self.build_ui()
|
||||
|
||||
# Check for expired notes
|
||||
self.check_expirations()
|
||||
|
||||
self.window.show_all()
|
||||
|
||||
def on_window_close(self, window, event):
|
||||
"""Handle window close event"""
|
||||
# Close the database connection before quitting
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.quit()
|
||||
return True
|
||||
|
||||
def get_db_path(self):
|
||||
"""Get path to the SQLite database"""
|
||||
configHome = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config'))
|
||||
configDir = os.path.join(configHome, 'stormux', 'I38')
|
||||
os.makedirs(configDir, exist_ok=True)
|
||||
return os.path.join(configDir, 'notes.sqlite')
|
||||
|
||||
def init_database(self):
|
||||
"""Initialize the SQLite database"""
|
||||
try:
|
||||
self.conn = sqlite3.connect(self.dbPath)
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Create notes table if it doesn't exist
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires TIMESTAMP NULL,
|
||||
locked BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# Create settings table if it doesn't exist
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
# Initialize default settings if they don't exist
|
||||
self.init_default_settings()
|
||||
|
||||
# Check if we need to migrate from old format
|
||||
self.migrate_if_needed()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
|
||||
def init_default_settings(self):
|
||||
"""Initialize default settings if they don't exist"""
|
||||
defaultSettings = {
|
||||
"expirationDays": "0",
|
||||
"confirmDelete": "1" # 1 = enabled, 0 = disabled
|
||||
}
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
for key, value in defaultSettings.items():
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def migrate_if_needed(self):
|
||||
"""Check if we need to migrate from old format"""
|
||||
# Check for old config directory
|
||||
oldConfigHome = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config'))
|
||||
oldConfigDir = os.path.join(oldConfigHome, 'jot')
|
||||
oldNotesFile = os.path.join(oldConfigDir, 'notes')
|
||||
|
||||
if os.path.exists(oldNotesFile):
|
||||
try:
|
||||
# Check if we already have notes in the database
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) FROM notes")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
# Only migrate if database is empty
|
||||
if count == 0:
|
||||
with open(oldNotesFile, 'r') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(': ', 1)
|
||||
if len(parts) == 2:
|
||||
noteNum, noteText = parts
|
||||
cursor.execute(
|
||||
"INSERT INTO notes (text, locked) VALUES (?, ?)",
|
||||
(noteText, 0)
|
||||
)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"Migrated notes from {oldNotesFile}")
|
||||
except Exception as e:
|
||||
print(f"Migration error: {e}")
|
||||
|
||||
def get_setting(self, key, default=None):
|
||||
"""Get a setting from the database"""
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute("SELECT value FROM settings WHERE key = ?", (key,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
return result[0]
|
||||
else:
|
||||
# Set default if not exists
|
||||
self.save_setting(key, default)
|
||||
return default
|
||||
except sqlite3.Error:
|
||||
return default
|
||||
|
||||
def save_setting(self, key, value):
|
||||
"""Save a setting to the database"""
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value)
|
||||
)
|
||||
self.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
print(f"Settings error: {e}")
|
||||
|
||||
def check_expirations(self):
|
||||
"""Check and remove expired notes"""
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Find expired notes that aren't locked
|
||||
cursor.execute(
|
||||
"SELECT id, text FROM notes WHERE expires IS NOT NULL AND expires < ? AND locked = 0",
|
||||
(now,)
|
||||
)
|
||||
expired = cursor.fetchall()
|
||||
|
||||
if expired:
|
||||
# Delete expired notes
|
||||
cursor.execute(
|
||||
"DELETE FROM notes WHERE expires IS NOT NULL AND expires < ? AND locked = 0",
|
||||
(now,)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
expiredCount = len(expired)
|
||||
if expiredCount > 0:
|
||||
self.show_status_message(f"Removed {expiredCount} expired notes")
|
||||
# Refresh notes list
|
||||
self.populate_notes()
|
||||
except sqlite3.Error as e:
|
||||
print(f"Expiration check error: {e}")
|
||||
|
||||
def build_ui(self):
|
||||
"""Build the main user interface with tabs"""
|
||||
# Create notebook (tabbed interface)
|
||||
self.notebook = Gtk.Notebook()
|
||||
self.notebook.set_tab_pos(Gtk.PositionType.TOP)
|
||||
|
||||
# Make tabs keyboard navigable
|
||||
self.notebook.set_can_focus(True)
|
||||
|
||||
self.window.add(self.notebook)
|
||||
|
||||
# Build notes tab
|
||||
notesTab = self.build_notes_tab()
|
||||
notesTabLabel = Gtk.Label(label="Notes")
|
||||
self.notebook.append_page(notesTab, notesTabLabel)
|
||||
self.notebook.set_tab_reorderable(notesTab, False)
|
||||
|
||||
# Build settings tab
|
||||
settingsTab = self.build_settings_tab()
|
||||
settingsTabLabel = Gtk.Label(label="Settings")
|
||||
self.notebook.append_page(settingsTab, settingsTabLabel)
|
||||
self.notebook.set_tab_reorderable(settingsTab, False)
|
||||
|
||||
# Connect tab change signal
|
||||
self.notebook.connect("switch-page", self.on_tab_switched)
|
||||
|
||||
def build_notes_tab(self):
|
||||
"""Build the notes tab"""
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
vbox.set_border_width(10)
|
||||
|
||||
# Notes list with scrolling
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
# Create list store and view
|
||||
self.notesStore = Gtk.ListStore(int, str, bool, str) # id, text, locked, expiry
|
||||
self.notesView = Gtk.TreeView(model=self.notesStore)
|
||||
self.notesView.set_activate_on_single_click(False)
|
||||
self.notesView.connect("row-activated", self.on_row_activated)
|
||||
|
||||
# Improve keyboard navigation in the tree view
|
||||
self.notesView.set_can_focus(True)
|
||||
self.notesView.set_headers_clickable(True)
|
||||
self.notesView.set_enable_search(True)
|
||||
self.notesView.set_search_column(1) # Search by note text
|
||||
|
||||
# Add columns with renderers
|
||||
self.add_columns()
|
||||
|
||||
# Populate the list
|
||||
self.populate_notes()
|
||||
|
||||
scrolled.add(self.notesView)
|
||||
vbox.pack_start(scrolled, True, True, 0)
|
||||
|
||||
# Action buttons
|
||||
actionBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
|
||||
# Copy button
|
||||
copyButton = Gtk.Button.new_with_label("Copy to Clipboard")
|
||||
copyButton.connect("clicked", self.on_copy_clicked)
|
||||
copyButton.set_can_focus(True)
|
||||
actionBox.pack_start(copyButton, False, False, 0)
|
||||
|
||||
# Delete button
|
||||
deleteButton = Gtk.Button.new_with_label("Delete Note")
|
||||
deleteButton.connect("clicked", self.on_delete_button_clicked)
|
||||
deleteButton.set_can_focus(True)
|
||||
actionBox.pack_start(deleteButton, False, False, 0)
|
||||
|
||||
vbox.pack_start(actionBox, False, False, 0)
|
||||
|
||||
# Entry for adding new notes
|
||||
entryBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
self.newNoteEntry = Gtk.Entry()
|
||||
self.newNoteEntry.set_placeholder_text("Type a new note and press Enter")
|
||||
self.newNoteEntry.connect("activate", self.on_entry_activate)
|
||||
self.newNoteEntry.set_can_focus(True)
|
||||
entryBox.pack_start(self.newNoteEntry, True, True, 0)
|
||||
|
||||
# Add button
|
||||
addButton = Gtk.Button.new_with_label("Add Note")
|
||||
addButton.connect("clicked", self.on_add_clicked)
|
||||
addButton.set_can_focus(True)
|
||||
entryBox.pack_start(addButton, False, False, 0)
|
||||
|
||||
vbox.pack_start(entryBox, False, False, 0)
|
||||
|
||||
# Status bar
|
||||
self.statusbar = Gtk.Statusbar()
|
||||
self.statusbarCtx = self.statusbar.get_context_id("jot")
|
||||
vbox.pack_start(self.statusbar, False, False, 0)
|
||||
|
||||
return vbox
|
||||
|
||||
def build_settings_tab(self):
|
||||
"""Build the settings tab"""
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
vbox.set_border_width(20)
|
||||
|
||||
# Create a frame for expiration settings
|
||||
expiryFrame = Gtk.Frame(label="Note Expiration")
|
||||
vbox.pack_start(expiryFrame, False, False, 0)
|
||||
|
||||
# Container for frame content
|
||||
expiryBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
expiryBox.set_border_width(10)
|
||||
expiryFrame.add(expiryBox)
|
||||
|
||||
# Note expiration setting
|
||||
# First radio button for "Never expire"
|
||||
self.neverExpireRadio = Gtk.RadioButton.new_with_label_from_widget(None, "Never expire notes")
|
||||
self.neverExpireRadio.set_can_focus(True)
|
||||
expiryBox.pack_start(self.neverExpireRadio, False, False, 0)
|
||||
|
||||
# Container for expiration days selection
|
||||
expireDaysBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
|
||||
# Radio button for custom expiration
|
||||
self.customExpireRadio = Gtk.RadioButton.new_with_label_from_widget(
|
||||
self.neverExpireRadio,
|
||||
"Expire notes after"
|
||||
)
|
||||
self.customExpireRadio.set_can_focus(True)
|
||||
expireDaysBox.pack_start(self.customExpireRadio, False, False, 0)
|
||||
|
||||
# Spin button for days
|
||||
adjustment = Gtk.Adjustment(
|
||||
value=max(1, int(self.expirationDays)) if int(self.expirationDays) > 0 else 7,
|
||||
lower=1,
|
||||
upper=30,
|
||||
step_increment=1
|
||||
)
|
||||
self.daysSpinButton = Gtk.SpinButton()
|
||||
self.daysSpinButton.set_adjustment(adjustment)
|
||||
self.daysSpinButton.set_can_focus(True)
|
||||
expireDaysBox.pack_start(self.daysSpinButton, False, False, 0)
|
||||
|
||||
# Label for "days"
|
||||
daysLabel = Gtk.Label(label="days")
|
||||
expireDaysBox.pack_start(daysLabel, False, False, 0)
|
||||
|
||||
expiryBox.pack_start(expireDaysBox, False, False, 0)
|
||||
|
||||
# Create a frame for confirmation settings
|
||||
confirmFrame = Gtk.Frame(label="Confirmations")
|
||||
vbox.pack_start(confirmFrame, False, False, 10)
|
||||
|
||||
# Container for confirmation settings
|
||||
confirmBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
confirmBox.set_border_width(10)
|
||||
confirmFrame.add(confirmBox)
|
||||
|
||||
# Delete confirmation checkbox
|
||||
self.confirmDeleteCheck = Gtk.CheckButton(label="Confirm before deleting notes")
|
||||
self.confirmDeleteCheck.set_active(bool(int(self.confirmDelete)))
|
||||
self.confirmDeleteCheck.set_can_focus(True)
|
||||
confirmBox.pack_start(self.confirmDeleteCheck, False, False, 0)
|
||||
|
||||
# Set the active radio button based on current setting
|
||||
if int(self.expirationDays) > 0:
|
||||
self.customExpireRadio.set_active(True)
|
||||
else:
|
||||
self.neverExpireRadio.set_active(True)
|
||||
|
||||
# Connect signals
|
||||
self.neverExpireRadio.connect("toggled", self.on_expiry_radio_toggled)
|
||||
self.customExpireRadio.connect("toggled", self.on_expiry_radio_toggled)
|
||||
|
||||
# Enable/disable the spin button based on the selected radio
|
||||
self.on_expiry_radio_toggled(None)
|
||||
|
||||
# Save button
|
||||
saveButton = Gtk.Button.new_with_label("Save Settings")
|
||||
saveButton.connect("clicked", self.on_save_settings)
|
||||
saveButton.set_can_focus(True)
|
||||
vbox.pack_start(saveButton, False, False, 10)
|
||||
|
||||
return vbox
|
||||
|
||||
def add_columns(self):
|
||||
"""Add columns to the TreeView"""
|
||||
# ID Column
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn("ID", renderer, text=0)
|
||||
column.set_sort_column_id(0)
|
||||
self.notesView.append_column(column)
|
||||
|
||||
# Note Text Column
|
||||
renderer = Gtk.CellRendererText()
|
||||
renderer.set_property("ellipsize", True)
|
||||
column = Gtk.TreeViewColumn("Note", renderer, text=1)
|
||||
column.set_expand(True)
|
||||
self.notesView.append_column(column)
|
||||
|
||||
# Locked Column
|
||||
renderer = Gtk.CellRendererToggle()
|
||||
renderer.connect("toggled", self.on_locked_toggled)
|
||||
column = Gtk.TreeViewColumn("Locked", renderer, active=2)
|
||||
self.notesView.append_column(column)
|
||||
|
||||
# Expiration Column
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn("Expires", renderer, text=3)
|
||||
self.notesView.append_column(column)
|
||||
|
||||
def populate_notes(self):
|
||||
"""Populate the list store with notes from the database"""
|
||||
self.notesStore.clear()
|
||||
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, text, locked, expires FROM notes ORDER BY id"
|
||||
)
|
||||
notes = cursor.fetchall()
|
||||
|
||||
for note in notes:
|
||||
noteId, text, locked, expires = note
|
||||
|
||||
# Format expiry date if it exists
|
||||
expiryText = ""
|
||||
if expires:
|
||||
try:
|
||||
expiryDate = datetime.datetime.strptime(expires, "%Y-%m-%d %H:%M:%S")
|
||||
expiryText = expiryDate.strftime("%Y-%m-%d")
|
||||
except:
|
||||
expiryText = "Invalid date"
|
||||
|
||||
self.notesStore.append([
|
||||
noteId, # Database ID
|
||||
text, # Note text
|
||||
bool(locked), # Locked status
|
||||
expiryText # Expiry date
|
||||
])
|
||||
except sqlite3.Error as e:
|
||||
print(f"Error populating notes: {e}")
|
||||
|
||||
def setup_actions(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Delete selected note
|
||||
deleteAction = Gio.SimpleAction.new("delete", None)
|
||||
deleteAction.connect("activate", self.on_delete_clicked)
|
||||
self.add_action(deleteAction)
|
||||
self.set_accels_for_action("app.delete", ["Delete"])
|
||||
|
||||
# Toggle lock on selected note
|
||||
lockAction = Gio.SimpleAction.new("lock", None)
|
||||
lockAction.connect("activate", self.on_lock_toggled)
|
||||
self.add_action(lockAction)
|
||||
self.set_accels_for_action("app.lock", ["l"])
|
||||
|
||||
# Copy note to clipboard
|
||||
copyAction = Gio.SimpleAction.new("copy", None)
|
||||
copyAction.connect("activate", self.on_copy_clicked)
|
||||
self.add_action(copyAction)
|
||||
self.set_accels_for_action("app.copy", ["<Control>c"])
|
||||
|
||||
# Switch to notes tab
|
||||
notesTabAction = Gio.SimpleAction.new("notes_tab", None)
|
||||
notesTabAction.connect("activate", lambda a, p: self.notebook.set_current_page(0))
|
||||
self.add_action(notesTabAction)
|
||||
self.set_accels_for_action("app.notes_tab", ["<Alt>1"])
|
||||
|
||||
# Switch to settings tab
|
||||
settingsTabAction = Gio.SimpleAction.new("settings_tab", None)
|
||||
settingsTabAction.connect("activate", lambda a, p: self.notebook.set_current_page(1))
|
||||
self.add_action(settingsTabAction)
|
||||
self.set_accels_for_action("app.settings_tab", ["<Alt>2"])
|
||||
|
||||
# Quit application
|
||||
quitAction = Gio.SimpleAction.new("quit", None)
|
||||
quitAction.connect("activate", lambda a, p: self.quit())
|
||||
self.add_action(quitAction)
|
||||
self.set_accels_for_action("app.quit", ["Escape"])
|
||||
|
||||
def show_status_message(self, message):
|
||||
"""Show a message in the statusbar"""
|
||||
self.statusbar.push(self.statusbarCtx, message)
|
||||
# Auto-remove after 5 seconds
|
||||
GLib.timeout_add_seconds(5, self.statusbar.pop, self.statusbarCtx)
|
||||
|
||||
def on_tab_switched(self, notebook, page, page_num):
|
||||
"""Handler for tab switching"""
|
||||
# Reset status bar on tab switch
|
||||
self.statusbar.pop(self.statusbarCtx)
|
||||
|
||||
# Set focus appropriately
|
||||
if page_num == 0: # Notes tab
|
||||
self.newNoteEntry.grab_focus()
|
||||
elif page_num == 1: # Settings tab
|
||||
if self.neverExpireRadio.get_active():
|
||||
self.neverExpireRadio.grab_focus()
|
||||
else:
|
||||
self.customExpireRadio.grab_focus()
|
||||
|
||||
def on_expiry_radio_toggled(self, widget):
|
||||
"""Handler for expiry radio button toggles"""
|
||||
# Enable/disable spin button based on which radio is active
|
||||
self.daysSpinButton.set_sensitive(self.customExpireRadio.get_active())
|
||||
|
||||
def on_save_settings(self, button):
|
||||
"""Handler for Save Settings button"""
|
||||
if self.neverExpireRadio.get_active():
|
||||
self.expirationDays = 0
|
||||
else:
|
||||
self.expirationDays = self.daysSpinButton.get_value_as_int()
|
||||
|
||||
# Get delete confirmation setting
|
||||
self.confirmDelete = 1 if self.confirmDeleteCheck.get_active() else 0
|
||||
|
||||
# Save to database
|
||||
self.save_setting("expirationDays", self.expirationDays)
|
||||
self.save_setting("confirmDelete", self.confirmDelete)
|
||||
|
||||
# Apply expiration to notes if needed
|
||||
if self.expirationDays > 0:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
now = datetime.datetime.now()
|
||||
expiryDate = now + datetime.timedelta(days=self.expirationDays)
|
||||
expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Set expiration for notes that aren't locked and don't have expiration
|
||||
cursor.execute(
|
||||
"UPDATE notes SET expires = ? WHERE locked = 0 AND expires IS NULL",
|
||||
(expiryStr,)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# Refresh the notes list
|
||||
self.populate_notes()
|
||||
except sqlite3.Error as e:
|
||||
print(f"Error updating expirations: {e}")
|
||||
|
||||
self.show_status_message("Settings saved")
|
||||
|
||||
# Switch back to notes tab
|
||||
self.notebook.set_current_page(0)
|
||||
|
||||
def on_entry_activate(self, entry):
|
||||
"""Handle Enter key in the entry field"""
|
||||
self.add_new_note(entry.get_text())
|
||||
entry.set_text("")
|
||||
|
||||
def on_add_clicked(self, button):
|
||||
"""Handle Add Note button click"""
|
||||
self.add_new_note(self.newNoteEntry.get_text())
|
||||
self.newNoteEntry.set_text("")
|
||||
|
||||
def add_new_note(self, text):
|
||||
"""Add a new note to the database"""
|
||||
if not text.strip():
|
||||
self.show_status_message("Note text cannot be empty")
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Set expiration if enabled
|
||||
expires = None
|
||||
if int(self.expirationDays) > 0:
|
||||
expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays))
|
||||
expires = expiryDate.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Insert the new note
|
||||
cursor.execute(
|
||||
"INSERT INTO notes (text, expires, locked) VALUES (?, ?, ?)",
|
||||
(text, expires, 0)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# Refresh the notes list
|
||||
self.populate_notes()
|
||||
self.show_status_message("Note added")
|
||||
except sqlite3.Error as e:
|
||||
self.show_status_message(f"Error adding note: {e}")
|
||||
|
||||
def on_row_activated(self, view, path, column):
|
||||
"""Handle double-click on a note - edit the note"""
|
||||
model = view.get_model()
|
||||
noteId = model[path][0]
|
||||
|
||||
# Get the note from the database
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute("SELECT text, locked, expires FROM notes WHERE id = ?", (noteId,))
|
||||
note = cursor.fetchone()
|
||||
|
||||
if note:
|
||||
self.edit_note_dialog(noteId, note)
|
||||
except sqlite3.Error as e:
|
||||
self.show_status_message(f"Error retrieving note: {e}")
|
||||
|
||||
def on_locked_toggled(self, renderer, path):
|
||||
"""Handle toggling the locked state from the view"""
|
||||
model = self.notesView.get_model()
|
||||
noteId = model[path][0]
|
||||
currentLocked = model[path][2]
|
||||
newLocked = not currentLocked
|
||||
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Update locked status
|
||||
cursor.execute(
|
||||
"UPDATE notes SET locked = ? WHERE id = ?",
|
||||
(1 if newLocked else 0, noteId)
|
||||
)
|
||||
|
||||
# If unlocking and expiration is enabled, set expiration
|
||||
if not newLocked and int(self.expirationDays) > 0:
|
||||
expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays))
|
||||
expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE notes SET expires = ? WHERE id = ?",
|
||||
(expiryStr, noteId)
|
||||
)
|
||||
|
||||
# Update the expiry text in the model
|
||||
model[path][3] = expiryDate.strftime("%Y-%m-%d")
|
||||
|
||||
self.show_status_message("Note unlocked - expiration set")
|
||||
elif newLocked:
|
||||
self.show_status_message("Note locked - will not expire")
|
||||
else:
|
||||
self.show_status_message("Note unlocked")
|
||||
|
||||
# Update the model
|
||||
model[path][2] = newLocked
|
||||
|
||||
self.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self.show_status_message(f"Error updating note: {e}")
|
||||
|
||||
def on_lock_toggled(self, action, parameter):
|
||||
"""Handle keyboard shortcut to toggle lock"""
|
||||
selection = self.notesView.get_selection()
|
||||
model, treeiter = selection.get_selected()
|
||||
if treeiter:
|
||||
noteId = model[treeiter][0]
|
||||
currentLocked = model[treeiter][2]
|
||||
|
||||
# Simulate clicking the toggle
|
||||
path = model.get_path(treeiter)
|
||||
self.on_locked_toggled(None, path)
|
||||
|
||||
def on_copy_clicked(self, action=None, parameter=None):
|
||||
"""Copy the selected note to clipboard"""
|
||||
selection = self.notesView.get_selection()
|
||||
model, treeiter = selection.get_selected()
|
||||
if treeiter:
|
||||
noteText = model[treeiter][1]
|
||||
|
||||
# Get the clipboard
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(noteText, -1)
|
||||
|
||||
self.show_status_message("Note copied to clipboard")
|
||||
else:
|
||||
self.show_status_message("No note selected")
|
||||
|
||||
def on_delete_button_clicked(self, button):
|
||||
"""Handle Delete button click"""
|
||||
selection = self.notesView.get_selection()
|
||||
model, treeiter = selection.get_selected()
|
||||
if treeiter:
|
||||
noteId = model[treeiter][0]
|
||||
noteText = model[treeiter][1]
|
||||
|
||||
if int(self.confirmDelete):
|
||||
self.confirm_delete_note(noteId, noteText)
|
||||
else:
|
||||
self.delete_note(noteId)
|
||||
|
||||
def on_delete_clicked(self, action, parameter):
|
||||
"""Handle Delete key to remove a note"""
|
||||
selection = self.notesView.get_selection()
|
||||
model, treeiter = selection.get_selected()
|
||||
if treeiter:
|
||||
noteId = model[treeiter][0]
|
||||
noteText = model[treeiter][1]
|
||||
|
||||
if int(self.confirmDelete):
|
||||
self.confirm_delete_note(noteId, noteText)
|
||||
else:
|
||||
self.delete_note(noteId)
|
||||
|
||||
def delete_note(self, noteId):
|
||||
"""Delete a note by ID without confirmation"""
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute("DELETE FROM notes WHERE id = ?", (noteId,))
|
||||
self.conn.commit()
|
||||
|
||||
self.populate_notes()
|
||||
self.show_status_message("Note deleted")
|
||||
except sqlite3.Error as e:
|
||||
self.show_status_message(f"Error deleting note: {e}")
|
||||
|
||||
def confirm_delete_note(self, noteId, noteText):
|
||||
"""Show confirmation dialog before deleting a note"""
|
||||
if len(noteText) > 30:
|
||||
noteText = noteText[:30] + "..."
|
||||
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self.window,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.YES_NO,
|
||||
text=f"Delete note: {noteText}?"
|
||||
)
|
||||
dialog.set_default_response(Gtk.ResponseType.NO)
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.YES:
|
||||
self.delete_note(noteId)
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def edit_note_dialog(self, noteId, note):
|
||||
"""Show dialog to edit a note"""
|
||||
text, locked, expires = note
|
||||
|
||||
dialog = Gtk.Dialog(
|
||||
title="Edit Note",
|
||||
parent=self.window,
|
||||
flags=0,
|
||||
buttons=(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
|
||||
)
|
||||
)
|
||||
dialog.set_default_size(400, 200)
|
||||
|
||||
# Make the dialog accessible
|
||||
dialog.set_role("dialog")
|
||||
dialog.set_property("has-tooltip", True)
|
||||
|
||||
# Create a text view for the note
|
||||
entryBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
entryBox.set_border_width(10)
|
||||
|
||||
label = Gtk.Label(label="Edit note text:")
|
||||
label.set_halign(Gtk.Align.START)
|
||||
entryBox.pack_start(label, False, False, 0)
|
||||
|
||||
# Scrolled window for text view
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scrolled.set_shadow_type(Gtk.ShadowType.IN)
|
||||
|
||||
# Text view for multi-line editing
|
||||
textBuffer = Gtk.TextBuffer()
|
||||
textBuffer.set_text(text)
|
||||
textView = Gtk.TextView.new_with_buffer(textBuffer)
|
||||
textView.set_wrap_mode(Gtk.WrapMode.WORD)
|
||||
textView.set_can_focus(True)
|
||||
|
||||
scrolled.add(textView)
|
||||
entryBox.pack_start(scrolled, True, True, 0)
|
||||
|
||||
# Add lock checkbox
|
||||
lockCheck = Gtk.CheckButton(label="Lock note (prevent expiration)")
|
||||
lockCheck.set_active(bool(locked))
|
||||
lockCheck.set_can_focus(True)
|
||||
entryBox.pack_start(lockCheck, False, False, 0)
|
||||
|
||||
# Show expiration date if it exists
|
||||
if expires and not locked:
|
||||
try:
|
||||
expiryDate = datetime.datetime.strptime(expires, "%Y-%m-%d %H:%M:%S")
|
||||
expiryLabel = Gtk.Label(label=f"Expires on: {expiryDate.strftime('%Y-%m-%d')}")
|
||||
expiryLabel.set_halign(Gtk.Align.START)
|
||||
entryBox.pack_start(expiryLabel, False, False, 0)
|
||||
except:
|
||||
pass
|
||||
|
||||
dialog.get_content_area().add(entryBox)
|
||||
dialog.set_default_response(Gtk.ResponseType.OK)
|
||||
dialog.show_all()
|
||||
|
||||
# Set focus to text view
|
||||
textView.grab_focus()
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
# Get the text from the buffer
|
||||
start, end = textBuffer.get_bounds()
|
||||
newText = textBuffer.get_text(start, end, False)
|
||||
newLocked = lockCheck.get_active()
|
||||
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Update the note
|
||||
cursor.execute(
|
||||
"UPDATE notes SET text = ?, locked = ? WHERE id = ?",
|
||||
(newText, 1 if newLocked else 0, noteId)
|
||||
)
|
||||
|
||||
# Update expiration if needed
|
||||
if not newLocked and int(self.expirationDays) > 0:
|
||||
expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays))
|
||||
expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE notes SET expires = ? WHERE id = ?",
|
||||
(expiryStr, noteId)
|
||||
)
|
||||
|
||||
self.conn.commit()
|
||||
self.populate_notes()
|
||||
self.show_status_message("Note updated")
|
||||
except sqlite3.Error as e:
|
||||
self.show_status_message(f"Error updating note: {e}")
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def main():
|
||||
app = JotApp()
|
||||
return app.run(None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -20,13 +20,13 @@ i3 = i3ipc.Connection()
|
||||
|
||||
def on_new_window(self,i3):
|
||||
if i3.container.name == 'xfce4-notifyd':
|
||||
system('play -nqV0 synth .05 sq 1800 tri 2400 delay 0 .03 remix - repeat 2 echo .55 0.7 20 1 norm -12 &')
|
||||
system('play -n synth .05 sq 1800 tri 2400 delay 0 .03 remix - repeat 2 echo .55 0.7 20 1 norm -12 &')
|
||||
else:
|
||||
system('play -nqV0 synth .25 sin 440:880 sin 480:920 remix - norm -3 pitch -500 &')
|
||||
system('play -n synth .25 sin 440:880 sin 480:920 remix - norm -3 pitch -500 &')
|
||||
|
||||
def on_close_window(self,i3):
|
||||
if i3.container.name != 'xfce4-notifyd':
|
||||
system('play -nqV0 synth .25 sin 880:440 sin 920:480 remix - norm -3 pitch -500 &')
|
||||
system('play -n synth .25 sin 880:440 sin 920:480 remix - norm -3 pitch -500 &')
|
||||
|
||||
def on_mode(self,event):
|
||||
mode= event.change
|
||||
@ -37,7 +37,7 @@ def on_mode(self,event):
|
||||
elif mode == 'default':
|
||||
system('play -qV0 "|sox -np synth .07 sq 400" "|sox -np synth .5 sq 800" fade h 0 .5 .5 norm -20 reverse &')
|
||||
else:
|
||||
system('play -nqV0 synth 0.05 pluck F3 norm -8 : synth 0.05 pluck C4 norm -8 : synth 0.05 pluck F4 norm -8 : synth 0.05 pluck C5 norm -8')
|
||||
system('play -n synth 0.05 pluck F3 norm -8 : synth 0.05 pluck C4 norm -8 : synth 0.05 pluck F4 norm -8 : synth 0.05 pluck C5 norm -8')
|
||||
|
||||
def on_workspace_focus(self,i3):
|
||||
#system('play -qnV0 synth pi fade 0 .25 .15 pad 0 1 reverb overdrive riaa norm -8 speed 1 &')
|
||||
@ -47,13 +47,13 @@ def on_workspace_move(self,i3):
|
||||
system('play -qnV0 synth pi fade 0 .25 .15 pad 0 1 reverb overdrive riaa norm -8 speed 1 reverse &')
|
||||
|
||||
def on_restart(self,i3):
|
||||
system('play -qnV0 synth .25 saw 500:1200 fade .1 .25 .1 norm -8 &')
|
||||
system('play -qn synth .25 saw 500:1200 fade .1 .25 .1 norm -8 &')
|
||||
|
||||
def on_exit(self,i3):
|
||||
system('play -qnV0 synth .3 sin 700:200 fade 0 .3 0 &')
|
||||
system('play -qn synth .3 sin 700:200 fade 0 .3 0 &')
|
||||
|
||||
def on_fullscreen(self,i3):
|
||||
system('play -qnV0 synth br flanger fade h .3 .3 0 &')
|
||||
system('play -qn synth br flanger fade h .3 .3 0 &')
|
||||
|
||||
i3 = i3ipc.Connection()
|
||||
|
||||
|
@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
# Initialize variables
|
||||
cpuUsage=0
|
||||
cpuTemp=0
|
||||
memoryUsed=0
|
||||
memoryTotal=0
|
||||
memoryPercent=0
|
||||
swapUsed=0
|
||||
swapTotal=0
|
||||
swapPercent=0
|
||||
diskUsed=0
|
||||
diskTotal=0
|
||||
diskPercent=0
|
||||
networkSent=0
|
||||
networkRecv=0
|
||||
|
||||
# Helper function for temperature conversion
|
||||
celsius_to_fahrenheit() {
|
||||
local celsius="$1"
|
||||
[[ -z "$celsius" || "$celsius" == "--" ]] && echo "--" && return
|
||||
[[ ! "$celsius" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] && echo "--" && return
|
||||
|
||||
local fahrenheit
|
||||
fahrenheit=$(echo "scale=1; ($celsius * 9/5) + 32" | bc -l)
|
||||
echo "$fahrenheit"
|
||||
}
|
||||
|
||||
update_system_data() {
|
||||
# CPU usage
|
||||
cpuUsage=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}')
|
||||
cpuUsage=$(printf "%.1f" "$cpuUsage" 2>/dev/null || echo "$cpuUsage")
|
||||
|
||||
# CPU temperature - fix for high readings
|
||||
local tempCelsius="--"
|
||||
if [[ -f /sys/class/thermal/thermal_zone0/temp ]]; then
|
||||
tempCelsius=$(cat /sys/class/thermal/thermal_zone0/temp)
|
||||
# Check if we need to divide by 1000 (common format)
|
||||
if [[ $tempCelsius -gt 200 ]]; then
|
||||
tempCelsius=$(echo "scale=1; $tempCelsius/1000" | bc -l)
|
||||
fi
|
||||
elif [[ -f /sys/class/hwmon/hwmon0/temp1_input ]]; then
|
||||
tempCelsius=$(cat /sys/class/hwmon/hwmon0/temp1_input)
|
||||
if [[ $tempCelsius -gt 200 ]]; then
|
||||
tempCelsius=$(echo "scale=1; $tempCelsius/1000" | bc -l)
|
||||
fi
|
||||
elif command -v sensors &>/dev/null; then
|
||||
tempCelsius=$(sensors | grep -oP 'Core 0.*?\+\K[0-9.]+')
|
||||
fi
|
||||
|
||||
[[ "$tempCelsius" != "--" && "$tempCelsius" != "null" ]] && cpuTemp=$(celsius_to_fahrenheit "$tempCelsius") || cpuTemp="--"
|
||||
|
||||
# Memory usage
|
||||
memoryTotal=$(free -m | awk '/^Mem:/{print $2/1024}')
|
||||
memoryUsed=$(free -m | awk '/^Mem:/{print $3/1024}')
|
||||
memoryPercent=$(free | awk '/^Mem:/{printf("%.1f", $3/$2 * 100)}')
|
||||
|
||||
# Swap usage
|
||||
swapTotal=$(free -m | awk '/^Swap:/{print $2/1024}')
|
||||
swapUsed=$(free -m | awk '/^Swap:/{print $3/1024}')
|
||||
[[ "$swapTotal" -gt 0 ]] && swapPercent=$(free | awk '/^Swap:/{printf("%.1f", $3/$2 * 100)}') || swapPercent=0
|
||||
|
||||
# Disk usage
|
||||
diskTotal=$(df -h / | awk 'NR==2 {print $2}')
|
||||
diskUsed=$(df -h / | awk 'NR==2 {print $3}')
|
||||
diskPercent=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
|
||||
|
||||
# Network usage
|
||||
networkSent=$(cat /proc/net/dev | grep -v "lo:" | awk '{s+=$10} END {printf "%.2f", s/1024/1024}')
|
||||
networkRecv=$(cat /proc/net/dev | grep -v "lo:" | awk '{r+=$2} END {printf "%.2f", r/1024/1024}')
|
||||
}
|
||||
|
||||
display_system_info() {
|
||||
update_system_data
|
||||
|
||||
# Create the system information text with proper line breaks
|
||||
systemInfoText="System Information
|
||||
|
||||
CPU
|
||||
Usage: ${cpuUsage}%
|
||||
Temperature: ${cpuTemp}° F
|
||||
|
||||
Memory
|
||||
Usage: ${memoryUsed} / ${memoryTotal} GB (${memoryPercent}%)
|
||||
Swap: ${swapUsed} / ${swapTotal} GB (${swapPercent}%)
|
||||
|
||||
Disk (Root Partition)
|
||||
Usage: ${diskUsed} / ${diskTotal} GB (${diskPercent}%)
|
||||
|
||||
Network (Total Since Boot)
|
||||
Received: ${networkRecv} MB
|
||||
Sent: ${networkSent} MB
|
||||
|
||||
End of text. Press Control+Home to return to the beginning."
|
||||
|
||||
# Display in text-info dialog for screen reader accessibility
|
||||
echo "$systemInfoText" | yad --pname=I38System \
|
||||
--title="I38 System Information" \
|
||||
--text-info \
|
||||
--show-cursor \
|
||||
--width=400 \
|
||||
--height=400 \
|
||||
--center \
|
||||
--button="Close:0"
|
||||
}
|
||||
|
||||
display_system_info
|
||||
|
||||
exit 0
|
@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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/>.
|
||||
|
||||
is_running() {
|
||||
pgrep -x "$1" >/dev/null
|
||||
return $?
|
||||
}
|
||||
|
||||
speak() {
|
||||
spd-say -P important -Cw -- "$*"
|
||||
}
|
||||
|
||||
# Make sure screen readers are available
|
||||
orcaAvailable=false
|
||||
cthulhuAvailable=false
|
||||
|
||||
if command -v orca &> /dev/null; then
|
||||
orcaAvailable=true
|
||||
fi
|
||||
|
||||
if command -v cthulhu &> /dev/null; then
|
||||
cthulhuAvailable=true
|
||||
fi
|
||||
|
||||
# Check if at least one screen reader is available
|
||||
if ! $orcaAvailable && ! $cthulhuAvailable; then
|
||||
speak "No screen readers found. Please install Orca or Cthulhu."
|
||||
yad --center --title="I38" --alert="No screen readers found.\nPlease install Orca or Cthulhu."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine current state
|
||||
currentReader="none"
|
||||
if is_running "cthulhu"; then
|
||||
currentReader="cthulhu"
|
||||
elif is_running "orca"; then
|
||||
currentReader="orca"
|
||||
fi
|
||||
|
||||
# Build YAD command based on available screen readers
|
||||
items=()
|
||||
|
||||
# First add the currently active screen reader
|
||||
if [ "$currentReader" != "none" ]; then
|
||||
if [ "$currentReader" = "cthulhu" ] && $cthulhuAvailable; then
|
||||
items+=("Cthulhu (active)" "cthulhu")
|
||||
elif [ "$currentReader" = "orca" ] && $orcaAvailable; then
|
||||
items+=("Orca (active)" "orca")
|
||||
fi
|
||||
fi
|
||||
|
||||
if $cthulhuAvailable && [ "$currentReader" != "cthulhu" ]; then
|
||||
items+=("Cthulhu" "cthulhu")
|
||||
fi
|
||||
|
||||
if $orcaAvailable && [ "$currentReader" != "orca" ]; then
|
||||
items+=("Orca" "orca")
|
||||
fi
|
||||
|
||||
# Display the dialog
|
||||
result=$(yad --center --title="Screen Reader Toggle" --text="Select screen reader:" \
|
||||
--list --on-top --skip-taskbar \
|
||||
--button="Cancel:1" --button="OK:0" \
|
||||
--column="Screen Reader" --column=ID:HD "${items[@]}")
|
||||
exitCode=$?
|
||||
if [ $exitCode -ne 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$result" ]; then
|
||||
# Extract the selected reader from the result
|
||||
selectedReader=$(echo "$result" | cut -d'|' -f2)
|
||||
|
||||
# Don't do anything if selecting the already active reader
|
||||
if [ "$selectedReader" = "$currentReader" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stop current screen reader
|
||||
if [ "$currentReader" != "none" ]; then
|
||||
speak "Switching from $currentReader to $selectedReader."
|
||||
pkill -15 "$currentReader"
|
||||
sleep 0.5
|
||||
else
|
||||
speak "Starting $selectedReader."
|
||||
fi
|
||||
|
||||
if [ "$selectedReader" = "orca" ]; then
|
||||
orca &
|
||||
else
|
||||
cthulhu &
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
@ -1,439 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
# Configuration settings
|
||||
defaultCity="Raleigh, NC"
|
||||
defaultLat="35.78"
|
||||
defaultLon="-78.64"
|
||||
tempUnit="F"
|
||||
updateInterval=30
|
||||
|
||||
configDir="${XDG_CONFIG_HOME:-$HOME/.config}/stormux/I38"
|
||||
configFile="$configDir/weather.conf"
|
||||
mkdir -p "$configDir"
|
||||
|
||||
# Initialize variables
|
||||
cityName="Detecting..."
|
||||
latitude=0.0
|
||||
longitude=0.0
|
||||
currentTemp="--"
|
||||
currentHumidity="--"
|
||||
currentWindSpeed="--"
|
||||
currentWindSpeedMph="--"
|
||||
currentConditions="Unknown"
|
||||
weatherLastUpdate=0
|
||||
severeWeatherAlerted=0
|
||||
|
||||
declare -a forecastDates=("--" "--" "--")
|
||||
declare -a forecastFormattedDates=("--" "--" "--")
|
||||
declare -a forecastMinTemps=("--" "--" "--")
|
||||
declare -a forecastMaxTemps=("--" "--" "--")
|
||||
declare -a forecastConditions=("Unknown" "Unknown" "Unknown")
|
||||
|
||||
declare -A weatherCodes
|
||||
weatherCodes[0]="Clear sky"
|
||||
weatherCodes[1]="Mainly clear"
|
||||
weatherCodes[2]="Partly cloudy"
|
||||
weatherCodes[3]="Overcast"
|
||||
weatherCodes[45]="Fog"
|
||||
weatherCodes[48]="Rime fog"
|
||||
weatherCodes[51]="Light drizzle"
|
||||
weatherCodes[53]="Moderate drizzle"
|
||||
weatherCodes[55]="Dense drizzle"
|
||||
weatherCodes[56]="Light freezing drizzle"
|
||||
weatherCodes[57]="Dense freezing drizzle"
|
||||
weatherCodes[61]="Slight rain"
|
||||
weatherCodes[63]="Moderate rain"
|
||||
weatherCodes[65]="Heavy rain"
|
||||
weatherCodes[66]="Light freezing rain"
|
||||
weatherCodes[67]="Heavy freezing rain"
|
||||
weatherCodes[71]="Slight snow fall"
|
||||
weatherCodes[73]="Moderate snow fall"
|
||||
weatherCodes[75]="Heavy snow fall"
|
||||
weatherCodes[77]="Snow flurries"
|
||||
weatherCodes[80]="Slight rain showers"
|
||||
weatherCodes[81]="Moderate rain showers"
|
||||
weatherCodes[82]="Heavy rain showers"
|
||||
weatherCodes[85]="Slight snow showers"
|
||||
weatherCodes[86]="Heavy snow showers"
|
||||
weatherCodes[95]="Thunderstorm"
|
||||
weatherCodes[96]="Thunderstorm with slight hail"
|
||||
weatherCodes[99]="Thunderstorm with heavy hail"
|
||||
|
||||
declare -a severeWeatherCodes=(65 67 75 82 86 95 96 99)
|
||||
|
||||
# Button return codes
|
||||
refreshBtn=0
|
||||
quitBtn=1
|
||||
settingsBtn=2
|
||||
|
||||
trap "pkill -P $$" EXIT INT TERM
|
||||
|
||||
# Load configuration if available
|
||||
if [ -f "$configFile" ]; then
|
||||
source "$configFile"
|
||||
# Convert lastWeatherUpdate string to integer if it exists
|
||||
[[ -n "$lastWeatherUpdate" ]] && weatherLastUpdate=$lastWeatherUpdate || weatherLastUpdate=0
|
||||
|
||||
if [[ -n "$city" ]]; then
|
||||
cityName="$city"
|
||||
latitude="$latitude"
|
||||
longitude="$longitude"
|
||||
fi
|
||||
|
||||
# Try to reload saved weather data
|
||||
if [[ "$weatherLastUpdate" -gt 0 && "$currentTemp" == "--" ]]; then
|
||||
[[ -n "$savedCurrentTemp" ]] && currentTemp="$savedCurrentTemp"
|
||||
[[ -n "$savedCurrentHumidity" ]] && currentHumidity="$savedCurrentHumidity"
|
||||
[[ -n "$savedCurrentConditions" ]] && currentConditions="$savedCurrentConditions"
|
||||
[[ -n "$savedCurrentWindSpeed" ]] && currentWindSpeedMph="$savedCurrentWindSpeed"
|
||||
|
||||
for i in {0..2}; do
|
||||
varDate="savedForecastDate_$i"
|
||||
varMin="savedForecastMin_$i"
|
||||
varMax="savedForecastMax_$i"
|
||||
varCond="savedForecastCond_$i"
|
||||
|
||||
[[ -n "${!varDate}" ]] && forecastFormattedDates[$i]="${!varDate}"
|
||||
[[ -n "${!varMin}" ]] && forecastMinTemps[$i]="${!varMin}"
|
||||
[[ -n "${!varMax}" ]] && forecastMaxTemps[$i]="${!varMax}"
|
||||
[[ -n "${!varCond}" ]] && forecastConditions[$i]="${!varCond}"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper functions
|
||||
time_diff() {
|
||||
local timestamp="$1"
|
||||
local now=$(date +%s)
|
||||
local diff=$((now - timestamp))
|
||||
|
||||
if [ $diff -lt 60 ]; then
|
||||
echo "just now"
|
||||
elif [ $diff -lt 3600 ]; then
|
||||
local minutes=$((diff / 60))
|
||||
echo "$minutes minute$([ $minutes -ne 1 ] && echo "s") ago"
|
||||
elif [ $diff -lt 86400 ]; then
|
||||
local hours=$((diff / 3600))
|
||||
echo "$hours hour$([ $hours -ne 1 ] && echo "s") ago"
|
||||
else
|
||||
local days=$((diff / 86400))
|
||||
echo "$days day$([ $days -ne 1 ] && echo "s") ago"
|
||||
fi
|
||||
}
|
||||
|
||||
celsius_to_fahrenheit() {
|
||||
local celsius="$1"
|
||||
[[ -z "$celsius" || "$celsius" == "--" ]] && echo "--" && return
|
||||
[[ ! "$celsius" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] && echo "--" && return
|
||||
|
||||
local fahrenheit
|
||||
fahrenheit=$(echo "scale=1; ($celsius * 9/5) + 32" | bc -l)
|
||||
echo "$fahrenheit"
|
||||
}
|
||||
|
||||
kmh_to_mph() {
|
||||
local kmh="$1"
|
||||
[[ -z "$kmh" || "$kmh" == "--" ]] && echo "--" && return
|
||||
[[ ! "$kmh" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] && echo "--" && return
|
||||
|
||||
local mph
|
||||
mph=$(echo "scale=1; $kmh * 0.621371" | bc -l)
|
||||
echo "$mph"
|
||||
}
|
||||
|
||||
format_date() {
|
||||
local isoDate="$1"
|
||||
[[ -z "$isoDate" || "$isoDate" == "--" ]] && echo "--" && return
|
||||
date -d "$isoDate" "+%A, %B %d" 2>/dev/null || echo "$isoDate"
|
||||
}
|
||||
|
||||
# Save configuration
|
||||
save_config() {
|
||||
cat > "$configFile" << EOF
|
||||
city="$cityName"
|
||||
latitude="$latitude"
|
||||
longitude="$longitude"
|
||||
tempUnit=$tempUnit
|
||||
updateInterval=$updateInterval
|
||||
lastWeatherUpdate=$weatherLastUpdate
|
||||
savedCurrentTemp="$currentTemp"
|
||||
savedCurrentHumidity="$currentHumidity"
|
||||
savedCurrentConditions="$currentConditions"
|
||||
savedCurrentWindSpeed="$currentWindSpeedMph"
|
||||
savedForecastDate_0="${forecastFormattedDates[0]}"
|
||||
savedForecastMin_0="${forecastMinTemps[0]}"
|
||||
savedForecastMax_0="${forecastMaxTemps[0]}"
|
||||
savedForecastCond_0="${forecastConditions[0]}"
|
||||
savedForecastDate_1="${forecastFormattedDates[1]}"
|
||||
savedForecastMin_1="${forecastMinTemps[1]}"
|
||||
savedForecastMax_1="${forecastMaxTemps[1]}"
|
||||
savedForecastCond_1="${forecastConditions[1]}"
|
||||
savedForecastDate_2="${forecastFormattedDates[2]}"
|
||||
savedForecastMin_2="${forecastMinTemps[2]}"
|
||||
savedForecastMax_2="${forecastMaxTemps[2]}"
|
||||
savedForecastCond_2="${forecastConditions[2]}"
|
||||
EOF
|
||||
}
|
||||
|
||||
# Play severe weather alert sound using Sox
|
||||
play_severe_weather_alert() {
|
||||
if command -v play &>/dev/null; then
|
||||
# Generate alert sound pattern using sox
|
||||
play -nqV0 synth 2 sine 853 sine 960 remix - norm -15 &
|
||||
fi
|
||||
|
||||
# Also display notification if available
|
||||
if command -v notify-send &>/dev/null; then
|
||||
notify-send "Severe Weather Alert" "Severe weather conditions detected for $cityName: $currentConditions" -u critical
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if a value is in array
|
||||
in_array() {
|
||||
local value="$1"
|
||||
shift
|
||||
local array=("$@")
|
||||
|
||||
for item in "${array[@]}"; do
|
||||
if [[ "$item" == "$value" ]]; then
|
||||
return 0 # True, found in array
|
||||
fi
|
||||
done
|
||||
return 1 # False, not found
|
||||
}
|
||||
|
||||
# Function to detect location
|
||||
get_location() {
|
||||
# Only try location detection if we don't already have a city name
|
||||
if [[ "$cityName" == "Detecting..." ]]; then
|
||||
echo "Attempting to detect location via ipinfo.io..."
|
||||
|
||||
# Try to fetch location data
|
||||
local locationData
|
||||
locationData=$(curl -s --connect-timeout 5 "https://ipinfo.io/json" 2>/dev/null)
|
||||
|
||||
if [[ $? -eq 0 && -n "$locationData" && $(echo "$locationData" | jq -e '.city') ]]; then
|
||||
echo "Location data received successfully"
|
||||
cityName=$(echo "$locationData" | jq -r '.city // "Unknown"')
|
||||
local region=$(echo "$locationData" | jq -r '.region // ""')
|
||||
# Add region/state to city name if available
|
||||
[[ -n "$region" ]] && cityName="$cityName, $region"
|
||||
|
||||
# Extract coordinates directly from the "loc" field
|
||||
local loc=$(echo "$locationData" | jq -r '.loc // "0,0"')
|
||||
latitude=$(echo "$loc" | cut -d',' -f1)
|
||||
longitude=$(echo "$loc" | cut -d',' -f2)
|
||||
save_config
|
||||
else
|
||||
cityName="$defaultCity"
|
||||
latitude="$defaultLat"
|
||||
longitude="$defaultLon"
|
||||
save_config
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to fetch weather data
|
||||
fetch_weather_data() {
|
||||
local now=$(date +%s)
|
||||
local elapsedMinutes=$(( (now - weatherLastUpdate) / 60 ))
|
||||
|
||||
# Only fetch if needed
|
||||
if [[ $weatherLastUpdate -eq 0 || $elapsedMinutes -ge $updateInterval ]]; then
|
||||
local url="https://api.open-meteo.com/v1/forecast?latitude=$latitude&longitude=$longitude¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto"
|
||||
local response=$(curl -s --connect-timeout 10 "$url" 2>/dev/null)
|
||||
|
||||
if [[ $? -eq 0 && -n "$response" && $(echo "$response" | jq -e '.current' 2>/dev/null) ]]; then
|
||||
# Update current weather data
|
||||
local tempCelsius=$(echo "$response" | jq -r '.current.temperature_2m // "--"' 2>/dev/null)
|
||||
[[ "$tempCelsius" != "--" && "$tempCelsius" != "null" ]] && currentTemp=$(celsius_to_fahrenheit "$tempCelsius") || currentTemp="--"
|
||||
|
||||
currentHumidity=$(echo "$response" | jq -r '.current.relative_humidity_2m // "--"' 2>/dev/null)
|
||||
[[ "$currentHumidity" == "null" ]] && currentHumidity="--"
|
||||
|
||||
currentWindSpeed=$(echo "$response" | jq -r '.current.wind_speed_10m // "--"' 2>/dev/null)
|
||||
if [[ "$currentWindSpeed" != "--" && "$currentWindSpeed" != "null" ]]; then
|
||||
currentWindSpeedMph=$(kmh_to_mph "$currentWindSpeed")
|
||||
else
|
||||
currentWindSpeed="--"
|
||||
currentWindSpeedMph="--"
|
||||
fi
|
||||
|
||||
local weatherCode=$(echo "$response" | jq -r '.current.weather_code // 0' 2>/dev/null)
|
||||
[[ "$weatherCode" == "null" ]] && weatherCode=0
|
||||
currentConditions="${weatherCodes[$weatherCode]:-Unknown}"
|
||||
|
||||
# Check for severe weather and play alert if needed
|
||||
if in_array "$weatherCode" "${severeWeatherCodes[@]}"; then
|
||||
if [ "$severeWeatherAlerted" -eq 0 ]; then
|
||||
play_severe_weather_alert
|
||||
severeWeatherAlerted=1
|
||||
fi
|
||||
else
|
||||
# Reset alert flag if weather is no longer severe
|
||||
severeWeatherAlerted=0
|
||||
fi
|
||||
|
||||
# Process forecast data (limited to 3 days)
|
||||
if [[ $(echo "$response" | jq -e '.daily' 2>/dev/null) ]]; then
|
||||
for i in {0..2}; do
|
||||
# Process forecast data
|
||||
forecastDates[$i]=$(echo "$response" | jq -r ".daily.time[$i] // \"--\"" 2>/dev/null)
|
||||
[[ "${forecastDates[$i]}" != "--" && "${forecastDates[$i]}" != "null" ]] && \
|
||||
forecastFormattedDates[$i]=$(format_date "${forecastDates[$i]}") || forecastFormattedDates[$i]="--"
|
||||
|
||||
local minTempC=$(echo "$response" | jq -r ".daily.temperature_2m_min[$i] // \"--\"" 2>/dev/null)
|
||||
[[ "$minTempC" != "--" && "$minTempC" != "null" ]] && \
|
||||
forecastMinTemps[$i]=$(celsius_to_fahrenheit "$minTempC") || forecastMinTemps[$i]="--"
|
||||
|
||||
local maxTempC=$(echo "$response" | jq -r ".daily.temperature_2m_max[$i] // \"--\"" 2>/dev/null)
|
||||
[[ "$maxTempC" != "--" && "$maxTempC" != "null" ]] && \
|
||||
forecastMaxTemps[$i]=$(celsius_to_fahrenheit "$maxTempC") || forecastMaxTemps[$i]="--"
|
||||
|
||||
local code=$(echo "$response" | jq -r ".daily.weather_code[$i] // 0" 2>/dev/null)
|
||||
[[ "$code" == "null" ]] && code=0
|
||||
forecastConditions[$i]="${weatherCodes[$code]:-Unknown}"
|
||||
done
|
||||
fi
|
||||
|
||||
# Update timestamp
|
||||
weatherLastUpdate=$(date +%s)
|
||||
save_config
|
||||
else
|
||||
echo "Failed to fetch weather data. Response code: $?"
|
||||
if [[ -n "$response" ]]; then
|
||||
echo "First 100 chars of response: ${response:0:100}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to change location (for settings)
|
||||
change_location() {
|
||||
local newLocation="$1"
|
||||
|
||||
if [[ -n "$newLocation" && "$newLocation" != "$cityName" ]]; then
|
||||
# Try to parse the location using curl to a geocoding service
|
||||
local result=$(curl -s --connect-timeout 10 "https://nominatim.openstreetmap.org/search?q=$newLocation&format=json" 2>/dev/null)
|
||||
|
||||
if [[ -n "$result" && $(echo "$result" | jq -e '.[0]') ]]; then
|
||||
cityName="$newLocation"
|
||||
latitude=$(echo "$result" | jq -r '.[0].lat // "0.0"')
|
||||
longitude=$(echo "$result" | jq -r '.[0].lon // "0.0"')
|
||||
|
||||
# Force weather update
|
||||
weatherLastUpdate=0
|
||||
save_config
|
||||
return 0
|
||||
else
|
||||
yad --title "Location Error" --text="Could not find location: $newLocation" --button=gtk-ok
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Display weather information in a text-info dialog
|
||||
display_weather() {
|
||||
local lastUpdateText="Never updated"
|
||||
[[ "$weatherLastUpdate" -gt 0 ]] && lastUpdateText="Last updated: $(time_diff "$weatherLastUpdate")"
|
||||
|
||||
# Create the weather information text with proper line breaks
|
||||
weatherInfoText="Weather for $cityName
|
||||
$lastUpdateText
|
||||
|
||||
Current Conditions
|
||||
Temperature: ${currentTemp}° F
|
||||
Conditions: $currentConditions
|
||||
Humidity: ${currentHumidity}%
|
||||
Wind Speed: ${currentWindSpeedMph} mph
|
||||
|
||||
3-Day Forecast
|
||||
────────────────────────────────────
|
||||
${forecastFormattedDates[0]}
|
||||
Temp: ${forecastMinTemps[0]}° to ${forecastMaxTemps[0]}° F
|
||||
Conditions: ${forecastConditions[0]}
|
||||
────────────────────────────────────
|
||||
${forecastFormattedDates[1]}
|
||||
Temp: ${forecastMinTemps[1]}° to ${forecastMaxTemps[1]}° F
|
||||
Conditions: ${forecastConditions[1]}
|
||||
────────────────────────────────────
|
||||
${forecastFormattedDates[2]}
|
||||
Temp: ${forecastMinTemps[2]}° to ${forecastMaxTemps[2]}° F
|
||||
Conditions: ${forecastConditions[2]}
|
||||
|
||||
End of text. Press Control+Home to return to the beginning."
|
||||
|
||||
# Display in text-info dialog for screen reader accessibility
|
||||
echo "$weatherInfoText" | yad --pname=I38Weather \
|
||||
--title="I38 Weather Monitor" \
|
||||
--text-info \
|
||||
--show-cursor \
|
||||
--width=500 \
|
||||
--height=600 \
|
||||
--center \
|
||||
--button="Settings:$settingsBtn" \
|
||||
--button="Refresh:$refreshBtn" \
|
||||
--button="Close:$quitBtn"
|
||||
|
||||
return $?
|
||||
}
|
||||
|
||||
# Function to display settings dialog
|
||||
display_settings() {
|
||||
local ret=$(yad --pname=I38WeatherSettings \
|
||||
--title="I38 Weather Settings" \
|
||||
--form \
|
||||
--width=400 \
|
||||
--center \
|
||||
--field="Location:":TEXT "$cityName" \
|
||||
--field="Current Coordinates:":LBL "Lat: $latitude, Lon: $longitude" \
|
||||
--field="Temperature Unit:":CB "F!C" \
|
||||
--field="Update Interval (minutes):":NUM "$updateInterval!5..120!5" \
|
||||
--button="Cancel:1" \
|
||||
--button="Save:0")
|
||||
|
||||
local saveResult=$?
|
||||
|
||||
if [[ $saveResult -eq 0 && -n "$ret" ]]; then
|
||||
local newLocation=$(echo "$ret" | cut -d"|" -f1)
|
||||
local newUnit=$(echo "$ret" | cut -d"|" -f3)
|
||||
local newInterval=$(echo "$ret" | cut -d"|" -f4)
|
||||
|
||||
# Apply any changes
|
||||
[[ -n "$newLocation" && "$newLocation" != "$cityName" ]] && change_location "$newLocation"
|
||||
[[ -n "$newUnit" && "$newUnit" != "$tempUnit" ]] && tempUnit="$newUnit" && save_config
|
||||
[[ -n "$newInterval" && "$newInterval" != "$updateInterval" ]] && updateInterval="$newInterval" && save_config
|
||||
fi
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while : ; do
|
||||
get_location
|
||||
fetch_weather_data
|
||||
|
||||
# Display weather using the text-info widget
|
||||
display_weather
|
||||
ret=$?
|
||||
|
||||
# Handle button actions
|
||||
case $ret in
|
||||
$refreshBtn)
|
||||
# Force a weather update
|
||||
weatherLastUpdate=0
|
||||
continue
|
||||
;;
|
||||
$settingsBtn)
|
||||
# Display settings dialog
|
||||
display_settings
|
||||
continue
|
||||
;;
|
||||
$quitBtn|252)
|
||||
# Quit button or window closed
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
@ -1,444 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# 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/>.
|
||||
|
||||
|
||||
import gi
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, Pango
|
||||
|
||||
DEFAULT_DOWNLOAD_DIR = os.path.expanduser("~/Downloads")
|
||||
|
||||
class WormholeGUI(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Magic Wormhole GUI")
|
||||
self.set_border_width(10)
|
||||
self.set_default_size(500, 400)
|
||||
|
||||
self.download_dir = DEFAULT_DOWNLOAD_DIR
|
||||
self.notebook = Gtk.Notebook()
|
||||
self.add(self.notebook)
|
||||
|
||||
self.init_main_tab()
|
||||
self.init_settings_tab()
|
||||
|
||||
# Escape key closes app
|
||||
self.connect("key-press-event", self.on_key_press)
|
||||
|
||||
def init_main_tab(self):
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self.notebook.append_page(main_box, Gtk.Label(label="Main"))
|
||||
|
||||
button_box = Gtk.Box(spacing=10)
|
||||
self.send_button = Gtk.Button(label="Send")
|
||||
self.send_button.connect("clicked", self.on_send_clicked)
|
||||
button_box.pack_start(self.send_button, True, True, 0)
|
||||
|
||||
self.receive_button = Gtk.Button(label="Receive")
|
||||
self.receive_button.connect("clicked", self.on_receive_clicked)
|
||||
button_box.pack_start(self.receive_button, True, True, 0)
|
||||
|
||||
main_box.pack_start(button_box, False, False, 0)
|
||||
|
||||
# Add a frame for the code display
|
||||
code_frame = Gtk.Frame(label="Wormhole Code")
|
||||
main_box.pack_start(code_frame, False, False, 5)
|
||||
|
||||
code_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
||||
code_box.set_border_width(5)
|
||||
code_frame.add(code_box)
|
||||
|
||||
self.code_display = Gtk.Entry()
|
||||
self.code_display.set_editable(False)
|
||||
code_box.pack_start(self.code_display, False, False, 0)
|
||||
|
||||
# Add a frame for progress output
|
||||
progress_frame = Gtk.Frame(label="Transfer Progress")
|
||||
main_box.pack_start(progress_frame, True, True, 5)
|
||||
|
||||
# Add a scrolled window for the progress text
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
progress_frame.add(scrolled_window)
|
||||
|
||||
# Add a text view for progress output
|
||||
self.progress_text = Gtk.TextView()
|
||||
self.progress_text.set_editable(False)
|
||||
self.progress_text.set_cursor_visible(False)
|
||||
self.progress_text.set_wrap_mode(Gtk.WrapMode.WORD)
|
||||
self.progress_text.override_font(Pango.FontDescription("Monospace 10"))
|
||||
self.progress_buffer = self.progress_text.get_buffer()
|
||||
scrolled_window.add(self.progress_text)
|
||||
|
||||
# Add action buttons
|
||||
action_box = Gtk.Box(spacing=10)
|
||||
self.copy_button = Gtk.Button(label="Copy Code")
|
||||
self.copy_button.connect("clicked", self.copy_code)
|
||||
action_box.pack_start(self.copy_button, True, True, 0)
|
||||
|
||||
self.cancel_button = Gtk.Button(label="Cancel")
|
||||
self.cancel_button.connect("clicked", self.cancel_transfer)
|
||||
action_box.pack_start(self.cancel_button, True, True, 0)
|
||||
|
||||
main_box.pack_start(action_box, False, False, 0)
|
||||
|
||||
def init_settings_tab(self):
|
||||
settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
settings_box.set_border_width(10)
|
||||
self.notebook.append_page(settings_box, Gtk.Label(label="Settings"))
|
||||
|
||||
# Create a frame for download settings
|
||||
download_frame = Gtk.Frame(label="Download Location")
|
||||
settings_box.pack_start(download_frame, False, False, 0)
|
||||
|
||||
# Add a container for the frame content
|
||||
download_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
download_box.set_border_width(10)
|
||||
download_frame.add(download_box)
|
||||
|
||||
# Add a description label
|
||||
description = Gtk.Label(label="Files will be saved to this directory:")
|
||||
description.set_xalign(0) # Align to left
|
||||
download_box.pack_start(description, False, False, 0)
|
||||
|
||||
# Add a directory selector
|
||||
dir_box = Gtk.Box(spacing=5)
|
||||
download_box.pack_start(dir_box, False, False, 5)
|
||||
|
||||
# Add an entry to show the current path
|
||||
self.dir_entry = Gtk.Entry()
|
||||
self.dir_entry.set_text(self.download_dir)
|
||||
dir_box.pack_start(self.dir_entry, True, True, 0)
|
||||
|
||||
# Add a browse button
|
||||
browse_button = Gtk.Button(label="Browse...")
|
||||
browse_button.connect("clicked", self.on_browse_clicked)
|
||||
dir_box.pack_start(browse_button, False, False, 0)
|
||||
|
||||
# Add a save button
|
||||
save_button = Gtk.Button(label="Save Settings")
|
||||
save_button.connect("clicked", self.on_save_settings)
|
||||
download_box.pack_start(save_button, False, False, 5)
|
||||
|
||||
def on_key_press(self, widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
# Check if a transfer is currently in progress
|
||||
if hasattr(self, 'current_process') and self.current_process and self.current_process.poll() is None:
|
||||
# Show a dialog indicating transfer is in progress
|
||||
dialog = Gtk.MessageDialog(
|
||||
parent=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text="Transfer in Progress"
|
||||
)
|
||||
dialog.format_secondary_text("Please wait for the transfer to complete or cancel it before closing.")
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
return True
|
||||
else:
|
||||
# No transfer in progress, confirm quit
|
||||
dialog = Gtk.MessageDialog(
|
||||
parent=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.YES_NO,
|
||||
text="Quit Application"
|
||||
)
|
||||
dialog.format_secondary_text("Are you sure you want to quit?")
|
||||
response = dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
if response == Gtk.ResponseType.YES:
|
||||
Gtk.main_quit()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_browse_clicked(self, button):
|
||||
"""Browse for a directory"""
|
||||
dialog = Gtk.FileChooserDialog(
|
||||
title="Select Download Directory",
|
||||
parent=self,
|
||||
action=Gtk.FileChooserAction.SELECT_FOLDER,
|
||||
buttons=(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OPEN, Gtk.ResponseType.OK
|
||||
)
|
||||
)
|
||||
|
||||
# Set the current folder to the current download directory
|
||||
if os.path.exists(self.download_dir):
|
||||
dialog.set_current_folder(self.download_dir)
|
||||
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
self.dir_entry.set_text(dialog.get_filename())
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def on_save_settings(self, button):
|
||||
"""Save the settings"""
|
||||
new_dir = self.dir_entry.get_text().strip()
|
||||
|
||||
# Handle ~ in path
|
||||
if new_dir.startswith("~"):
|
||||
new_dir = os.path.expanduser(new_dir)
|
||||
|
||||
# Validate the directory
|
||||
if not os.path.isdir(new_dir):
|
||||
try:
|
||||
os.makedirs(new_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
self.show_error(f"Could not create directory: {e}")
|
||||
return
|
||||
|
||||
# Update the directory
|
||||
self.download_dir = new_dir
|
||||
|
||||
# Show confirmation
|
||||
self.show_info("Settings saved successfully.")
|
||||
|
||||
def on_download_dir_changed(self, widget):
|
||||
self.download_dir = widget.get_filename()
|
||||
|
||||
def on_send_clicked(self, widget):
|
||||
chooser = Gtk.FileChooserDialog(
|
||||
title="Select File or Folder", parent=self,
|
||||
action=Gtk.FileChooserAction.OPEN,
|
||||
buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
|
||||
)
|
||||
chooser.set_select_multiple(False)
|
||||
chooser.set_local_only(True)
|
||||
chooser.set_modal(True)
|
||||
chooser.set_property("show-hidden", False)
|
||||
chooser.connect("key-press-event", self.on_key_press)
|
||||
|
||||
if chooser.run() == Gtk.ResponseType.OK:
|
||||
path = chooser.get_filename()
|
||||
chooser.destroy()
|
||||
self.send_file(path)
|
||||
else:
|
||||
chooser.destroy()
|
||||
|
||||
def send_file(self, path):
|
||||
self.code_display.set_text("Sending...")
|
||||
self.clear_progress()
|
||||
self.update_progress(f"Starting to send: {os.path.basename(path)}\n")
|
||||
|
||||
# Initialize current_process attribute
|
||||
self.current_process = None
|
||||
|
||||
def send():
|
||||
self.current_process = subprocess.Popen(
|
||||
["wormhole", "send", path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True
|
||||
)
|
||||
for line in self.current_process.stdout:
|
||||
print("SEND OUTPUT:", line.strip()) # Debug info
|
||||
|
||||
# Update the progress display
|
||||
GLib.idle_add(self.update_progress, line)
|
||||
|
||||
if "Wormhole code is:" in line:
|
||||
code = line.strip().split(":", 1)[-1].strip()
|
||||
GLib.idle_add(self.code_display.set_text, code)
|
||||
self.current_process.stdout.close()
|
||||
self.current_process.wait()
|
||||
|
||||
# Clear the current_process when done
|
||||
GLib.idle_add(self.clear_current_process)
|
||||
|
||||
threading.Thread(target=send, daemon=True).start()
|
||||
|
||||
def on_receive_clicked(self, widget):
|
||||
dialog = Gtk.Dialog(
|
||||
title="Enter Wormhole Code",
|
||||
parent=self,
|
||||
flags=0
|
||||
)
|
||||
dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OK, Gtk.ResponseType.OK
|
||||
)
|
||||
dialog.connect("key-press-event", self.on_key_press)
|
||||
|
||||
# Make OK button the default
|
||||
ok_button = dialog.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
ok_button.set_can_default(True)
|
||||
ok_button.grab_default()
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_activates_default(True)
|
||||
entry.grab_focus()
|
||||
|
||||
box = dialog.get_content_area()
|
||||
box.set_border_width(10)
|
||||
box.set_spacing(10)
|
||||
box.add(Gtk.Label(label="Enter the wormhole code:"))
|
||||
box.add(entry)
|
||||
box.show_all()
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
code = entry.get_text()
|
||||
dialog.destroy()
|
||||
self.receive_file(code)
|
||||
else:
|
||||
dialog.destroy()
|
||||
|
||||
def receive_file(self, code):
|
||||
self.code_display.set_text("Receiving...")
|
||||
self.clear_progress()
|
||||
self.update_progress(f"Starting to receive with code: {code}\n")
|
||||
|
||||
# Initialize current_process attribute
|
||||
self.current_process = None
|
||||
|
||||
def receive():
|
||||
# Save current directory
|
||||
original_dir = os.getcwd()
|
||||
|
||||
try:
|
||||
# Create download directory if it doesn't exist
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
# Change to download directory before starting the process
|
||||
os.chdir(self.download_dir)
|
||||
|
||||
self.update_progress(f"Downloading to: {self.download_dir}\n")
|
||||
|
||||
# Start the wormhole receive process
|
||||
self.current_process = subprocess.Popen(
|
||||
["wormhole", "receive", "--accept-file"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Send the code to the process
|
||||
self.current_process.stdin.write(code + "\n")
|
||||
self.current_process.stdin.flush()
|
||||
self.current_process.stdin.close()
|
||||
|
||||
for line in self.current_process.stdout:
|
||||
print("RECEIVE OUTPUT:", line.strip()) # Debug info
|
||||
|
||||
# Update the progress display
|
||||
GLib.idle_add(self.update_progress, line)
|
||||
|
||||
# Handle questions about accepting the file
|
||||
if "ok? (y/N):" in line:
|
||||
# Auto-accept the file
|
||||
self.current_process.stdin = open("/dev/stdin", "w")
|
||||
self.current_process.stdin.write("y\n")
|
||||
self.current_process.stdin.flush()
|
||||
self.current_process.stdin.close()
|
||||
|
||||
if "Received file" in line or "File received" in line:
|
||||
GLib.idle_add(self.code_display.set_text, "File received.")
|
||||
|
||||
self.current_process.stdout.close()
|
||||
self.current_process.wait()
|
||||
|
||||
# Add final status message
|
||||
GLib.idle_add(self.update_progress, f"\nFile saved to: {self.download_dir}\n")
|
||||
|
||||
except Exception as e:
|
||||
GLib.idle_add(self.update_progress, f"Error: {e}\n")
|
||||
|
||||
finally:
|
||||
# Change back to original directory
|
||||
os.chdir(original_dir)
|
||||
|
||||
# Clear the current_process when done
|
||||
GLib.idle_add(self.clear_current_process)
|
||||
|
||||
threading.Thread(target=receive, daemon=True).start()
|
||||
|
||||
def copy_code(self, widget):
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(self.code_display.get_text(), -1)
|
||||
self.update_progress("Code copied to clipboard.\n")
|
||||
|
||||
def cancel_transfer(self, widget):
|
||||
if hasattr(self, 'current_process') and self.current_process and self.current_process.poll() is None:
|
||||
try:
|
||||
self.current_process.terminate()
|
||||
self.update_progress("Transfer process terminated.\n")
|
||||
except Exception as e:
|
||||
self.update_progress(f"Error canceling transfer: {e}\n")
|
||||
|
||||
self.code_display.set_text("Transfer canceled.")
|
||||
self.update_progress("Transfer canceled by user.\n")
|
||||
self.clear_current_process()
|
||||
|
||||
def update_progress(self, text):
|
||||
"""Update the progress text view"""
|
||||
end = self.progress_buffer.get_end_iter()
|
||||
self.progress_buffer.insert(end, text)
|
||||
|
||||
# Scroll to the end
|
||||
self.progress_text.scroll_to_iter(end, 0.0, False, 0.0, 0.0)
|
||||
|
||||
return False # Required for GLib.idle_add
|
||||
|
||||
def clear_progress(self):
|
||||
"""Clear the progress text view"""
|
||||
self.progress_buffer.set_text("")
|
||||
|
||||
def show_error(self, message):
|
||||
"""Show an error dialog"""
|
||||
dialog = Gtk.MessageDialog(
|
||||
parent=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text="Error"
|
||||
)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def show_info(self, message):
|
||||
"""Show an info dialog"""
|
||||
dialog = Gtk.MessageDialog(
|
||||
parent=self,
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text="Information"
|
||||
)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def clear_current_process(self):
|
||||
"""Clear the current process reference"""
|
||||
self.current_process = None
|
||||
return False # Required for GLib.idle_add
|
||||
|
||||
def main():
|
||||
app = WormholeGUI()
|
||||
app.connect("destroy", Gtk.main_quit)
|
||||
app.show_all()
|
||||
Gtk.main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
Loading…
x
Reference in New Issue
Block a user