device.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Clutter from 'gi://Clutter';
  5. import GObject from 'gi://GObject';
  6. import St from 'gi://St';
  7. import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
  8. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  9. import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
  10. import {getIcon} from './utils.js';
  11. import * as GMenu from './gmenu.js';
  12. import Tooltip from './tooltip.js';
  13. /**
  14. * A battery widget with an icon, text percentage and time estimate tooltip
  15. */
  16. export const Battery = GObject.registerClass({
  17. GTypeName: 'GSConnectShellDeviceBattery',
  18. }, class Battery extends St.BoxLayout {
  19. _init(params) {
  20. super._init({
  21. reactive: true,
  22. style_class: 'gsconnect-device-battery',
  23. track_hover: true,
  24. });
  25. Object.assign(this, params);
  26. // Percent Label
  27. this.label = new St.Label({
  28. y_align: Clutter.ActorAlign.CENTER,
  29. });
  30. this.label.clutter_text.ellipsize = 0;
  31. this.add_child(this.label);
  32. // Battery Icon
  33. this.icon = new St.Icon({
  34. fallback_icon_name: 'battery-missing-symbolic',
  35. icon_size: 16,
  36. });
  37. this.add_child(this.icon);
  38. // Battery Estimate
  39. this.tooltip = new Tooltip({
  40. parent: this,
  41. text: null,
  42. });
  43. // Battery GAction
  44. this._actionAddedId = this.device.action_group.connect(
  45. 'action-added',
  46. this._onActionChanged.bind(this)
  47. );
  48. this._actionRemovedId = this.device.action_group.connect(
  49. 'action-removed',
  50. this._onActionChanged.bind(this)
  51. );
  52. this._actionStateChangedId = this.device.action_group.connect(
  53. 'action-state-changed',
  54. this._onStateChanged.bind(this)
  55. );
  56. this._onActionChanged(this.device.action_group, 'battery');
  57. // Cleanup on destroy
  58. this.connect('destroy', this._onDestroy);
  59. }
  60. _onActionChanged(action_group, action_name) {
  61. if (action_name !== 'battery')
  62. return;
  63. if (action_group.has_action('battery')) {
  64. const value = action_group.get_action_state('battery');
  65. const [charging, icon_name, level, time] = value.deepUnpack();
  66. this._state = {
  67. charging: charging,
  68. icon_name: icon_name,
  69. level: level,
  70. time: time,
  71. };
  72. } else {
  73. this._state = null;
  74. }
  75. this._sync();
  76. }
  77. _onStateChanged(action_group, action_name, value) {
  78. if (action_name !== 'battery')
  79. return;
  80. const [charging, icon_name, level, time] = value.deepUnpack();
  81. this._state = {
  82. charging: charging,
  83. icon_name: icon_name,
  84. level: level,
  85. time: time,
  86. };
  87. this._sync();
  88. }
  89. _getBatteryLabel() {
  90. if (!this._state)
  91. return null;
  92. const {charging, level, time} = this._state;
  93. if (level === 100)
  94. // TRANSLATORS: When the battery level is 100%
  95. return _('Fully Charged');
  96. if (time === 0)
  97. // TRANSLATORS: When no time estimate for the battery is available
  98. // EXAMPLE: 42% (Estimating…)
  99. return _('%d%% (Estimating…)').format(level);
  100. const total = time / 60;
  101. const minutes = Math.floor(total % 60);
  102. const hours = Math.floor(total / 60);
  103. if (charging) {
  104. // TRANSLATORS: Estimated time until battery is charged
  105. // EXAMPLE: 42% (1:15 Until Full)
  106. return _('%d%% (%d\u2236%02d Until Full)').format(
  107. level,
  108. hours,
  109. minutes
  110. );
  111. } else {
  112. // TRANSLATORS: Estimated time until battery is empty
  113. // EXAMPLE: 42% (12:15 Remaining)
  114. return _('%d%% (%d\u2236%02d Remaining)').format(
  115. level,
  116. hours,
  117. minutes
  118. );
  119. }
  120. }
  121. _onDestroy(actor) {
  122. actor.device.action_group.disconnect(actor._actionAddedId);
  123. actor.device.action_group.disconnect(actor._actionRemovedId);
  124. actor.device.action_group.disconnect(actor._actionStateChangedId);
  125. }
  126. _sync() {
  127. this.visible = !!this._state;
  128. if (!this.visible)
  129. return;
  130. this.icon.icon_name = this._state.icon_name;
  131. this.label.text = (this._state.level > -1) ? `${this._state.level}%` : '';
  132. this.tooltip.text = this._getBatteryLabel();
  133. }
  134. });
  135. /**
  136. * A cell signal strength widget with two icons
  137. */
  138. export const SignalStrength = GObject.registerClass({
  139. GTypeName: 'GSConnectShellDeviceSignalStrength',
  140. }, class SignalStrength extends St.BoxLayout {
  141. _init(params) {
  142. super._init({
  143. reactive: true,
  144. style_class: 'gsconnect-device-signal-strength',
  145. track_hover: true,
  146. });
  147. Object.assign(this, params);
  148. // Network Type Icon
  149. this.networkTypeIcon = new St.Icon({
  150. fallback_icon_name: 'network-cellular-symbolic',
  151. icon_size: 16,
  152. });
  153. this.add_child(this.networkTypeIcon);
  154. // Signal Strength Icon
  155. this.signalStrengthIcon = new St.Icon({
  156. fallback_icon_name: 'network-cellular-offline-symbolic',
  157. icon_size: 16,
  158. });
  159. this.add_child(this.signalStrengthIcon);
  160. // Network Type Text
  161. this.tooltip = new Tooltip({
  162. parent: this,
  163. text: null,
  164. });
  165. // ConnectivityReport GAction
  166. this._actionAddedId = this.device.action_group.connect(
  167. 'action-added',
  168. this._onActionChanged.bind(this)
  169. );
  170. this._actionRemovedId = this.device.action_group.connect(
  171. 'action-removed',
  172. this._onActionChanged.bind(this)
  173. );
  174. this._actionStateChangedId = this.device.action_group.connect(
  175. 'action-state-changed',
  176. this._onStateChanged.bind(this)
  177. );
  178. this._onActionChanged(this.device.action_group, 'connectivityReport');
  179. // Cleanup on destroy
  180. this.connect('destroy', this._onDestroy);
  181. }
  182. _onActionChanged(action_group, action_name) {
  183. if (action_name !== 'connectivityReport')
  184. return;
  185. if (action_group.has_action('connectivityReport')) {
  186. const value = action_group.get_action_state('connectivityReport');
  187. const [
  188. cellular_network_type,
  189. cellular_network_type_icon,
  190. cellular_network_strength,
  191. cellular_network_strength_icon,
  192. hotspot_name,
  193. hotspot_bssid,
  194. ] = value.deepUnpack();
  195. this._state = {
  196. cellular_network_type: cellular_network_type,
  197. cellular_network_type_icon: cellular_network_type_icon,
  198. cellular_network_strength: cellular_network_strength,
  199. cellular_network_strength_icon: cellular_network_strength_icon,
  200. hotspot_name: hotspot_name,
  201. hotspot_bssid: hotspot_bssid,
  202. };
  203. } else {
  204. this._state = null;
  205. }
  206. this._sync();
  207. }
  208. _onStateChanged(action_group, action_name, value) {
  209. if (action_name !== 'connectivityReport')
  210. return;
  211. const [
  212. cellular_network_type,
  213. cellular_network_type_icon,
  214. cellular_network_strength,
  215. cellular_network_strength_icon,
  216. hotspot_name,
  217. hotspot_bssid,
  218. ] = value.deepUnpack();
  219. this._state = {
  220. cellular_network_type: cellular_network_type,
  221. cellular_network_type_icon: cellular_network_type_icon,
  222. cellular_network_strength: cellular_network_strength,
  223. cellular_network_strength_icon: cellular_network_strength_icon,
  224. hotspot_name: hotspot_name,
  225. hotspot_bssid: hotspot_bssid,
  226. };
  227. this._sync();
  228. }
  229. _onDestroy(actor) {
  230. actor.device.action_group.disconnect(actor._actionAddedId);
  231. actor.device.action_group.disconnect(actor._actionRemovedId);
  232. actor.device.action_group.disconnect(actor._actionStateChangedId);
  233. }
  234. _sync() {
  235. this.visible = !!this._state;
  236. if (!this.visible)
  237. return;
  238. this.networkTypeIcon.icon_name = this._state.cellular_network_type_icon;
  239. this.signalStrengthIcon.icon_name = this._state.cellular_network_strength_icon;
  240. this.tooltip.text = this._state.cellular_network_type;
  241. }
  242. });
  243. /**
  244. * A PopupMenu used as an information and control center for a device
  245. */
  246. export class Menu extends PopupMenu.PopupMenuSection {
  247. constructor(params) {
  248. super();
  249. Object.assign(this, params);
  250. this.actor.add_style_class_name('gsconnect-device-menu');
  251. // Title
  252. this._title = new PopupMenu.PopupSeparatorMenuItem(this.device.name);
  253. this.addMenuItem(this._title);
  254. // Title -> Name
  255. this._title.label.style_class = 'gsconnect-device-name';
  256. this._title.label.clutter_text.ellipsize = 0;
  257. this.device.bind_property(
  258. 'name',
  259. this._title.label,
  260. 'text',
  261. GObject.BindingFlags.SYNC_CREATE
  262. );
  263. // Title -> Cellular Signal Strength
  264. this._signalStrength = new SignalStrength({device: this.device});
  265. this._title.actor.add_child(this._signalStrength);
  266. // Title -> Battery
  267. this._battery = new Battery({device: this.device});
  268. this._title.actor.add_child(this._battery);
  269. // Actions
  270. let actions;
  271. if (this.menu_type === 'icon') {
  272. actions = new GMenu.IconBox({
  273. action_group: this.device.action_group,
  274. model: this.device.menu,
  275. });
  276. } else if (this.menu_type === 'list') {
  277. actions = new GMenu.ListBox({
  278. action_group: this.device.action_group,
  279. model: this.device.menu,
  280. });
  281. }
  282. this.addMenuItem(actions);
  283. }
  284. isEmpty() {
  285. return false;
  286. }
  287. }
  288. /**
  289. * An indicator representing a Device in the Status Area
  290. */
  291. export const Indicator = GObject.registerClass({
  292. GTypeName: 'GSConnectDeviceIndicator',
  293. }, class Indicator extends PanelMenu.Button {
  294. _init(params) {
  295. super._init(0.0, `${params.device.name} Indicator`, false);
  296. Object.assign(this, params);
  297. // Device Icon
  298. this._icon = new St.Icon({
  299. gicon: getIcon(this.device.icon_name),
  300. style_class: 'system-status-icon gsconnect-device-indicator',
  301. });
  302. this.add_child(this._icon);
  303. // Menu
  304. const menu = new Menu({
  305. device: this.device,
  306. menu_type: 'icon',
  307. });
  308. this.menu.addMenuItem(menu);
  309. }
  310. });