preview.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. "use strict";
  2. // Global modules
  3. const Lang = imports.lang;
  4. const Main = imports.ui.main;
  5. const St = imports.gi.St;
  6. const Tweener = imports.ui.tweener;
  7. const Clutter = imports.gi.Clutter;
  8. const Signals = imports.signals;
  9. // Internal modules
  10. const ExtensionUtils = imports.misc.extensionUtils;
  11. const Me = ExtensionUtils.getCurrentExtension();
  12. const Polygnome = Me.imports.polygnome;
  13. const Signaling = Me.imports.signaling;
  14. const DisplayWrapper = Polygnome.DisplayWrapper;
  15. const SignalConnector = Signaling.SignalConnector;
  16. // At the moment magnification hasn't been tested and it's clumsy
  17. const SETTING_MAGNIFICATION_ALLOWED = false;
  18. const CORNER_TOP_LEFT = 0;
  19. const CORNER_TOP_RIGHT = 1;
  20. const CORNER_BOTTOM_RIGHT = 2;
  21. const CORNER_BOTTOM_LEFT = 3;
  22. const DEFAULT_CORNER = CORNER_TOP_RIGHT;
  23. var MIN_ZOOM = 0.10; // User shouldn't be able to make the preview too small or big, as it may break normal experience
  24. var MAX_ZOOM = 0.75;
  25. var DEFAULT_ZOOM = 0.20;
  26. var MAX_CROP_RATIO = 0.85;
  27. var DEFAULT_CROP_RATIO = 0.0;
  28. const SCROLL_ACTOR_MARGIN = 0.2; // scrolling: 20% external margin to crop, 80% to zoom
  29. const SCROLL_ZOOM_STEP = 0.01; // 1% zoom for step
  30. const SCROLL_CROP_STEP = 0.0063; // cropping step when user scrolls
  31. // Animation constants
  32. const TWEEN_OPACITY_FULL = 255;
  33. const TWEEN_OPACITY_SEMIFULL = Math.round(TWEEN_OPACITY_FULL * 0.90);
  34. const TWEEN_OPACITY_HALF = Math.round(TWEEN_OPACITY_FULL * 0.50);
  35. const TWEEN_OPACITY_TENTH = Math.round(TWEEN_OPACITY_FULL * 0.10);
  36. const TWEEN_OPACITY_NULL = 0;
  37. const TWEEN_TIME_SHORT = 0.25;
  38. const TWEEN_TIME_MEDIUM = 0.6;
  39. const TWEEN_TIME_LONG = 0.80;
  40. const GTK_MOUSE_LEFT_BUTTON = 1;
  41. const GTK_MOUSE_MIDDLE_BUTTON = 2;
  42. const GTK_MOUSE_RIGHT_BUTTON = 3;
  43. const GDK_SHIFT_MASK = 1;
  44. const GDK_CONTROL_MASK = 4;
  45. const GDK_MOD1_MASK = 8;
  46. const GDK_ALT_MASK = GDK_MOD1_MASK; // Most cases
  47. var WindowCornerPreview = new Lang.Class({
  48. Name: "WindowCornerPreview.preview",
  49. _init: function() {
  50. this._corner = DEFAULT_CORNER;
  51. this._zoom = DEFAULT_ZOOM;
  52. this._leftCrop = DEFAULT_CROP_RATIO;
  53. this._rightCrop = DEFAULT_CROP_RATIO;
  54. this._topCrop = DEFAULT_CROP_RATIO;
  55. this._bottomCrop = DEFAULT_CROP_RATIO;
  56. // The following properties are documented on _adjustVisibility()
  57. this._naturalVisibility = false;
  58. this._focusHidden = true;
  59. this._container = null;
  60. this._window = null;
  61. this._windowSignals = new SignalConnector();
  62. this._environmentSignals = new SignalConnector();
  63. this._handleZoomChange = null;
  64. },
  65. _onClick: function(actor, event) {
  66. let button = event.get_button();
  67. let state = event.get_state();
  68. // CTRL + LEFT BUTTON activate the window on top
  69. if (button === GTK_MOUSE_LEFT_BUTTON && (state & GDK_CONTROL_MASK)) {
  70. this._window.activate(global.get_current_time());
  71. }
  72. // Otherwise move the preview to another corner
  73. else {
  74. switch (button) {
  75. case GTK_MOUSE_RIGHT_BUTTON:
  76. this.corner += 1;
  77. break;
  78. case GTK_MOUSE_MIDDLE_BUTTON:
  79. this.corner += -1;
  80. break;
  81. default: // GTK_MOUSE_LEFT_BUTTON:
  82. this.corner += 2;
  83. }
  84. this.emit("corner-changed");
  85. }
  86. },
  87. _onScroll: function(actor, event) {
  88. let scroll_direction = event.get_scroll_direction();
  89. let direction;
  90. switch (scroll_direction) {
  91. case Clutter.ScrollDirection.UP:
  92. case Clutter.ScrollDirection.LEFT:
  93. direction = +1.0
  94. break;
  95. case Clutter.ScrollDirection.DOWN:
  96. case Clutter.ScrollDirection.RIGHT:
  97. direction = -1.0
  98. break;
  99. default:
  100. direction = 0.0;
  101. }
  102. if (! direction) return; // Clutter.EVENT_PROPAGATE;
  103. // On mouse over it's normally pretty transparent, but user needs to see more for adjusting it
  104. Tweener.addTween(this._container, {
  105. opacity: TWEEN_OPACITY_SEMIFULL,
  106. time: TWEEN_TIME_SHORT,
  107. transition: "easeOutQuad"
  108. });
  109. // Coords are absolute, screen related
  110. let [mouseX, mouseY] = event.get_coords();
  111. // _container absolute rect
  112. let [actorX1, actorY1] = this._container.get_transformed_position();
  113. let [actorWidth, actorHeight] = this._container.get_transformed_size();
  114. let actorX2 = actorX1 + actorWidth;
  115. let actorY2 = actorY1 + actorHeight;
  116. // Distance of pointer from each side
  117. let deltaLeft = Math.abs(actorX1 - mouseX);
  118. let deltaRight = Math.abs(actorX2 - mouseX);
  119. let deltaTop = Math.abs(actorY1 - mouseY);
  120. let deltaBottom = Math.abs(actorY2 - mouseY);
  121. let sortedDeltas = [{
  122. property: "leftCrop",
  123. pxDistance: deltaLeft,
  124. comparedDistance: deltaLeft / actorWidth,
  125. direction: -direction
  126. },
  127. {
  128. property: "rightCrop",
  129. pxDistance: deltaRight,
  130. comparedDistance: deltaRight / actorWidth,
  131. direction: -direction
  132. },
  133. {
  134. property: "topCrop",
  135. pxDistance: deltaTop,
  136. comparedDistance: deltaTop / actorHeight,
  137. direction: -direction /* feels more natural */
  138. },
  139. {
  140. property: "bottomCrop",
  141. pxDistance: deltaBottom,
  142. comparedDistance: deltaBottom / actorHeight,
  143. direction: -direction
  144. }
  145. ];
  146. sortedDeltas.sort(function(a, b) {
  147. return a.pxDistance - b.pxDistance
  148. });
  149. let deltaMinimum = sortedDeltas[0];
  150. // Scrolling inside the preview triggers the zoom
  151. if (deltaMinimum.comparedDistance > SCROLL_ACTOR_MARGIN) {
  152. this.zoom += direction * SCROLL_ZOOM_STEP;
  153. this.emit("zoom-changed");
  154. }
  155. // Scrolling along the margins triggers the cropping instead
  156. else {
  157. this[deltaMinimum.property] += deltaMinimum.direction * SCROLL_CROP_STEP;
  158. this.emit("crop-changed");
  159. }
  160. },
  161. _onEnter: function(actor, event) {
  162. let [x, y, state] = global.get_pointer();
  163. // SHIFT: ignore standard behavior
  164. if (state & GDK_SHIFT_MASK) {
  165. return; // Clutter.EVENT_PROPAGATE;
  166. }
  167. Tweener.addTween(this._container, {
  168. opacity: TWEEN_OPACITY_TENTH,
  169. time: TWEEN_TIME_MEDIUM,
  170. transition: "easeOutQuad"
  171. });
  172. },
  173. _onLeave: function() {
  174. Tweener.addTween(this._container, {
  175. opacity: TWEEN_OPACITY_FULL,
  176. time: TWEEN_TIME_MEDIUM,
  177. transition: "easeOutQuad"
  178. });
  179. },
  180. _onParamsChange: function() {
  181. // Zoom or crop properties changed
  182. if (this.enabled) this._setThumbnail();
  183. },
  184. _onWindowUnmanaged: function() {
  185. this.disable();
  186. this._window = null;
  187. // gnome-shell --replace will cause this event too
  188. this.emit("window-changed", null);
  189. },
  190. _adjustVisibility: function(options) {
  191. options = options || {};
  192. /*
  193. [Boolean] this._naturalVisibility:
  194. true === show the preview whenever is possible;
  195. false === don't show it in any case
  196. [Boolean] this._focusHidden:
  197. true === hide in case the mirrored window should be active
  198. options = {
  199. onComplete: [function] to call once the process is done.
  200. It's called even if visibility was already set as requested
  201. noAnimate: [Boolean] to skip animation. If switching from window A to window B,
  202. for example, the preview gets first destroyed (so hidden) then recreated.
  203. This would lead to a fade-out + fade-in, which is not what most users like.
  204. noAnimate === true avoids that.
  205. };
  206. */
  207. if (! this._container) {
  208. if (options.onComplete) options.onComplete();
  209. return;
  210. }
  211. // Hide when overView is shown, or source window is on top, or user related reasons
  212. let canBeShownOnFocus = (! this._focusHidden) || (global.display.focus_window !== this._window);
  213. let calculatedVisibility = this._window &&
  214. this._naturalVisibility &&
  215. canBeShownOnFocus &&
  216. (! Main.overview.visibleTarget);
  217. let calculatedOpacity = (calculatedVisibility) ? TWEEN_OPACITY_FULL : TWEEN_OPACITY_NULL;
  218. // Already OK (hidden / shown), no change needed
  219. if ((calculatedVisibility === this._container.visible) && (calculatedOpacity === this._container.get_opacity())) {
  220. if (options.onComplete) options.onComplete();
  221. }
  222. // Quick set (show or hide), but don't animate
  223. else if (options.noAnimate) {
  224. this._container.set_opacity(calculatedOpacity)
  225. this._container.visible = calculatedVisibility;
  226. if (options.onComplete) options.onComplete();
  227. }
  228. // Animation needed (either from less to more opacity or viceversa)
  229. else {
  230. this._container.reactive = false;
  231. if (! this._container.visible) {
  232. this._container.set_opacity(TWEEN_OPACITY_NULL);
  233. this._container.visible = true;
  234. }
  235. Tweener.addTween(this._container, {
  236. opacity: calculatedOpacity,
  237. time: TWEEN_TIME_SHORT,
  238. transition: "easeOutQuad",
  239. onComplete: Lang.bind(this, function() {
  240. this._container.visible = calculatedVisibility;
  241. this._container.reactive = true;
  242. if (options.onComplete) options.onComplete();
  243. })
  244. });
  245. }
  246. },
  247. _onNotifyFocusWindow: function() {
  248. this._adjustVisibility();
  249. },
  250. _onOverviewShowing: function() {
  251. this._adjustVisibility();
  252. },
  253. _onOverviewHiding: function() {
  254. this._adjustVisibility();
  255. },
  256. _onMonitorsChanged: function() {
  257. // TODO multiple monitors issue, the preview doesn't stick to the right monitor
  258. log("Monitors changed");
  259. },
  260. // Align the preview along the chrome area
  261. _setPosition: function() {
  262. if (! this._container) {
  263. return;
  264. }
  265. let posX, posY;
  266. let rectMonitor = Main.layoutManager.getWorkAreaForMonitor(DisplayWrapper.getScreen().get_current_monitor());
  267. let rectChrome = {
  268. x1: rectMonitor.x,
  269. y1: rectMonitor.y,
  270. x2: rectMonitor.width + rectMonitor.x - this._container.get_width(),
  271. y2: rectMonitor.height + rectMonitor.y - this._container.get_height()
  272. };
  273. switch (this._corner) {
  274. case CORNER_TOP_LEFT:
  275. posX = rectChrome.x1;
  276. posY = rectChrome.y1;
  277. break;
  278. case CORNER_BOTTOM_LEFT:
  279. posX = rectChrome.x1;
  280. posY = rectChrome.y2;
  281. break;
  282. case CORNER_BOTTOM_RIGHT:
  283. posX = rectChrome.x2;
  284. posY = rectChrome.y2;
  285. break;
  286. default: // CORNER_TOP_RIGHT:
  287. posX = rectChrome.x2;
  288. posY = rectChrome.y1;
  289. }
  290. this._container.set_position(posX, posY);
  291. },
  292. // Create a window thumbnail and adds it to the container
  293. _setThumbnail: function() {
  294. if (! this._container) return;
  295. this._container.foreach(function(actor) {
  296. actor.destroy();
  297. });
  298. if (! this._window) return;
  299. let mutw = this._window.get_compositor_private();
  300. if (! mutw) return;
  301. let windowTexture = mutw.get_texture();
  302. let [windowWidth, windowHeight] = windowTexture.get_size();
  303. /* To crop the window texture, for now I've found that:
  304. 1. Using a clip rect on Clutter.clone will hide the outside portion but also will KEEP the space along it
  305. 2. The Clutter.clone is stretched to fill all of its room when it's painted, so the transparent area outside
  306. cannot be easily left out by only adjusting the actor size (empty space only gets reproportioned).
  307. My current workaround:
  308. - Define a margin rect by using some proportional [0.0 - 1.0] trimming values for left, right, ... Zero: no trimming 1: all trimmed out
  309. - Set width and height of the Clutter.clone based on the crop rect and apply a translation to anchor it the top left margin
  310. (set_clip_to_allocation must be set true on the container to get rid of the translated texture overflow)
  311. - Ratio of the cropped texture is different from the original one, so this must be compensated with Clutter.clone scale_x/y parameters
  312. Known issues:
  313. - Strongly cropped textual windows like terminals get a little bit blurred. However, I was told this feature
  314. was useful for framed videos to peel off, particularly. So shouldn't affect that much.
  315. Hopefully, some kind guy will soon explain to me how to clone just a portion of the source :D
  316. */
  317. // Get absolute margin values for cropping
  318. let margins = {
  319. left: windowWidth * this.leftCrop,
  320. right: windowWidth * this.rightCrop,
  321. top: windowHeight * this.topCrop,
  322. bottom: windowHeight * this.bottomCrop,
  323. };
  324. // Calculate the size of the cropped rect (based on the 100% window size)
  325. let croppedWidth = windowWidth - (margins.left + margins.right);
  326. let croppedHeight = windowHeight - (margins.top + margins.bottom);
  327. // To mantain a similar thumbnail size whenever the user selects a different window to preview,
  328. // instead of zooming out based on the window size itself, it takes the window screen as a standard unit (= 100%)
  329. let rectMonitor = Main.layoutManager.getWorkAreaForMonitor(DisplayWrapper.getScreen().get_current_monitor());
  330. let targetRatio = rectMonitor.width * this.zoom / windowWidth;
  331. // No magnification allowed (KNOWN ISSUE: there's no height control if used, it still needs optimizing)
  332. if (! SETTING_MAGNIFICATION_ALLOWED && targetRatio > 1.0) {
  333. targetRatio = 1.0;
  334. this._zoom = windowWidth / rectMonitor.width; // do NOT set this.zoom (the encapsulated prop for _zoom) or it will be looping!
  335. }
  336. let thumbnail = new Clutter.Clone({ // list parameters https://www.roojs.org/seed/gir-1.2-gtk-3.0/seed/Clutter.Clone.html
  337. source: windowTexture,
  338. reactive: false,
  339. magnification_filter: Clutter.ScalingFilter.NEAREST, //NEAREST, //TRILINEAR,
  340. translation_x: -margins.left * targetRatio,
  341. translation_y: -margins.top * targetRatio,
  342. // Compensating scales due the different ratio of the cropped window texture
  343. scale_x: windowWidth / croppedWidth,
  344. scale_y: windowHeight / croppedHeight,
  345. width: croppedWidth * targetRatio,
  346. height: croppedHeight * targetRatio,
  347. margin_left: 0,
  348. margin_right: 0,
  349. margin_bottom: 0,
  350. margin_top: 0
  351. });
  352. this._container.add_actor(thumbnail);
  353. this._setPosition();
  354. },
  355. // xCrop properties normalize their opposite counterpart, so that margins won't ever overlap
  356. set leftCrop(value) {
  357. // [0, MAX] range
  358. this._leftCrop = Math.min(MAX_CROP_RATIO, Math.max(0.0, value));
  359. // Decrease the opposite margin if necessary
  360. this._rightCrop = Math.min(this._rightCrop, MAX_CROP_RATIO - this._leftCrop);
  361. this._onParamsChange();
  362. },
  363. set rightCrop(value) {
  364. this._rightCrop = Math.min(MAX_CROP_RATIO, Math.max(0.0, value));
  365. this._leftCrop = Math.min(this._leftCrop, MAX_CROP_RATIO - this._rightCrop);
  366. this._onParamsChange();
  367. },
  368. set topCrop(value) {
  369. this._topCrop = Math.min(MAX_CROP_RATIO, Math.max(0.0, value));
  370. this._bottomCrop = Math.min(this._bottomCrop, MAX_CROP_RATIO - this._topCrop);
  371. this._onParamsChange();
  372. },
  373. set bottomCrop(value) {
  374. this._bottomCrop = Math.min(MAX_CROP_RATIO, Math.max(0.0, value));
  375. this._topCrop = Math.min(this._topCrop, MAX_CROP_RATIO - this._bottomCrop);
  376. this._onParamsChange();
  377. },
  378. get leftCrop() {
  379. return this._leftCrop;
  380. },
  381. get rightCrop() {
  382. return this._rightCrop;
  383. },
  384. get topCrop() {
  385. return this._topCrop;
  386. },
  387. get bottomCrop() {
  388. return this._bottomCrop;
  389. },
  390. set zoom(value) {
  391. this._zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
  392. this._onParamsChange();
  393. },
  394. get zoom() {
  395. return this._zoom;
  396. },
  397. set focusHidden(value) {
  398. this._focusHidden = !!value;
  399. this._adjustVisibility();
  400. },
  401. get focusHidden() {
  402. return this._focusHidden;
  403. },
  404. set corner(value) {
  405. this._corner = (value %= 4) < 0 ? (value + 4) : (value);
  406. this._setPosition();
  407. },
  408. get corner() {
  409. return this._corner;
  410. },
  411. get enabled() {
  412. return !!this._container;
  413. },
  414. get visible() {
  415. return this._container && this._window && this._naturalVisibility;
  416. },
  417. show: function(onComplete) {
  418. this._naturalVisibility = true;
  419. this._adjustVisibility({
  420. onComplete: onComplete
  421. });
  422. },
  423. hide: function(onComplete) {
  424. this._naturalVisibility = false;
  425. this._adjustVisibility({
  426. onComplete: onComplete
  427. });
  428. },
  429. toggle: function(onComplete) {
  430. this._naturalVisibility = !this._naturalVisibility;
  431. this._adjustVisibility({
  432. onComplete: onComplete
  433. });
  434. },
  435. passAway: function() {
  436. this._naturalVisibility = false;
  437. this._adjustVisibility({
  438. onComplete: Lang.bind(this, this.disable)
  439. });
  440. },
  441. get window() {
  442. return this._window;
  443. },
  444. set window(metawindow) {
  445. this.enable();
  446. this._windowSignals.disconnectAll();
  447. this._window = metawindow;
  448. if (metawindow) {
  449. this._windowSignals.tryConnect(metawindow, "unmanaged", Lang.bind(this, this._onWindowUnmanaged));
  450. // Version 3.10 does not support size-changed
  451. this._windowSignals.tryConnect(metawindow, "size-changed", Lang.bind(this, this._setThumbnail));
  452. this._windowSignals.tryConnect(metawindow, "notify::maximized-vertically", Lang.bind(this, this._setThumbnail));
  453. this._windowSignals.tryConnect(metawindow, "notify::maximized-horizontally", Lang.bind(this, this._setThumbnail));
  454. }
  455. this._setThumbnail();
  456. this.emit("window-changed", metawindow);
  457. },
  458. enable: function() {
  459. if (this._container) return;
  460. let isSwitchingWindow = this.enabled;
  461. this._environmentSignals.tryConnect(Main.overview, "showing", Lang.bind(this, this._onOverviewShowing));
  462. this._environmentSignals.tryConnect(Main.overview, "hiding", Lang.bind(this, this._onOverviewHiding));
  463. this._environmentSignals.tryConnect(global.display, "notify::focus-window", Lang.bind(this, this._onNotifyFocusWindow));
  464. this._environmentSignals.tryConnect(DisplayWrapper.getMonitorManager(), "monitors-changed", Lang.bind(this, this._onMonitorsChanged));
  465. this._container = new St.Button({
  466. style_class: "window-corner-preview"
  467. });
  468. // Force content not to overlap, allowing cropping
  469. this._container.set_clip_to_allocation(true);
  470. this._container.connect("enter-event", Lang.bind(this, this._onEnter));
  471. this._container.connect("leave-event", Lang.bind(this, this._onLeave));
  472. // Don't use button-press-event, as set_position conflicts and Gtk would react for enter and leave event of ANY item on the chrome area
  473. this._container.connect("button-release-event", Lang.bind(this, this._onClick));
  474. this._container.connect("scroll-event", Lang.bind(this, this._onScroll));
  475. this._container.visible = false;
  476. Main.layoutManager.addChrome(this._container);
  477. return;
  478. // isSwitchingWindow = false means user only changed window, but preview was on, so does not animate
  479. this._adjustVisibility({
  480. noAnimate: isSwitchingWindow
  481. });
  482. },
  483. disable: function() {
  484. this._windowSignals.disconnectAll();
  485. this._environmentSignals.disconnectAll();
  486. if (! this._container) return;
  487. Main.layoutManager.removeChrome(this._container);
  488. this._container.destroy();
  489. this._container = null;
  490. }
  491. })
  492. Signals.addSignalMethods(WindowCornerPreview.prototype);