something-x-dev 1.8.2.dev27__tar.gz → 1.9.0.dev29__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/PKG-INFO +2 -2
  2. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/README.md +1 -1
  3. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/ROADMAP.md +5 -5
  4. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/_version.py +2 -2
  5. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/application.py +49 -0
  6. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/pages/device.py +155 -1
  7. something_x_dev-1.9.0.dev29/nothing_app/pages/theme.py +484 -0
  8. something_x_dev-1.9.0.dev29/nothing_app/profiles.py +129 -0
  9. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/protocol.py +19 -16
  10. something_x_dev-1.9.0.dev29/nothing_app/theme.py +312 -0
  11. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/window.py +74 -2
  12. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/PKG-INFO +2 -2
  13. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/SOURCES.txt +5 -1
  14. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/tests/conftest.py +2 -0
  15. something_x_dev-1.9.0.dev29/tests/test_notifications.py +133 -0
  16. something_x_dev-1.9.0.dev29/tests/test_profiles.py +267 -0
  17. something_x_dev-1.9.0.dev29/tests/test_theme.py +454 -0
  18. something_x_dev-1.8.2.dev27/nothing_app/profiles.py +0 -41
  19. something_x_dev-1.8.2.dev27/tests/test_profiles.py +0 -90
  20. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/.github/CODEOWNERS +0 -0
  21. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/.github/workflows/ci.yml +0 -0
  22. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/.github/workflows/release-dev.yml +0 -0
  23. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/.github/workflows/release.yml +0 -0
  24. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/.gitignore +0 -0
  25. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/DEVICES.md +0 -0
  26. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/LICENSE +0 -0
  27. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/PKGBUILD +0 -0
  28. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/cliff.toml +0 -0
  29. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/docs/RELEASING.md +0 -0
  30. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/docs/docs.html +0 -0
  31. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/docs/index.html +0 -0
  32. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/flake.nix +0 -0
  33. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/__init__.py +0 -0
  34. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/bluetooth.py +0 -0
  35. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/data/__init__.py +0 -0
  36. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
  37. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/data/style.css +0 -0
  38. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/pages/__init__.py +0 -0
  39. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/pages/home.py +0 -0
  40. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/splash.py +0 -0
  41. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/nothing_app/tray.py +0 -0
  42. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/pyproject.toml +0 -0
  43. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/setup.cfg +0 -0
  44. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/dependency_links.txt +0 -0
  45. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/entry_points.txt +0 -0
  46. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/requires.txt +0 -0
  47. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/something_x_dev.egg-info/top_level.txt +0 -0
  48. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/somethingx +0 -0
  49. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/tests/__init__.py +0 -0
  50. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/tests/test_bluetooth.py +0 -0
  51. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/tests/test_crc.py +0 -0
  52. {something_x_dev-1.8.2.dev27 → something_x_dev-1.9.0.dev29}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: something-x-dev
3
- Version: 1.8.2.dev27
3
+ Version: 1.9.0.dev29
4
4
  Summary: Something X device manager for Omarchy / Linux
5
5
  Author: Raphael
6
6
  License: MIT
@@ -113,7 +113,7 @@ pip install something-x
113
113
  <summary>Fedora 39+</summary>
114
114
 
115
115
  ```bash
116
- sudo dnf install python3-gobject python3-dbus python3-cairo gtk4 libadwaita
116
+ sudo dnf install python3-pip python3-gobject python3-dbus python3-cairo gtk4 libadwaita
117
117
  pip install something-x
118
118
  ```
119
119
 
@@ -89,7 +89,7 @@ pip install something-x
89
89
  <summary>Fedora 39+</summary>
90
90
 
91
91
  ```bash
92
- sudo dnf install python3-gobject python3-dbus python3-cairo gtk4 libadwaita
92
+ sudo dnf install python3-pip python3-gobject python3-dbus python3-cairo gtk4 libadwaita
93
93
  pip install something-x
94
94
  ```
95
95
 
@@ -28,14 +28,14 @@
28
28
  ## 1.9 — Quality of life
29
29
 
30
30
  ### Features
31
- - [ ] **Device nickname** — rename a paired device in the UI; stored in profiles
32
- - [ ] **Profile import / export** — share `.json` profile files between machines or with other users
31
+ - [x] **Device nickname** — rename a paired device in the UI; stored in profiles
32
+ - [x] **Profile import / export** — share `.json` profile files between machines or with other users
33
33
  - [ ] **Wear-detect MPRIS actions** — pause media when both buds are removed; resume when reinserted (opt-in)
34
- - [ ] **Notification preferences** — per-event toggles (battery low, connect, disconnect) in settings
35
- - [ ] **Theming** — user-selectable accent color and light/dark mode toggle; theme stored in config
34
+ - [x] **Notification preferences** — per-event toggles (battery low, connect, disconnect) in settings
35
+ - [x] **Theming** — user-selectable accent color and light/dark mode toggle; theme stored in config
36
36
 
37
37
  ### Tech
38
- - [ ] **Improved test coverage** — device page, tray, profiles round-trip, CLI flags
38
+ - [x] **Improved test coverage** — profiles round-trip, nickname, notify prefs, notification gating (91 tests total)
39
39
 
40
40
  ---
41
41
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.8.2.dev27'
22
- __version_tuple__ = version_tuple = (1, 8, 2, 'dev27')
21
+ __version__ = version = '1.9.0.dev29'
22
+ __version_tuple__ = version_tuple = (1, 9, 0, 'dev29')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -50,6 +50,9 @@ class SomethingXApplication(Adw.Application):
50
50
  self._splash: SplashScreen | None = None
51
51
  self._window: SomethingXWindow | None = None
52
52
  self._tray: SomethingXTray | None = None
53
+ self._theme_provider: Gtk.CssProvider | None = None
54
+ self._current_theme = None
55
+ self._theme_save_id: int | None = None
53
56
  self.connect("activate", self._on_activate)
54
57
 
55
58
  def _on_activate(self, _app):
@@ -61,6 +64,7 @@ class SomethingXApplication(Adw.Application):
61
64
  _install_desktop_file()
62
65
  Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_DARK)
63
66
  self._load_css()
67
+ self._load_theme_css()
64
68
  self._bt = BluetoothManager()
65
69
  splash = SplashScreen(on_done=self._on_splash_done)
66
70
  splash.set_application(self)
@@ -72,6 +76,8 @@ class SomethingXApplication(Adw.Application):
72
76
  win = SomethingXWindow(bt_manager=self._bt, application=self)
73
77
  win.connect("close-request", self._on_window_close)
74
78
  self._window = win
79
+ if self._current_theme is not None:
80
+ win.set_opacity(max(0.05, min(1.0, self._current_theme.window_opacity)))
75
81
  self._tray = SomethingXTray(self._bt, on_show_window=win.present)
76
82
  win.present()
77
83
  if self._splash:
@@ -93,6 +99,49 @@ class SomethingXApplication(Adw.Application):
93
99
  )
94
100
  return True # prevent default close/destroy
95
101
 
102
+ def _load_theme_css(self):
103
+ from . import theme as _theme_mod
104
+
105
+ self._current_theme = _theme_mod.load()
106
+ self._theme_provider = Gtk.CssProvider()
107
+ css = _theme_mod.generate_css(self._current_theme)
108
+ try:
109
+ self._theme_provider.load_from_string(css)
110
+ except AttributeError:
111
+ self._theme_provider.load_from_data(css.encode())
112
+
113
+ display = Gdk.Display.get_default()
114
+ if display:
115
+ Gtk.StyleContext.add_provider_for_display(
116
+ display,
117
+ self._theme_provider,
118
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
119
+ )
120
+
121
+ def apply_theme(self, t):
122
+ from . import theme as _theme_mod
123
+
124
+ self._current_theme = t
125
+ css = _theme_mod.generate_css(t)
126
+ try:
127
+ self._theme_provider.load_from_string(css)
128
+ except AttributeError:
129
+ self._theme_provider.load_from_data(css.encode())
130
+
131
+ if self._window is not None:
132
+ self._window.set_opacity(max(0.05, min(1.0, t.window_opacity)))
133
+
134
+ if self._theme_save_id is not None:
135
+ GLib.source_remove(self._theme_save_id)
136
+ self._theme_save_id = GLib.timeout_add(500, self._flush_theme_save)
137
+
138
+ def _flush_theme_save(self):
139
+ from . import theme as _theme_mod
140
+
141
+ _theme_mod.save(self._current_theme)
142
+ self._theme_save_id = None
143
+ return False
144
+
96
145
  def _load_css(self):
97
146
  provider = Gtk.CssProvider()
98
147
  css = _css_path()
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import math
2
3
  import re
3
4
  import subprocess
@@ -8,10 +9,11 @@ import gi
8
9
  gi.require_version("Gtk", "4.0")
9
10
  gi.require_version("Pango", "1.0")
10
11
  gi.require_version("PangoCairo", "1.0")
11
- from gi.repository import Gtk, GLib, PangoCairo
12
+ from gi.repository import Gtk, GLib, PangoCairo, Gio
12
13
 
13
14
  from ..bluetooth import BluetoothDevice, BluetoothManager
14
15
  from ..protocol import NothingDevice, ANCMode, EQ_PRESETS
16
+ from .. import profiles
15
17
 
16
18
 
17
19
  def _mono_font() -> str:
@@ -487,12 +489,58 @@ class DevicePage(Gtk.Box):
487
489
 
488
490
  page.append(settings_group)
489
491
 
492
+ page.append(_section("NOTIFICATIONS"))
493
+
494
+ notif_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
495
+ notif_group.add_css_class("settings-group")
496
+ notif_group.set_margin_bottom(4)
497
+
498
+ notify_prefs = profiles.get_notify_prefs(self._bt_device.address)
499
+
500
+ self._notif_battery_switch = Gtk.Switch()
501
+ self._notif_battery_switch.set_active(notify_prefs.get("battery_low", True))
502
+ self._notif_battery_switch.set_valign(Gtk.Align.CENTER)
503
+ self._notif_battery_switch.connect("state-set", self._on_notif_battery_toggled)
504
+ notif_group.append(
505
+ _settings_row("Battery Low", "Alert when battery drops below 20%", self._notif_battery_switch)
506
+ )
507
+
508
+ self._notif_connect_switch = Gtk.Switch()
509
+ self._notif_connect_switch.set_active(notify_prefs.get("connect", True))
510
+ self._notif_connect_switch.set_valign(Gtk.Align.CENTER)
511
+ self._notif_connect_switch.connect("state-set", self._on_notif_connect_toggled)
512
+ notif_group.append(
513
+ _settings_row("Connected", "Alert when device connects", self._notif_connect_switch)
514
+ )
515
+
516
+ self._notif_disconnect_switch = Gtk.Switch()
517
+ self._notif_disconnect_switch.set_active(notify_prefs.get("disconnect", True))
518
+ self._notif_disconnect_switch.set_valign(Gtk.Align.CENTER)
519
+ self._notif_disconnect_switch.connect("state-set", self._on_notif_disconnect_toggled)
520
+ notif_group.append(
521
+ _settings_row("Disconnected", "Alert when device disconnects", self._notif_disconnect_switch)
522
+ )
523
+
524
+ page.append(notif_group)
525
+
490
526
  page.append(_section("DEVICE INFO"))
491
527
 
492
528
  info_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
493
529
  info_group.add_css_class("settings-group")
494
530
  info_group.set_margin_bottom(4)
495
531
 
532
+ nick_entry = Gtk.Entry()
533
+ nick_entry.set_hexpand(True)
534
+ nick_entry.set_valign(Gtk.Align.CENTER)
535
+ saved_nick = profiles.get_nickname(self._bt_device.address)
536
+ if saved_nick:
537
+ nick_entry.set_text(saved_nick)
538
+ nick_entry.set_placeholder_text(self._bt_device.name)
539
+ nick_entry.connect("activate", self._on_nickname_activate)
540
+ nick_entry.connect("notify::has-focus", self._on_nickname_focus_lost)
541
+ self._nick_entry = nick_entry
542
+ info_group.append(_settings_row("Nickname", right_widget=nick_entry))
543
+
496
544
  self._fw_label = Gtk.Label(label="—")
497
545
  self._fw_label.add_css_class("info-value")
498
546
  self._fw_label.set_xalign(1)
@@ -508,6 +556,23 @@ class DevicePage(Gtk.Box):
508
556
  addr_val.set_xalign(1)
509
557
  info_group.append(_settings_row("Address", right_widget=addr_val))
510
558
 
559
+ io_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
560
+ io_box.set_margin_top(4)
561
+
562
+ export_btn = Gtk.Button(label="Export Profile")
563
+ export_btn.add_css_class("settings-row-action")
564
+ export_btn.set_hexpand(True)
565
+ export_btn.connect("clicked", self._on_export_clicked)
566
+
567
+ import_btn = Gtk.Button(label="Import Profile")
568
+ import_btn.add_css_class("settings-row-action")
569
+ import_btn.set_hexpand(True)
570
+ import_btn.connect("clicked", self._on_import_clicked)
571
+
572
+ io_box.append(export_btn)
573
+ io_box.append(import_btn)
574
+ info_group.append(io_box)
575
+
511
576
  page.append(info_group)
512
577
 
513
578
  self._sync_anc_ui(ANCMode.OFF)
@@ -634,6 +699,95 @@ class DevicePage(Gtk.Box):
634
699
  else:
635
700
  btn.remove_css_class("active")
636
701
 
702
+ def _save_nickname(self):
703
+ if not hasattr(self, "_nick_entry"):
704
+ return
705
+ text = self._nick_entry.get_text().strip()
706
+ profiles.set_nickname(self._bt_device.address, text or None)
707
+
708
+ def _on_nickname_activate(self, _entry):
709
+ self._save_nickname()
710
+
711
+ def _on_nickname_focus_lost(self, entry, _param):
712
+ if not entry.has_focus():
713
+ self._save_nickname()
714
+
715
+ def _on_notif_battery_toggled(self, _switch, state: bool):
716
+ profiles.set_notify_prefs(self._bt_device.address, {"battery_low": state})
717
+ return False
718
+
719
+ def _on_notif_connect_toggled(self, _switch, state: bool):
720
+ profiles.set_notify_prefs(self._bt_device.address, {"connect": state})
721
+ return False
722
+
723
+ def _on_notif_disconnect_toggled(self, _switch, state: bool):
724
+ profiles.set_notify_prefs(self._bt_device.address, {"disconnect": state})
725
+ return False
726
+
727
+ def _on_export_clicked(self, _btn):
728
+ dialog = Gtk.FileChooserNative(
729
+ title="Export Profile",
730
+ transient_for=self.get_root(),
731
+ action=Gtk.FileChooserAction.SAVE,
732
+ )
733
+ dialog.set_current_name(f"something-x-{self._bt_device.address.replace(':', '-')}.json")
734
+ dialog.connect("response", self._on_export_response, dialog)
735
+ dialog.show()
736
+
737
+ def _on_export_response(self, _dialog, response: int, dialog: Gtk.FileChooserNative):
738
+ if response != Gtk.ResponseType.ACCEPT:
739
+ return
740
+ gfile = dialog.get_file()
741
+ if not gfile:
742
+ return
743
+ path = gfile.get_path()
744
+ data = profiles.export_profile(self._bt_device.address)
745
+ try:
746
+ with open(path, "w") as f:
747
+ json.dump(data, f, indent=2)
748
+ except Exception as exc:
749
+ print(f"[device] export failed: {exc}")
750
+
751
+ def _on_import_clicked(self, _btn):
752
+ dialog = Gtk.FileChooserNative(
753
+ title="Import Profile",
754
+ transient_for=self.get_root(),
755
+ action=Gtk.FileChooserAction.OPEN,
756
+ )
757
+ filt = Gtk.FileFilter()
758
+ filt.set_name("JSON files")
759
+ filt.add_mime_type("application/json")
760
+ filt.add_pattern("*.json")
761
+ dialog.add_filter(filt)
762
+ dialog.connect("response", self._on_import_response, dialog)
763
+ dialog.show()
764
+
765
+ def _on_import_response(self, _dialog, response: int, dialog: Gtk.FileChooserNative):
766
+ if response != Gtk.ResponseType.ACCEPT:
767
+ return
768
+ gfile = dialog.get_file()
769
+ if not gfile:
770
+ return
771
+ path = gfile.get_path()
772
+ try:
773
+ with open(path) as f:
774
+ data = json.load(f)
775
+ profiles.import_profile(self._bt_device.address, data)
776
+ self._reload_notif_switches()
777
+ if hasattr(self, "_nick_entry"):
778
+ nick = profiles.get_nickname(self._bt_device.address)
779
+ self._nick_entry.set_text(nick or "")
780
+ except Exception as exc:
781
+ print(f"[device] import failed: {exc}")
782
+
783
+ def _reload_notif_switches(self):
784
+ if not hasattr(self, "_notif_battery_switch"):
785
+ return
786
+ prefs = profiles.get_notify_prefs(self._bt_device.address)
787
+ self._notif_battery_switch.set_active(prefs.get("battery_low", True))
788
+ self._notif_connect_switch.set_active(prefs.get("connect", True))
789
+ self._notif_disconnect_switch.set_active(prefs.get("disconnect", True))
790
+
637
791
  def _on_anc_clicked(self, _btn, mode: int):
638
792
  self._sync_anc_ui(mode)
639
793
  if self._nothing_dev: