Compare commits

..

33 Commits
v2.2 ... master

Author SHA1 Message Date
Storm Dragon
7ce6e3d10b Updated the readme. Added power options to the panel mode. It relies on lxsession being installed and is optional, so not necessary for those who prefer to shutdown from the command line. 2025-04-08 19:20:39 -04:00
Storm Dragon
002c5ce1d7 Totally revamped the alt+f1 menu system. I think this is much better than the old version, and it also fixes a bug where the old menu didn't close when something was selected. It is experimental, and can be removed if people prefer the old menu, just let me know. 2025-04-08 06:02:15 -04:00
Storm Dragon
70f3cc749e Bluetooth added using blueman if installed. Updated the first run documentation. 2025-04-08 04:36:45 -04:00
Storm Dragon
100d25773c Experimental magic-wormhole GUI interface added. 2025-04-08 03:56:06 -04:00
Storm Dragon
ebe5dcf404 Remind moved to the panel, Ctrl+Alt+tab, r. 2025-04-07 15:18:34 -04:00
Storm Dragon
f41741866a A simple notes system added for the panel, I38 notes. 2025-04-07 15:08:11 -04:00
Storm Dragon
dc8f832840 Reworked the panel. Now it's a mode, like ratpoison mode. New items include panel mode w for weather, and panel mode s for system information. More coming if this turns out to work well. New dependencies added to readme. 2025-04-06 19:40:55 -04:00
Storm Dragon
8064eaa6e2 Added vivaldi as browser option. 2025-03-19 14:12:19 -04:00
Storm Dragon
416fdc7be4 Updated the README. 2025-03-09 23:21:05 -04:00
Storm Dragon
72721abb22 Toggle screen reader is now a gui, so you can see which one is currently running and switch if you want. 2025-03-09 23:00:32 -04:00
Storm Dragon
2f2eefddce Created a fake panel. It's technically an 11th workspace, but programs that need to be opened but don't often need interaction are automatically sent there. Use control+alt+tab to access the panel. 2025-03-04 19:59:51 -05:00
Storm Dragon
5229b3b18a Introduction to I38 help file loads on first start after config generation. 2025-03-03 22:49:46 -05:00
Storm Dragon
d66d60ee49 Added ability to toggle between Cthulhu and Orca with RP Mode - shift+T. 2024-12-20 06:47:55 -05:00
Storm Dragon
2990660e61 Added chrome's accessibility flag for the apps that still need it. 2024-12-03 07:50:56 -05:00
Storm Dragon
ca1b76302d Fixed a typo. 2024-10-23 06:09:21 -04:00
Storm Dragon
93127aeca3 Add .gitignore for python cache files. 2024-10-23 06:06:29 -04:00
Storm Dragon
d393677f00 Merge branch 'master' of git.stormux.org:storm/I38 2024-10-23 05:44:07 -04:00
Storm Dragon
9b8932aca6 Added the grayscale flag to mod+F5 command. This should speed it up quite a bit and make it more accurate for games. 2024-10-23 05:43:49 -04:00
13ba2b6dee Support the Flatpak path.
Now you can launch your Flatpaks from the I38 menu.
2024-09-25 17:32:52 -04:00
732c2b0240 Sway: when reloading the configuration, don't run the sound script always.
I don't think Sway's IPC works the same as it does under I38. It must
be running a different one per each reload, but not sure.

Regardless, you won't get extra sounds on reload now.
2024-09-25 15:57:46 -04:00
0eb4e494ff Adda a note about the custom version of xfce4-notifyd.
I've been using it for weeks now, but didn't think to check the README.
2024-09-23 19:19:40 -04:00
ce22b8347c Fix for reloading and restarting the configuration.
In Sway, you say "command" instead of "run_command" Additionally, take
out the restart since that's not available in Sway.
2024-09-21 16:10:02 -04:00
62c2c06904 Rename script. 2024-09-20 14:00:27 -04:00
12649c0f60 Directory local variables for Emacs contributors.
Sets some handy things for sending patches, might be useful for other contributors.
2024-09-20 14:00:27 -04:00
a63091b320 Put the sensible-terminal script into i38 itself.
There is no such --sensible-terminal flag to Sway, and given it's just
a script we may as well put it in our codebase instead.
2024-09-20 14:00:27 -04:00
faf7ca45e0 When using Sway, include the default distribution files for configuration.
Useful for things such as importing the proper dBus environment.
2024-09-20 14:00:27 -04:00
Storm Dragon
3769428fdf Added path to ps5 contoller to game controller battery script. 2024-09-17 18:08:36 -04:00
Storm Dragon
f2ab61b389 Added optional support for x11bell: https://github.com/jovanlanik/x11bell 2024-09-11 02:19:37 -04:00
Storm Dragon
8c29d40616 Found a sound missing -V0 flag. 2024-08-31 14:26:13 -04:00
Storm Dragon
31c920de8a OCR through speech-dispatcher added. Interrupt speech-dispatcher key added. 2024-08-13 00:37:39 -04:00
Storm Dragon
6cf1aac9de Merge branch 'master' of git.stormux.org:storm/I38 2024-07-13 18:55:55 -04:00
Storm Dragon
3f7f7d7b21 Fixed very chatty error messages on some systems with alsa. 2024-07-13 18:55:32 -04:00
Jeremiah Ticket
2c763ce6ea Fixed links to licenses. 2024-07-12 19:08:37 -08:00
17 changed files with 3045 additions and 120 deletions

5
.dir-locals.el Normal file
View File

@ -0,0 +1,5 @@
;;; 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 Normal file
View File

@ -0,0 +1,4 @@
**/__pycache__/
*.pyc
*.pyo
*.pyd

325
I38.md Normal file
View File

@ -0,0 +1,325 @@
# 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*

View File

@ -3,7 +3,7 @@
Accessibility setup script for the i3 window manager. Accessibility setup script for the i3 window manager.
## i38.sh ## i38.sh
Released under the terms of the GPL License Version 3: http://www.wtfpl.net Released under the terms of the GPL License Version 3: https://www.gnu.org/licenses/
This is a Stormux project: https://stormux.org This is a Stormux project: https://stormux.org
@ -14,17 +14,21 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht
## Requirements ## Requirements
- 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. - 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. - bc: For the information panel.
- clipster: clipboard manager - clipster: clipboard manager
- dex: [optional] Alternative method for auto starting applications.
- i3-wm: The i3 window manager.
- jq: for getting the current workspace - 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 - 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 - 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. - ocrdesktop: For getting contents of the current window with OCR.
- pamixer: for the mute-unmute script - pamixer: for the mute-unmute script
- pandoc or markdown: To generate html files.
- pcmanfm: [optional] Graphical file manager.
- playerctl: music controls - playerctl: music controls
- python-gobject: for applications menu. - python-gobject: for applications menu.
- python-i3ipc: for sounds etc. - python-i3ipc: for sounds etc.
@ -32,18 +36,20 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht
- sox: for sounds. - sox: for sounds.
- transfersh: [optional] for file sharing GUI - transfersh: [optional] for file sharing GUI
- udiskie: [optional] for automatically mounting removable storage - udiskie: [optional] for automatically mounting removable storage
- xclip: Clipboard support - x11bell: [optional] Bell support if you do not have a PC speaker. Available from https://github.com/jovanlanik/x11bell
- xbacklight: [optional] for screen brightness adjustment - xbacklight: [optional] for screen brightness adjustment
- 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>)
- xorg-setxkbmap: [optional] for multiple keyboard layouts - xorg-setxkbmap: [optional] for multiple keyboard layouts
- yad: For screen reader accessible dialogs - yad: For screen reader accessible dialogs
I38 will try to detect your browser, file manager, and 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. 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.
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. 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. 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 gsettings set org.gnome.desktop.sound theme-name name
@ -61,7 +67,7 @@ To configure with a config file, edit or create ~/.config/gtk-3.0/settings.ini
gtk-sound-theme-name=name gtk-sound-theme-name=name
gtk-modules=canberra-gtk-module gtk-modules=canberra-gtk-module
You can apply the same configuration to GTK2 appss. Create or edit ~/.gtkrc-2.0 You can apply the same configuration to GTK2 apps. Create or edit ~/.gtkrc-2.0
gtk-enable-event-sounds=1 gtk-enable-event-sounds=1
gtk-enable-input-feedback-sounds=1 gtk-enable-input-feedback-sounds=1
@ -79,11 +85,16 @@ You can apply the same configuration to GTK2 appss. Create or edit ~/.gtkrc-2.0
## Ratpoison Mode ## 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. 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 ## 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. 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.

194
i38.sh
View File

@ -11,10 +11,11 @@
# PARTICULAR PURPOSE. See the GNU General Public License for more details. # 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/>. # 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" i3Path="${XDG_CONFIG_HOME:-$HOME/.config}/i3"
i3msg="i3-msg" i3msg="i3-msg"
sensibleTerminal="i3-sensible-terminal"
# Dialog accessibility # Dialog accessibility
export DIALOGOPTS='--no-lines --visit-items' export DIALOGOPTS='--no-lines --visit-items'
@ -32,6 +33,11 @@ if [[ -n "${missing}" ]]; then
echo "${missing[*]}" echo "${missing[*]}"
exit 1 exit 1
fi 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() { keyboard_menu() {
keyboardMenu=("us" "English (US)" keyboardMenu=("us" "English (US)"
@ -174,7 +180,7 @@ yesno() {
help() { help() {
echo "${0##*/}" echo "${0##*/}"
echo "Released under the terms of the WTFPL License: http://www.wtfpl.net" echo "Released under the terms of the GPL V3 License: https://www.gnu.org/licenses/"
echo -e "This is a Stormux project: https://stormux.org\n" echo -e "This is a Stormux project: https://stormux.org\n"
echo -e "Usage:\n" echo -e "Usage:\n"
echo "With no arguments, create the i3 configuration." echo "With no arguments, create the i3 configuration."
@ -227,6 +233,7 @@ fi
cat << 'EOF' > ~/.xprofile cat << 'EOF' > ~/.xprofile
# Accessibility variables # Accessibility variables
export ACCESSIBILITY_ENABLED=1 export ACCESSIBILITY_ENABLED=1
export CHROME_FLAGS="--force-renderer-accessibility"
export GTK_MODULES=gail:atk-bridge export GTK_MODULES=gail:atk-bridge
export GNOME_ACCESSIBILITY=1 export GNOME_ACCESSIBILITY=1
export QT_ACCESSIBILITY=1 export QT_ACCESSIBILITY=1
@ -258,9 +265,10 @@ while getopts "${args}" i ; do
case "$i" in case "$i" in
h) help;; h) help;;
s) s)
swaySystemIncludesPath="/etc/sway/config.d"
usingSway=0
i3msg="swaymsg" i3msg="swaymsg"
i3Path="${XDG_CONFIG_HOME:-$HOME/.config}/sway" i3Path="${XDG_CONFIG_HOME:-$HOME/.config}/sway"
sensibleTerminal="sway --sensible-terminal"
;; ;;
u) update_scripts;; u) update_scripts;;
x) write_xinitrc ;& x) write_xinitrc ;&
@ -268,6 +276,10 @@ while getopts "${args}" i ; do
esac esac
done done
# Mod1 alt
# Mod4 super
# Mod2 and Mod3 not usually defined.
# Configuration questions # Configuration questions
export i3Mode=$(yesno "Would you like to use ratpoison mode? This behaves more like strychnine, with an escape key followed by keybindings. (Recommended)") 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 # Prevent setting ratpoison mode key to the same as default mode key
@ -293,6 +305,23 @@ if [[ $(yesno "Do you want to use multiple keyboard layouts?") -eq 0 ]]; then
fi fi
# Volume jump # Volume jump
volumeJump=$(rangebox "How much should pressing the volume keys change the volume?" 1 15 5) 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 # Email client
unset programList unset programList
for i in betterbird evolution thunderbird ; do for i in betterbird evolution thunderbird ; do
@ -309,10 +338,10 @@ if [ "$programList" != "${programList// /}" ]; then
else else
emailClient="${programList/#-/}" emailClient="${programList/#-/}"
fi fi
emailClient="$(command -v $emailClient)" export emailClient="$(command -v $emailClient)"
# Web browser # Web browser
unset programList 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 ; 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 vivaldi ; do
if command -v ${i/#-/} &> /dev/null ; then if command -v ${i/#-/} &> /dev/null ; then
if [ -n "$programList" ]; then if [ -n "$programList" ]; then
programList="$programList $i" programList="$programList $i"
@ -326,7 +355,7 @@ if [ "$programList" != "${programList// /}" ]; then
else else
webBrowser="${programList/#-/}" webBrowser="${programList/#-/}"
fi fi
webBrowser="$(command -v $webBrowser)" export webBrowser="$(command -v $webBrowser)"
# Text editor # Text editor
unset programList unset programList
for i in emacs geany gedit kate kwrite l3afpad leafpad libreoffice mousepad pluma ; do for i in emacs geany gedit kate kwrite l3afpad leafpad libreoffice mousepad pluma ; do
@ -343,7 +372,7 @@ textEditor="$(menulist "Text editor:" $programList)"
else else
textEditor="${programList/#-/}" textEditor="${programList/#-/}"
fi fi
textEditor="$(command -v $textEditor)" export textEditor="$(command -v $textEditor)"
# File browser # File browser
# Configure file browser # Configure file browser
unset programList unset programList
@ -361,7 +390,7 @@ if [ "$programList" != "${programList// /}" ]; then
else else
fileBrowser="${programList/#-/}" fileBrowser="${programList/#-/}"
fi fi
fileBrowser="$(command -v $fileBrowser)" export fileBrowser="$(command -v $fileBrowser)"
# Auto mount removable media # Auto mount removable media
udiskie=1 udiskie=1
if command -v udiskie &> /dev/null ; then if command -v udiskie &> /dev/null ; then
@ -373,14 +402,14 @@ if command -v dex &> /dev/null ; then
export dex=$(yesno "Would you like to autostart applications with dex?") export dex=$(yesno "Would you like to autostart applications with dex?")
fi fi
if [[ $dex -eq 0 ]]; then if [[ $dex -eq 0 ]]; then
dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c $(command -v orca) dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c $(command -v $screenReader)
fi fi
if command -v acpi &> /dev/null ; then if command -v acpi &> /dev/null ; then
batteryAlert=1 batteryAlert=1
batteryAlert=$(yesno "Do you want low battery notifications?") batteryAlert=$(yesno "Do you want low battery notifications?")
fi fi
brlapi=1 brlapi=1
brlapi=$(yesno "Do you want to use a braille display with Orca?") brlapi=$(yesno "Do you want to use a braille display with ${screenReader##*/}?")
sounds=1 sounds=1
sounds=$(yesno "Do you want window event sounds?") sounds=$(yesno "Do you want window event sounds?")
# Play Login Sound # Play Login Sound
@ -402,8 +431,18 @@ cp -rv scripts/ "${i3Path}/" | dialog --backtitle "I38" --progressbox "Moving sc
cat << EOF > ${i3Path}/config cat << EOF > ${i3Path}/config
# Generated by I38 (${0##*/}) https://git.stormux.org/storm/I38 # Generated by I38 (${0##*/}) https://git.stormux.org/storm/I38
# $(date '+%A, %B %d, %Y at %I:%M%p') # $(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) # i3 config file (v4)
# #
# Please see https://i3wm.org/docs/userguide.html for a complete reference! # Please see https://i3wm.org/docs/userguide.html for a complete reference!
@ -443,7 +482,7 @@ bindsym \$mod+Control+Delete exec --no-startup-id sgtk-bar
# Use pactl to adjust volume in PulseAudio. # 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+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+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}/scripts/mute-unmute.sh bindsym \$mod+XF86AudioMute exec --no-startup-id ${i3Path}/scrip/ts/mute-unmute.sh
# Music player controls # Music player controls
# Requires playerctl. # Requires playerctl.
@ -457,7 +496,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 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 # start a terminal
bindsym \$mod+Return exec $sensibleTerminal bindsym \$mod+Return exec ${i3Path}/scripts/i3-sensible-terminal.sh
# kill focused window # kill focused window
bindsym \$mod+F4 kill bindsym \$mod+F4 kill
@ -533,11 +572,56 @@ bindsym $mod+Shift+BackSpace mode "default"
EOF 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. # Multiple keyboard layouts if requested.
if [[ ${#kbd[@]} -gt 1 ]]; then if [[ ${#kbd[@]} -gt 1 ]]; then
echo "bindsym Mod4+space exec ${i3Path}/scripts/keyboard.sh cycle ${kbd[@]}" >> ${i3Path}/config echo "bindsym Mod4+space exec ${i3Path}/scripts/keyboard.sh cycle ${kbd[@]}" >> ${i3Path}/config
fi 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. # Create ratpoison mode if requested.
if [[ -n "${escapeKey}" ]]; then if [[ -n "${escapeKey}" ]]; then
cat << EOF >> ${i3Path}/config cat << EOF >> ${i3Path}/config
@ -563,10 +647,6 @@ $(if command -v mumble &> /dev/null ; then
echo "# Mumble bound to m" echo "# Mumble bound to m"
echo "bindsym m exec $(command -v mumble), mode \"default\"" echo "bindsym m exec $(command -v mumble), mode \"default\""
fi) 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 $(if command -v ocrdesktop &> /dev/null ; then
echo "# OCR desktop bound to print screen alternative \$mod+r" echo "# OCR desktop bound to print screen alternative \$mod+r"
echo "bindsym Print exec $(command -v ocrdesktop) -b, mode \"default\"" echo "bindsym Print exec $(command -v ocrdesktop) -b, mode \"default\""
@ -601,15 +681,25 @@ bindsym Mod1+Shift+u exec --no-startup-id play -qV0 "| sox -np synth 0.03 sin 20
#Check battery status #Check battery status
bindsym Mod1+b exec --no-startup-id ${i3Path}/scripts/battery_status.sh, mode "default" bindsym Mod1+b exec --no-startup-id ${i3Path}/scripts/battery_status.sh, mode "default"
#Check controller battery status #Check controller battery status
bindsym g exec ${i3Path}/scripts/game_controler.sh -s, mode "default" bindsym g exec ${i3Path}/scripts/game_controller.sh -s, mode "default"
# Get a list of windows in the current workspace # Get a list of windows in the current workspace
bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default" bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default"
# Restart orca # Restart Cthulhu
bindsym Shift+c exec $(command -v cthulhu) --replace, mode "default"
# Restart Orca
bindsym Shift+o exec $(command -v orca) --replace, mode "default" bindsym Shift+o exec $(command -v orca) --replace, mode "default"
# reload the configuration file # Toggle screen reader
bindsym Control+semicolon exec bash -c '$i3msg -t run_command reload && spd-say -P important -Cw "I38 Configuration reloaded."', mode "default" bindsym Shift+t exec ${i3Path}/scripts/toggle_screenreader.sh, mode "default"
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) $(if [[ $usingSway -eq 0 ]]; then
bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default" 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)
# Run dialog with exclamation # Run dialog with exclamation
bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default" bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default"
# exit i3 (logs you out of your X session) # exit i3 (logs you out of your X session)
@ -627,8 +717,12 @@ fi
cat << EOF >> ${i3Path}/config cat << EOF >> ${i3Path}/config
# Auto start section # Auto start section
$(if [[ $sounds -eq 0 ]]; then $(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" echo "exec_always --no-startup-id ${i3Path}/scripts/sound.py"
fi fi
fi
if [[ $loginSound -eq 0 ]]; then if [[ $loginSound -eq 0 ]]; then
echo 'exec --no-startup-id canberra-gtk-play -i desktop-login' echo 'exec --no-startup-id canberra-gtk-play -i desktop-login'
fi fi
@ -660,13 +754,63 @@ if [[ $dex -eq 0 ]]; then
echo 'exec --no-startup-id dex --autostart --environment i3' echo 'exec --no-startup-id dex --autostart --environment i3'
else else
echo '# Startup applications' 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 --no-startup-id clipster -d'
echo 'exec orca' echo "exec $screenReader"
echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.sh" echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.sh"
fi) 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 # If you want to add personal customizations to i3, add them in ${i3Path}/customizations
# It is not overwritten with the config file is recreated. # It is not overwritten when the config file is recreated.
include "${i3Path}/customizations" include "${i3Path}/customizations"
EOF EOF
touch "${i3Path}/customizations" 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"

2
panel.conf Normal file
View File

@ -0,0 +1,2 @@
assign [class="Solaar"] workspace number 11
assign [class="qjoypad"] workspace number 11

View File

@ -26,6 +26,11 @@ fi
left=9 left=9
right=0 right=0
msg="Workspace ${workSpace}" 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 if ! [[ "${workSpace}" =~ ^[0-9]+$ ]]; then
right=9 right=9
else else

View File

@ -19,7 +19,7 @@
batteryName="" batteryName=""
if [[ "$batteryName" == "" ]]; then if [[ "$batteryName" == "" ]]; then
batteryName="$(find /sys/class/power_supply -name 'sony_controller_battery_*' | cut -d/ -f5)" batteryName="$(find /sys/class/power_supply -name 'sony_controller_battery_*' -or -name 'ps-controller-battery-*' | cut -d/ -f5)"
fi fi
# If there's no file, we don't check it. # If there's no file, we don't check it.

23
scripts/i3-sensible-terminal.sh Executable file
View File

@ -0,0 +1,23 @@
#!/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.'

View File

@ -16,7 +16,8 @@ from pathlib import Path
from collections import defaultdict from collections import defaultdict
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk gi.require_version('Atk', '1.0')
from gi.repository import Gtk, Gdk, GLib, Atk
def read_desktop_files(paths): def read_desktop_files(paths):
desktopEntries = [] desktopEntries = []
@ -29,8 +30,9 @@ def read_desktop_files(paths):
userApplicationsPath = Path.home() / '.local/share/applications' userApplicationsPath = Path.home() / '.local/share/applications'
systemApplicationsPath = Path('/usr/share/applications') systemApplicationsPath = Path('/usr/share/applications')
userFlatpakApplicationsPath = Path.home() / '.local/share/flatpak/exports/share/applications'
desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath]) systemFlatpakApplicationsPath = Path('/var/lib/flatpak/exports/share/applications')
desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath, userFlatpakApplicationsPath, systemFlatpakApplicationsPath])
# Combine some of the categories # Combine some of the categories
categoryMap = { categoryMap = {
@ -84,90 +86,574 @@ categoryMap = {
"X-Xfce-Toplevel": "XFCE", "X-Xfce-Toplevel": "XFCE",
} }
categories = defaultdict(set) # First, gather all applications by category
categoryApps = defaultdict(dict)
subcategories = defaultdict(set)
for entry in desktopEntries: for entry in desktopEntries:
try: try:
entryCategories = entry.get('Desktop Entry', 'Categories').split(';') # Check if NoDisplay=true is set
for category in entryCategories: try:
combinedCategory = categoryMap.get(category, category) noDisplay = entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False)
if noDisplay:
continue
except:
pass
name = entry.get('Desktop Entry', 'Name') name = entry.get('Desktop Entry', 'Name')
execCommand = entry.get('Desktop Entry', 'Exec') execCommand = entry.get('Desktop Entry', 'Exec')
# Use a tuple of name and execCommand as a unique identifier entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';')
if (name, execCommand) not in categories[combinedCategory]:
categories[combinedCategory].add((name, execCommand)) # For applications with categories
except configparser.NoOptionError: mainCategory = None
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):
continue continue
class Xdg_Menu_Window(Gtk.Window): # 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):
def __init__(self): def __init__(self):
super().__init__(title="I38 Menu") super().__init__(title="I38 Menu")
self.set_default_size(400, 300) self.set_default_size(500, 400)
self.set_border_width(10) self.set_border_width(10)
self.store = Gtk.TreeStore(str, str) # Columns: Category/Application Name, Exec Command # Main container
self.mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add(self.mainBox)
sortedCategories = sorted(categories.items()) # Sort categories alphabetically # Add search box at the top
self.searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
for category, entries in sortedCategories: # Create completion for the search entry
if category == "": 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
continue 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])
self.treeview = Gtk.TreeView(model=self.store) # Create a TreeStore for this category
store = Gtk.TreeStore(str, str) # Columns: Name, Exec
self.stores[category] = store
# Add applications to this category's store
sortedApps = sorted(categoryApps[category].items())
# Check for potential subcategories within this category
categorySubcategories = {}
for appName, appExec in sortedApps:
subcatFound = False
for subcat in subcategories.get(category, []):
if appName in categoryApps.get(subcat, {}):
if subcat not in categorySubcategories:
categorySubcategories[subcat] = []
categorySubcategories[subcat].append((appName, appExec))
subcatFound = True
break
if not subcatFound:
# Add directly to the category's root
store.append(None, [appName, appExec])
# Add any subcategories
for subcat, subcatApps in sorted(categorySubcategories.items()):
subcatIter = store.append(None, [subcat, None])
for appName, appExec in sorted(subcatApps):
store.append(subcatIter, [appName, appExec])
# Create TreeView for this category
treeView = Gtk.TreeView(model=store)
treeView.set_headers_visible(False)
self.treeViews[category] = treeView
# Add column for application names
renderer = Gtk.CellRendererText() renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn("Applications", renderer, text=0) column = Gtk.TreeViewColumn("Applications", renderer, text=0)
self.treeview.append_column(column) treeView.append_column(column)
self.treeview.set_headers_visible(False)
self.treeview.connect("row-activated", self.on_row_activated) # Set up scrolled window
self.treeview.connect("key-press-event", self.on_key_press) scrolledWindow = Gtk.ScrolledWindow()
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolledWindow.add(treeView)
self.add(self.treeview) # Connect signals
self.connect("key-press-event", self.on_key_press) treeView.connect("row-activated", self.on_row_activated)
self.treeview.connect("focus-out-event", self.on_focus_out) treeView.connect("key-press-event", self.on_key_press)
self.treeview.grab_focus() # Create tab label
self.show_all() 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)
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: if execCommand:
os.system(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)
def on_focus_out(self, widget, event): # Use GLib.spawn_command_line_async for better process handling
try:
GLib.spawn_command_line_async(cleanCmd)
Gtk.main_quit() 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_key_press(self, widget, event): def on_key_press(self, widget, event):
keyval = event.keyval keyval = event.keyval
state = event.state
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
if keyval == Gdk.KEY_Escape: if keyval == Gdk.KEY_Escape:
Gtk.main_quit() Gtk.main_quit()
elif keyval == Gdk.KEY_Left: return True
path = self.treeview.get_cursor()[0]
if path: elif keyval == Gdk.KEY_slash:
iter = self.store.get_iter(path) # Forward slash activates search
if self.treeview.row_expanded(path): self.searchEntry.grab_focus()
self.treeview.collapse_row(path) return True
else:
parent_iter = self.store.iter_parent(iter) return False
if parent_iter:
parent_path = self.store.get_path(parent_iter) def on_focus_out(self, widget, event):
self.treeview.collapse_row(parent_path) # Quit when the window loses focus
else: Gtk.main_quit()
self.treeview.collapse_row(path)
elif keyval == Gdk.KEY_Right: window = I38_Tab_Menu()
path = self.treeview.get_cursor()[0] window.connect("destroy", Gtk.main_quit)
if path: window.show_all()
if not self.treeview.row_expanded(path):
self.treeview.expand_row(path, open_all=False) # 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
win = Xdg_Menu_Window()
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main() Gtk.main()

View File

@ -16,6 +16,6 @@ if [ $(pamixer --get-mute) = false ]; then
pamixer -t pamixer -t
else else
pamixer -t pamixer -t
play -qnG synth 0.05 sin 440 play -qnGV0 synth 0.05 sin 440
spd-say -P important -Cw 'Unmuted!' spd-say -P important -Cw 'Unmuted!'
fi fi

823
scripts/notes.py Executable file
View File

@ -0,0 +1,823 @@
#!/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()

View File

@ -20,13 +20,13 @@ i3 = i3ipc.Connection()
def on_new_window(self,i3): def on_new_window(self,i3):
if i3.container.name == 'xfce4-notifyd': if i3.container.name == 'xfce4-notifyd':
system('play -n synth .05 sq 1800 tri 2400 delay 0 .03 remix - repeat 2 echo .55 0.7 20 1 norm -12 &') system('play -nqV0 synth .05 sq 1800 tri 2400 delay 0 .03 remix - repeat 2 echo .55 0.7 20 1 norm -12 &')
else: else:
system('play -n synth .25 sin 440:880 sin 480:920 remix - norm -3 pitch -500 &') system('play -nqV0 synth .25 sin 440:880 sin 480:920 remix - norm -3 pitch -500 &')
def on_close_window(self,i3): def on_close_window(self,i3):
if i3.container.name != 'xfce4-notifyd': if i3.container.name != 'xfce4-notifyd':
system('play -n synth .25 sin 880:440 sin 920:480 remix - norm -3 pitch -500 &') system('play -nqV0 synth .25 sin 880:440 sin 920:480 remix - norm -3 pitch -500 &')
def on_mode(self,event): def on_mode(self,event):
mode= event.change mode= event.change
@ -37,7 +37,7 @@ def on_mode(self,event):
elif mode == 'default': 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 &') system('play -qV0 "|sox -np synth .07 sq 400" "|sox -np synth .5 sq 800" fade h 0 .5 .5 norm -20 reverse &')
else: else:
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') 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')
def on_workspace_focus(self,i3): 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 &') #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 &') 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): def on_restart(self,i3):
system('play -qn synth .25 saw 500:1200 fade .1 .25 .1 norm -8 &') system('play -qnV0 synth .25 saw 500:1200 fade .1 .25 .1 norm -8 &')
def on_exit(self,i3): def on_exit(self,i3):
system('play -qn synth .3 sin 700:200 fade 0 .3 0 &') system('play -qnV0 synth .3 sin 700:200 fade 0 .3 0 &')
def on_fullscreen(self,i3): def on_fullscreen(self,i3):
system('play -qn synth br flanger fade h .3 .3 0 &') system('play -qnV0 synth br flanger fade h .3 .3 0 &')
i3 = i3ipc.Connection() i3 = i3ipc.Connection()

110
scripts/sysinfo.sh Executable file
View File

@ -0,0 +1,110 @@
#!/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

104
scripts/toggle_screenreader.sh Executable file
View File

@ -0,0 +1,104 @@
#!/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

439
scripts/weather.sh Executable file
View File

@ -0,0 +1,439 @@
#!/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&current=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

444
scripts/wormhole.py Executable file
View File

@ -0,0 +1,444 @@
#!/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()