Music production session manager https://www.laborejo.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

539 lines
24KB

  1. #! /usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net )
  5. This file is part of the Laborejo Software Suite ( https://www.laborejo.org ).
  6. This application is free software: you can redistribute it and/or modify
  7. it under the terms of the GNU General Public License as published by
  8. the Free Software Foundation, either version 3 of the License, or
  9. (at your option) any later version.
  10. This program is distributed in the hope that it will be useful,
  11. but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. GNU General Public License for more details.
  14. You should have received a copy of the GNU General Public License
  15. along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. import logging; logger = logging.getLogger(__name__); logger.info("import")
  18. #Standard Library
  19. from sys import argv as sysargv
  20. from sys import exit as sysexit
  21. #Third Party
  22. from PyQt5 import QtCore, QtGui, QtWidgets
  23. logger.info(f"PyQt Version: {QtCore.PYQT_VERSION_STR}")
  24. #Engine
  25. from engine.config import METADATA #includes METADATA only. No other environmental setup is executed.
  26. from engine.start import PATHS, qtApp
  27. import engine.api as api #This loads the engine and starts a session.
  28. #Qt
  29. from .systemtray import SystemTray
  30. from .eventloop import EventLoop
  31. from .designer.mainwindow import Ui_MainWindow
  32. from .helper import setPaletteAndFont
  33. from .helper import iconFromString
  34. from .sessiontreecontroller import SessionTreeController
  35. from .opensessioncontroller import OpenSessionController
  36. from .quicksessioncontroller import QuickSessionController
  37. from .quickopensessioncontroller import QuickOpenSessionController
  38. from .projectname import ProjectNameWidget
  39. from .addclientprompt import askForExecutable, updateWordlist
  40. from .waitdialog import WaitDialog
  41. from .resources import *
  42. from .settings import SettingsDialog
  43. api.eventLoop = EventLoop()
  44. #Setup the translator before classes are set up. Otherwise we can't use non-template translation.
  45. #to test use LANGUAGE=de_DE.UTF-8 . not LANG=
  46. language = QtCore.QLocale().languageToString(QtCore.QLocale().language())
  47. logger.info("{}: Language set to {}".format(METADATA["name"], language))
  48. if language in METADATA["supportedLanguages"]:
  49. translator = QtCore.QTranslator()
  50. translator.load(METADATA["supportedLanguages"][language], ":/translations/") #colon to make it a resource URL
  51. qtApp.installTranslator(translator)
  52. else:
  53. """silently fall back to English by doing nothing"""
  54. def nothing(*args):
  55. pass
  56. class RecentlyOpenedSessions(object):
  57. """Class to make it easier handle recently opened session with qt settings, type conversions
  58. limiting the size of the list and uniqueness"""
  59. def __init__(self):
  60. self.data = []
  61. def load(self, dataFromQtSettings):
  62. """Handle qt settings load.
  63. triggered by restoreWindowSettings in mainWindow init"""
  64. if dataFromQtSettings:
  65. for name in dataFromQtSettings:
  66. self.add(name)
  67. def add(self, nsmSessionName:str):
  68. if nsmSessionName in self.data:
  69. #Just sort
  70. self.data.remove(nsmSessionName)
  71. self.data.append(nsmSessionName)
  72. return
  73. self.data.append(nsmSessionName)
  74. if len(self.data) > 3:
  75. self.data.pop(0)
  76. assert len(self.data) <= 3, len(self.data)
  77. def get(self)->list:
  78. """List of nsmSessionName strings"""
  79. sessionList = api.sessionList()
  80. self.data = [n for n in self.data if n in sessionList]
  81. return self.data
  82. def last(self)->str:
  83. """Return the last active session.
  84. Useful for continue-mode command line arg.
  85. """
  86. if self.data:
  87. return self.get()[-1]
  88. else:
  89. return None
  90. class MainWindow(QtWidgets.QMainWindow):
  91. def __init__(self):
  92. super().__init__()
  93. self.qtApp = qtApp
  94. self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program
  95. self.qtApp.setApplicationName(f"{METADATA['name']}")
  96. self.qtApp.setApplicationDisplayName(f"{METADATA['name']}")
  97. self.qtApp.setOrganizationName("Laborejo Software Suite")
  98. self.qtApp.setOrganizationDomain("laborejo.org")
  99. self.qtApp.setApplicationVersion(METADATA["version"])
  100. QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
  101. logger.info("Init MainWindow")
  102. #QtGui.QIcon.setFallbackThemeName("hicolor") #only one, not a list. This is the fallback if the theme can't be found. Not if icons can't be found in a theme.
  103. #iconPaths = QtGui.QIcon.themeSearchPaths()
  104. #iconPaths += ["/usr/share/icons/hicolor", "/usr/share/pixmaps"]
  105. #QtGui.QIcon.setThemeSearchPaths(iconPaths)
  106. logger.info(f"Program icons path: {QtGui.QIcon.themeSearchPaths()}, {QtGui.QIcon.themeName()}")
  107. #Set up the user interface from Designer and other widgets
  108. self.ui = Ui_MainWindow()
  109. self.ui.setupUi(self)
  110. self.fPalBlue = setPaletteAndFont(self.qtApp)
  111. assert self.ui.tabbyCat.currentIndex() == 0, self.ui.tabbyCat.currentIndex() # this is critical. If you left the Qt Designer with the wrong tab open this is the error that happens. It will trigger the tab changed later that will go wrong because setup is not complete yet and you'll get AttributeError
  112. self.ui.mainPageSwitcher.setCurrentIndex(0) #1 is messageLabel 0 is the tab widget
  113. SettingsDialog.loadFromSettingsAndSendToEngine() #set blacklist, whitelist for programdatabase and addtional executable paths for environment
  114. #TODO: Hide information tab until the feature is ready
  115. self.ui.tabbyCat.removeTab(2)
  116. self.programIcons = {} #executableName:QIcon. Filled by self._updateGUIWithCachedPrograms which calls _updateIcons
  117. self.sessionController = SessionController(mainWindow=self)
  118. self.systemTray = SystemTray(mainWindow=self)
  119. self.connectMenu()
  120. self.recentlyOpenedSessions = RecentlyOpenedSessions()
  121. #self.ui.stack_loaded_session is only visible when there is a loaded session and the full view tab is active
  122. #we link the session context menu to the session menu menu.
  123. self.ui.stack_loaded_session.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
  124. self.ui.stack_loaded_session.customContextMenuRequested.connect(self.customContextMenu)
  125. #Api Callbacks
  126. api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed)
  127. api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen)
  128. api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise)
  129. #Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here
  130. api.eventLoop.start()
  131. api.startEngine()
  132. self.restoreWindowSettings() #populates recentlyOpenedSessions
  133. if PATHS["startHidden"] and self.systemTray.available:
  134. logger.info("Starting hidden")
  135. self.toggleVisible(force=False)
  136. else:
  137. logger.info("Starting visible")
  138. self.toggleVisible(force=True)
  139. if PATHS["continueLastSession"]: #will be None if --sesion NAME was given as command line parameter and --continue on top.
  140. continueSession = self.recentlyOpenedSessions.last()
  141. if continueSession:
  142. logger.info(f"Got continue session as command line parameter. Opening: {continueSession}")
  143. api.sessionOpen(continueSession)
  144. else:
  145. logger.info(f"Got continue session as command line parameter but there is no session available.")
  146. #Handle the application data cache. If not present instruct the engine to build one.
  147. #This is also needed by the prompt in sessionController
  148. logger.info("Trying to restore cached program database")
  149. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  150. if settings.contains("programDatabase"):
  151. listOfDicts = settings.value("programDatabase", type=list)
  152. api.setSystemsPrograms(listOfDicts)
  153. logger.info("Restored program database from qt cache to engine")
  154. self._updateGUIWithCachedPrograms()
  155. else: #First or fresh start
  156. #A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready.
  157. logger.info("First run. Instructing engine to build program database")
  158. QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms()
  159. logger.info("Deciding if we run as tray-icon or window")
  160. if not self.isVisible():
  161. text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready")
  162. self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal.
  163. logger.info("Ready for user input. Exec_ Qt.")
  164. qtApp.exec_()
  165. #No code after exec_ except atexit
  166. def tabtest(self):
  167. import subprocess
  168. from time import sleep
  169. #xdotool search --name xeyes
  170. #xdotool search --pid 12345
  171. subprocess.Popen(["patchage"], shell=True, stdin=None, stdout=None, stderr=None, close_fds=True) #parameters are for not waiting
  172. sleep(1)
  173. result = subprocess.run(["xdotool", "search", "--name", "patchage"], stdout=subprocess.PIPE).stdout.decode('utf-8')
  174. if "\n" in result:
  175. windowID = int(result.split("\n")[0])
  176. else:
  177. windowID = int(result)
  178. window = QtGui.QWindow.fromWinId(int(windowID))
  179. window.setFlags(QtCore.Qt.FramelessWindowHint)
  180. widget = QtWidgets.QWidget.createWindowContainer(window)
  181. self.ui.tabbyCat.addTab(widget, "Patchage")
  182. def hideEvent(self, event):
  183. if self.systemTray.available:
  184. super().hideEvent(event)
  185. else:
  186. event.ignore()
  187. def activateAndRaise(self):
  188. self.toggleVisible(force=True)
  189. getattr(self, "raise")() #raise is python syntax. Can't use that directly
  190. self.activateWindow()
  191. text = QtCore.QCoreApplication.translate("mainWindow", "Another GUI tried to launch.")
  192. self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal.
  193. def _updateGUIWithCachedPrograms(self):
  194. logger.info("Updating entire program with cached program lists")
  195. updateWordlist() #addclientprompt.py
  196. self._updateIcons()
  197. self.sessionController.openSessionController.launcherTable.buildPrograms()
  198. self.sessionController.quickOpenSessionController.buildCleanStarterClients(nsmSessionExportDict={}) #wants a dict parameter for callback compatibility, but doesn't use it
  199. def _updateIcons(self):
  200. logger.info("Creating icon database")
  201. programs = api.getSystemPrograms()
  202. self.programIcons.clear()
  203. for entry in programs:
  204. exe = entry["argodejoExec"]
  205. if "icon" in entry:
  206. icon = QtGui.QIcon.fromTheme(entry["icon"])
  207. else:
  208. icon = QtGui.QIcon.fromTheme(exe)
  209. if icon.isNull():
  210. icon = iconFromString(exe)
  211. self.programIcons[exe] = icon
  212. def updateProgramDatabase(self):
  213. """Display a progress-dialog that waits for the database to be build.
  214. Automatically called on first start or when instructed by the user"""
  215. text = QtCore.QCoreApplication.translate("mainWindow", "Updating Program Database.\nThank you for your patience.")
  216. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  217. settings.remove("programDatabase")
  218. logger.info("Asking api to getSystemPrograms while waiting")
  219. diag = WaitDialog(self, text, api.buildSystemPrograms) #save in local var to keep alive
  220. settings.setValue("programDatabase", api.getSystemPrograms())
  221. self._updateGUIWithCachedPrograms()
  222. def reactCallback_sessionClosed(self):
  223. self.setWindowTitle("")
  224. def reactCallback_sessionOpen(self, nsmSessionExportDict):
  225. self.setWindowTitle(nsmSessionExportDict["nsmSessionName"])
  226. self.recentlyOpenedSessions.add(nsmSessionExportDict["nsmSessionName"])
  227. def toggleVisible(self, force:bool=None):
  228. if force is None:
  229. newState = not self.isVisible()
  230. else:
  231. newState = force
  232. if newState:
  233. logger.info("Show")
  234. self.restoreWindowSettings()
  235. self.show()
  236. self.setVisible(True)
  237. else:
  238. logger.info("Hide")
  239. self.storeWindowSettings()
  240. self.hide()
  241. self.setVisible(False)
  242. #self.systemTray.buildContextMenu() #Don't. This crashes Qt through some delayed execution of who knows what. Workaround: tray context menu now say "Show/Hide" and not only the actual state.
  243. def _askBeforeQuit(self, nsmSessionName):
  244. """If you quit while in a session ask what to do.
  245. The TrayIcon context menu uses different functions and directly acts, without a question"""
  246. text = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit but session {} still open").format(nsmSessionName)
  247. informativeText = QtCore.QCoreApplication.translate("AskBeforeQuit", "Do you want to save?")
  248. title = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit")
  249. box = QtWidgets.QMessageBox()
  250. box.setWindowFlag(QtCore.Qt.Popup, True)
  251. box.setIcon(box.Warning)
  252. box.setText(text)
  253. box.setWindowTitle(title)
  254. box.setInformativeText(informativeText)
  255. stay = box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Don't Quit"), box.RejectRole) #0
  256. box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Save"), box.YesRole) #1
  257. box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Discard Changes"), box.DestructiveRole) #2
  258. box.setDefaultButton(stay)
  259. ret = box.exec() #Return values are NOT the button roles.
  260. if ret == 2:
  261. logger.info("Quit: Don't save.")
  262. api.sessionAbort(blocking=True)
  263. return True
  264. elif ret == 1:
  265. logger.info("Quit: Close and Save. Waiting for clients to close.")
  266. api.sessionClose(blocking=True)
  267. return True
  268. else: #Escape, window close through WM etc.
  269. logger.info("Quit: Changed your mind, stay in session.")
  270. return False
  271. def abortAndQuit(self):
  272. """For the context menu. A bit
  273. A bit redundant, but that is ok :)"""
  274. api.sessionAbort(blocking=True)
  275. self._callSysExit()
  276. def closeAndQuit(self):
  277. api.sessionClose(blocking=True)
  278. self._callSysExit()
  279. def _callSysExit(self):
  280. """The process of quitting
  281. After sysexit the atexit handler gets called.
  282. That closes nsmd, if we started ourselves.
  283. """
  284. self.storeWindowSettings()
  285. sysexit(0) #directly afterwards @atexit is handled, but this function does not return.
  286. logging.error("Code executed after sysexit. This message should not have been visible.")
  287. def menuRealQuit(self):
  288. """Called by the menu.
  289. The TrayIcon provides another method of quitting that does not call this function,
  290. but it will call _actualQuit.
  291. """
  292. if api.currentSession():
  293. result = self._askBeforeQuit(api.currentSession())
  294. else:
  295. result = True
  296. if result:
  297. self.storeWindowSettings()
  298. self._callSysExit()
  299. def closeEvent(self, event):
  300. """Window manager close.
  301. Ignore. We use it to send the GUI into hiding."""
  302. event.ignore()
  303. self.toggleVisible(force=False)
  304. def connectMenu(self):
  305. #Control
  306. self.ui.actionRebuild_Program_Database.triggered.connect(self.updateProgramDatabase)
  307. self.ui.actionSettings.triggered.connect(self._reactMenu_settings)
  308. self.ui.actionHide_in_System_Tray.triggered.connect(lambda: self.toggleVisible(force=False))
  309. self.ui.actionMenuQuit.triggered.connect(self.menuRealQuit)
  310. def _reactMenu_settings(self):
  311. widget = SettingsDialog(self) #blocks until closed
  312. if widget.success:
  313. self.updateProgramDatabase()
  314. def customContextMenu(self, qpoint):
  315. pos = QtGui.QCursor.pos()
  316. pos.setY(pos.y() + 5)
  317. self.ui.menuSession.exec_(pos)
  318. def storeWindowSettings(self):
  319. """Window state is not saved in the real save file. That would lead to portability problems
  320. between computers, like different screens and resolutions.
  321. For convenience that means we just use the damned qt settings and save wherever qt wants.
  322. bottom line: get a tiling window manager.
  323. """
  324. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  325. settings.setValue("geometry", self.saveGeometry())
  326. settings.setValue("windowState", self.saveState())
  327. #settings.setValue("visible", self.isVisible()) Deprecated. see restoreWindowSettings
  328. settings.setValue("recentlyOpenedSessions", self.recentlyOpenedSessions.get())
  329. settings.setValue("tab", self.ui.tabbyCat.currentIndex())
  330. def restoreWindowSettings(self):
  331. """opposite of storeWindowSettings. Read there."""
  332. logger.info("Restoring window settings, geometry and recently opened session")
  333. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  334. actions = {
  335. "geometry":self.restoreGeometry,
  336. "windowState":self.restoreState,
  337. "recentlyOpenedSessions":self.recentlyOpenedSessions.load,
  338. "tab": lambda i: self.ui.tabbyCat.setCurrentIndex(int(i)),
  339. }
  340. types = {
  341. "recentlyOpenedSessions": list,
  342. "tab": int,
  343. }
  344. for key in settings.allKeys():
  345. if key in actions: #if not it doesn't matter. this is all uncritical.
  346. if key in types:
  347. actions[key](settings.value(key, type=types[key]))
  348. else:
  349. actions[key](settings.value(key))
  350. #Deprecated. Always open the GUI when started normally, saving minimzed has little value.
  351. #Instead we introduced a command line options and .desktop option to auto-load the last session and start Argodejo GUI hidden.
  352. """
  353. if self.systemTray.available and settings.contains("visible") and settings.value("visible") == "false":
  354. self.setVisible(False)
  355. else:
  356. self.setVisible(True) #This is also the default state if there is no config
  357. """
  358. class SessionController(object):
  359. """Controls the StackWidget that contains the Session Tree, Open Session/Client and their
  360. quick and easy variants.
  361. Can be controlled from up and down the hierarchy.
  362. While all tabs are open at the same time for simplicity we hide the menus when in quick-view.
  363. """
  364. def __init__(self, mainWindow):
  365. super().__init__()
  366. self.mainWindow = mainWindow
  367. self.ui = self.mainWindow.ui
  368. self.sessionTreeController = SessionTreeController(mainWindow=mainWindow)
  369. self.openSessionController = OpenSessionController(mainWindow=mainWindow)
  370. self.quickSessionController = QuickSessionController(mainWindow=mainWindow)
  371. self.quickOpenSessionController = QuickOpenSessionController(mainWindow=mainWindow)
  372. self._connectMenu()
  373. #Callbacks
  374. #api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._switch("open")) #When loading ist done. This takes a while when non-nsm clients are in the session
  375. api.callbacks.sessionClosed.append(lambda: self._setMenuEnabled(None))
  376. api.callbacks.sessionClosed.append(lambda: self._switch("choose")) #The rest is handled by the widget itself. It keeps itself updated, no matter if visible or not.
  377. api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._setMenuEnabled(nsmSessionExportDict))
  378. api.callbacks.sessionOpenLoading.append(lambda nsmSessionExportDict: self._switch("open"))
  379. #Convenience Signals to directly disable the client messages on gui instruction.
  380. #This is purely for speed and preventing the user from sending a signal while the session is shutting down
  381. self.mainWindow.ui.actionSessionAbort.triggered.connect(lambda: self._setMenuEnabled(None))
  382. self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._setMenuEnabled(None))
  383. #GUI signals
  384. self.mainWindow.ui.tabbyCat.currentChanged.connect(self._activeTabChanged)
  385. self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex())
  386. def _activeTabChanged(self, index:int):
  387. """index 0 quick, 1 detailed, 2 info"""
  388. if index == 1: #detailed
  389. state = bool(api.currentSession())
  390. else: #quick and information and future tabs
  391. state = False
  392. self.ui.menuClientNameId.menuAction().setVisible(state) #already deactivated
  393. self.ui.menuSession.menuAction().setVisible(state) #already deactivated
  394. #It is not enough to disable the menu itself. Shortcuts will still work. We need the children!
  395. for action in self.ui.menuSession.actions():
  396. action.setEnabled(state)
  397. if state and not self.openSessionController.clientTabe.clientsTreeWidget.currentItem():
  398. state = False #we wanted to activate, but there is no client selected.
  399. for action in self.ui.menuClientNameId.actions():
  400. action.setEnabled(state)
  401. def _connectMenu(self):
  402. #Session
  403. #Only Active when a session is currently available
  404. self.ui.actionSessionSave.triggered.connect(api.sessionSave)
  405. self.ui.actionSessionAbort.triggered.connect(api.sessionAbort)
  406. self.ui.actionSessionSaveAs.triggered.connect(self._reactMenu_SaveAs) #NSM "Duplicate"
  407. self.ui.actionSessionSaveAndClose.triggered.connect(api.sessionClose)
  408. self.ui.actionShow_All_Clients.triggered.connect(api.clientShowAll)
  409. self.ui.actionHide_All_Clients.triggered.connect(api.clientHideAll)
  410. self.ui.actionSessionAddClient.triggered.connect(lambda: askForExecutable(self.mainWindow)) #Prompt version
  411. def _reactMenu_SaveAs(self):
  412. """Only when a session is open.
  413. We could either check the session controller or the simple one for the name."""
  414. currentName = api.currentSession()
  415. assert currentName
  416. widget = ProjectNameWidget(parent=self.mainWindow, startwith=currentName+"-new")
  417. if widget.result:
  418. api.sessionSaveAs(widget.result)
  419. def _setMenuEnabled(self, nsmSessionExportDictOrNone):
  420. """We receive the sessionDict or None"""
  421. state = bool(nsmSessionExportDictOrNone)
  422. if state:
  423. self.ui.menuSession.setTitle(nsmSessionExportDictOrNone["nsmSessionName"])
  424. self.ui.menuSession.menuAction().setVisible(True)
  425. self.ui.menuClientNameId.menuAction().setVisible(True) #session controller might disable that
  426. else:
  427. self.ui.menuSession.setTitle("Session")
  428. self.ui.menuSession.menuAction().setVisible(False)
  429. self.ui.menuClientNameId.menuAction().setVisible(False) #already deactivated
  430. #self.ui.menuSession.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work.
  431. for action in self.ui.menuSession.actions():
  432. action.setEnabled(state)
  433. #Maybe the tab state overrules everything
  434. self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex())
  435. def _switch(self, page:str):
  436. """Only called by the sub-controllers.
  437. For example when an existing session gets opened"""
  438. if page == "choose":
  439. pageIndex = 0
  440. elif page == "open":
  441. pageIndex = 1
  442. else:
  443. raise ValueError(f"_switch accepts choose or open, not {page}")
  444. self.mainWindow.ui.detailedStackedWidget.setCurrentIndex(pageIndex)
  445. self.mainWindow.ui.quickStackedWidget.setCurrentIndex(pageIndex)