Example #1
0
	def appendMaterial(self, item):
		for i in self.materiallist:
			if item[0] == i[0]:
				error = QErrorMessage(self)
				error.setWindowTitle("提示")
				error.showMessage("{}已存在,请删除原来的再添加".format(item[0]))
				error.show()
				return
		try:
			self.materiallist.append(item)
			self.showTable()
		except Exception as ret:
			print(ret)
Example #2
0
 def login(self):
     """登录函数"""
     acc = self.lineEdit.text()
     pas = self.lineEdit_2.text()
     if acc and pas:
         if CheckLogin(acc, pas).isHave():
             self.login_signal.emit()
             return
     # 登录失败提示
     error = QErrorMessage(self)
     error.setWindowTitle("提示")
     error.showMessage("登录失败,请正确输入后重试!")
     error.show()
Example #3
0
 def checkText(self):
     if not self.textLine.text().isalpha():
         message = QErrorMessage(self)
         message.showMessage('Invalid Entry! Try Again.')
         message.setWindowTitle('Warning')
         message.setWindowIcon(QIcon('error.png'))
     else:
         location = self.textLine.text()
         url = weather_url + location
         global json
         json = requests.get(url).json()
         try:
             json['message']
         except Exception:
             self.obj = ShowWeather()
         else:
             message = QErrorMessage(self)
             message.showMessage('Invalid City Name! Try Again.')
             message.setWindowTitle('Warning')
             message.setWindowIcon(QIcon('error.png'))
Example #4
0
        listener = create_listener()
    except socket.error:  # Good singleinstance is correct (on UNIX)
        otherinstance = True
    else:
        # On windows only singleinstance can be trusted
        otherinstance = True if iswindows else False
    if not otherinstance and not opts.shutdown_running_calibre:
        return run_gui(opts, args, listener, app, gui_debug=gui_debug)

    communicate(opts, args)

    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except Exception as err:
        if not iswindows:
            raise
        tb = traceback.format_exc()
        from PyQt5.Qt import QErrorMessage
        logfile = os.path.join(os.path.expanduser('~'), 'calibre.log')
        if os.path.exists(logfile):
            log = open(logfile).read().decode('utf-8', 'ignore')
            d = QErrorMessage()
            d.showMessage(('<b>Error:</b>%s<br><b>Traceback:</b><br>'
                '%s<b>Log:</b><br>%s')%(unicode(err),
                    unicode(tb).replace('\n', '<br>'),
                    log.replace('\n', '<br>')))
Example #5
0
        otherinstance = True
    else:
        # On windows only singleinstance can be trusted
        otherinstance = True if iswindows else False
    if not otherinstance and not opts.shutdown_running_calibre:
        return run_gui(opts, args, listener, app, gui_debug=gui_debug)

    communicate(opts, args)

    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except Exception as err:
        if not iswindows:
            raise
        tb = traceback.format_exc()
        from PyQt5.Qt import QErrorMessage
        logfile = os.path.join(os.path.expanduser('~'), 'calibre.log')
        if os.path.exists(logfile):
            log = open(logfile).read().decode('utf-8', 'ignore')
            d = QErrorMessage()
            d.showMessage(('<b>Error:</b>%s<br><b>Traceback:</b><br>'
                '%s<b>Log:</b><br>%s')%(unicode(err),
                    unicode(tb).replace('\n', '<br>'),
                    log.replace('\n', '<br>')))


Example #6
0
class ExportKarmaDialog(QDialog):
    def __init__(self, parent, modal=True, flags=Qt.WindowFlags()):
        QDialog.__init__(self, parent, flags)
        self.model = None
        self.setModal(modal)
        self.setWindowTitle("Export Karma annotations")
        lo = QVBoxLayout(self)
        lo.setContentsMargins(10, 10, 10, 10)
        lo.setSpacing(5)
        # file selector
        self.wfile = FileSelector(self,
                                  label="Filename:",
                                  dialog_label="Karma annotations filename",
                                  default_suffix="ann",
                                  file_types="Karma annotations (*.ann)")
        lo.addWidget(self.wfile)
        # selected sources checkbox
        self.wsel = QCheckBox("selected sources only", self)
        lo.addWidget(self.wsel)
        # OK/cancel buttons
        lo.addSpacing(10)
        lo2 = QHBoxLayout()
        lo.addLayout(lo2)
        lo2.setContentsMargins(5, 5, 5, 5)
        self.wokbtn = QPushButton("OK", self)
        self.wokbtn.setMinimumWidth(128)
        self.wokbtn.clicked.connect(self.accept)
        self.wokbtn.setEnabled(False)
        cancelbtn = QPushButton("Cancel", self)
        cancelbtn.setMinimumWidth(128)
        cancelbtn.clicked.connect(self.reject)
        lo2.addWidget(self.wokbtn)
        lo2.addStretch(1)
        lo2.addWidget(cancelbtn)
        self.setMinimumWidth(384)
        # signals
        self.wfile.valid.connect(self.wokbtn.setEnabled)
        # internal state
        self.qerrmsg = QErrorMessage(self)
        self._model_filename = None

    def setModel(self, model):
        self.model = model
        # set the default annotations filename, whenever a new model filename is set
        filename = self.model.filename()
        if filename and filename != self._model_filename:
            self._model_filename = filename
            self.wfile.setFilename(os.path.splitext(filename)[0] + ".ann")

    def accept(self):
        """Tries to export annotations, and closes the dialog if successful."""
        try:
            filename = self.wfile.filename()
            if os.path.exists(filename) and QMessageBox.question(
                    self, "Exporting Karma annotations",
                    "<P>Overwrite the file %s?</P>" % filename, QMessageBox.Yes
                    | QMessageBox.No, QMessageBox.Yes) != QMessageBox.Yes:
                return
            f = open(self.wfile.filename(), "wt")
            f.write('COORD W\nPA STANDARD\nCOLOR GREEN\nFONT hershey12\n')
            # source list
            if self.wsel.isChecked():
                sources = [src for src in self.model.sources if src.selected]
            else:
                sources = self.model.sources
            # calculate basis size for crosses (TODO: replace min_size with something more sensible, as this value is in degrees)
            brightnesses = [
                abs(src.brightness()) for src in sources
                if src.brightness() != 0
            ]
            min_bright = brightnesses and min(brightnesses)
            min_size = 0.01
            # loop over sources
            busy = BusyIndicator()
            for src in sources:
                ra = src.pos.ra / DEG
                dec = src.pos.dec / DEG
                # figure out source size
                if src.brightness() and min_bright:
                    ysize = (math.log10(abs(src.brightness())) -
                             math.log10(min_bright) + 1) * min_size
                else:
                    ysize = min_size
                xsize = ysize / (math.cos(src.pos.dec) or 1)
                # figure out source style
                style, label = self.model.getSourcePlotStyle(src)
                if style:
                    f.write('# %s\n' % src.name)
                    # write symbol for source
                    f.write('COLOR %s\n' % style.symbol_color)
                    if style.symbol == "plus":
                        f.write('CROSS %.12f %.12f %f %f\n' %
                                (ra, dec, xsize, ysize))
                    elif style.symbol == "cross":
                        f.write('CROSS %.12f %.12f %f %f 45\n' %
                                (ra, dec, ysize, ysize))
                    elif style.symbol == "circle":
                        f.write('CIRCLE %.12f %.12f %f\n' % (ra, dec, ysize))
                    elif style.symbol == "dot":
                        f.write('DOT %.12f %.12f\n' % (ra, dec))
                    elif style.symbol == "square":
                        f.write('CBOX %.12f %.12f %f %f\n' %
                                (ra, dec, xsize, ysize))
                    elif style.symbol == "diamond":
                        f.write('CBOX %.12f %.12f %f %f 45\n' %
                                (ra, dec, xsize, ysize))
                    # write label
                    if label:
                        f.write('FONT hershey%d\n' % (style.label_size * 2))
                        f.write('COLOR %s\n' % style.label_color)
                        f.write('TEXT %.12f %.12f %s\n' % (ra, dec, label))
            f.close()
        except IOError as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage(
                "Error writing Karma annotations file %s: %s" %
                (filename, str(err)))
            return
        busy.reset_cursor()
        self.parent().showMessage(
            "Wrote Karma annotations for %d sources to file %s" %
            (len(sources), filename))
        return QDialog.accept(self)
Example #7
0
	def onSave(self):
		"""导出保存"""
		ccompany = self.lineEdit.text().strip()
		cman = self.lineEdit_2.text().strip()
		cphone = self.lineEdit_3.text().replace('-', '').strip()
		caddress = self.lineEdit_4.text().strip()
		csigndate = self.lineEdit_6.text()
		cgive = self.lineEdit_7.text()
		cpaydate = self.lineEdit_11.text()
		myname = self.lineEdit_8.text().strip()
		myphone = self.lineEdit_9.text().replace('-', '')
		myaddres = self.lineEdit_10.text().strip()
		cpaymethod = self.comboBox.currentText()
		ctotalprice = self.lineEdit_5.text()
		cmaterials = ""
		for i in self.materiallist:
			cmaterials += "{}x{}x{};".format(i[0], i[1], i[2])
		cmaterials = cmaterials[:-1]
		print(cmaterials)

		if ccompany and cman and cphone and caddress and csigndate and cgive and cpaydate and myname and myphone and myaddres and cpaymethod and ctotalprice and cmaterials:
			cid = "H{}{}".format(csigndate.replace('-', ''), getToday(csigndate))
			print(cid)
			dd = [cid, cmaterials, ctotalprice, cpaymethod, ccompany, cman, cphone, caddress, csigndate, cgive, myname, myphone, myaddres, cpaydate]
			try:
				sc = SaveContract(dd)
				sc.saveContract()
				# 制作合同
				f = open("resource/static/contractmodel.html", 'r', encoding="utf-8")
				m = f.read()
				f.close()
				temp = ""
				cmaterials = cmaterials.split("x")[0]
				l = [cid, cmaterials, ctotalprice, cpaymethod, ccompany, cman, cphone, caddress, csigndate, cgive, cpaydate]
				n = 0
				for i in m:
					if i == "{":
						temp += "{}".format(l[n])
						n += 1
					else:
						temp += i
				with open('resource/static/contractres.html', 'w', encoding='utf-8') as f:
					f.write(temp)
					print("制作完成")
				# 初始化
				self.materiallist = list()
				self.showTable()
				self.lineEdit.clear()
				self.lineEdit_2.clear()
				self.lineEdit_3.clear()
				self.lineEdit_4.clear()
				self.lineEdit_5.clear()
				self.lineEdit_6.clear()
				self.lineEdit_7.clear()
				self.lineEdit_8.clear()
				self.lineEdit_9.clear()
				self.lineEdit_10.clear()
				self.lineEdit_11.clear()
				self.previewpane = PreviewPane("resource/static/contractres.html")
				self.previewpane.show()
			except Exception as ret:
				print(ret)
		else:
			mes = QErrorMessage(self)
			mes.setWindowTitle("提示")
			mes.showMessage("请填写完整信息!")
			mes.show()
Example #8
0
    except socket.error:  # Good singleinstance is correct (on UNIX)
        otherinstance = True
    else:
        # On windows only singleinstance can be trusted
        otherinstance = True if iswindows else False
    if not otherinstance and not opts.shutdown_running_calibre:
        return run_gui(opts, args, listener, app, gui_debug=gui_debug)

    communicate(opts, args)

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as err:
        if not iswindows:
            raise
        tb = traceback.format_exc()
        from PyQt5.Qt import QErrorMessage

        logfile = os.path.join(os.path.expanduser("~"), "calibre.log")
        if os.path.exists(logfile):
            log = open(logfile).read().decode("utf-8", "ignore")
            d = QErrorMessage()
            d.showMessage(
                ("<b>Error:</b>%s<br><b>Traceback:</b><br>" "%s<b>Log:</b><br>%s")
                % (unicode(err), unicode(tb).replace("\n", "<br>"), log.replace("\n", "<br>"))
            )
Example #9
0
class RestoreImageDialog(QDialog):
    def __init__(self, parent, modal=True, flags=Qt.WindowFlags()):
        QDialog.__init__(self, parent, flags)
        self.model = None
        self.setModal(modal)
        self.setWindowTitle("Restore model into image")
        lo = QVBoxLayout(self)
        lo.setContentsMargins(10, 10, 10, 10)
        lo.setSpacing(5)
        # file selector
        self.wfile_in = FileSelector(self,
                                     label="Input FITS file:",
                                     dialog_label="Input FITS file",
                                     default_suffix="fits",
                                     file_types="FITS files (*.fits *.FITS)",
                                     file_mode=QFileDialog.ExistingFile)
        lo.addWidget(self.wfile_in)
        self.wfile_out = FileSelector(self,
                                      label="Output FITS file:",
                                      dialog_label="Output FITS file",
                                      default_suffix="fits",
                                      file_types="FITS files (*.fits *.FITS)",
                                      file_mode=QFileDialog.AnyFile)
        lo.addWidget(self.wfile_out)
        # beam size
        lo1 = QHBoxLayout()
        lo.addLayout(lo1)
        lo1.setContentsMargins(0, 0, 0, 0)
        lo1.addWidget(QLabel("Restoring beam FWHM, major axis:", self))
        self.wbmaj = QLineEdit(self)
        lo1.addWidget(self.wbmaj)
        lo1.addWidget(QLabel("\"     minor axis:", self))
        self.wbmin = QLineEdit(self)
        lo1.addWidget(self.wbmin)
        lo1.addWidget(QLabel("\"     P.A.:", self))
        self.wbpa = QLineEdit(self)
        lo1.addWidget(self.wbpa)
        lo1.addWidget(QLabel("\u00B0", self))
        for w in self.wbmaj, self.wbmin, self.wbpa:
            w.setValidator(QDoubleValidator(self))
        lo1 = QHBoxLayout()
        lo.addLayout(lo1)
        lo1.setContentsMargins(0, 0, 0, 0)
        self.wfile_psf = FileSelector(
            self,
            label="Set restoring beam by fitting PSF image:",
            dialog_label="PSF FITS file",
            default_suffix="fits",
            file_types="FITS files (*.fits *.FITS)",
            file_mode=QFileDialog.ExistingFile)
        lo1.addSpacing(32)
        lo1.addWidget(self.wfile_psf)
        # selection only
        self.wselonly = QCheckBox("restore selected model sources only", self)
        lo.addWidget(self.wselonly)
        # OK/cancel buttons
        lo.addSpacing(10)
        lo2 = QHBoxLayout()
        lo.addLayout(lo2)
        lo2.setContentsMargins(0, 0, 0, 0)
        lo2.setContentsMargins(5, 5, 5, 5)
        self.wokbtn = QPushButton("OK", self)
        self.wokbtn.setMinimumWidth(128)
        self.wokbtn.clicked.connect(self.accept)
        self.wokbtn.setEnabled(False)
        cancelbtn = QPushButton("Cancel", self)
        cancelbtn.setMinimumWidth(128)
        cancelbtn.clicked.connect(self.reject)
        lo2.addWidget(self.wokbtn)
        lo2.addStretch(1)
        lo2.addWidget(cancelbtn)
        self.setMinimumWidth(384)
        # signals
        self.wfile_in.filenameSelected.connect(self._fileSelected)
        self.wfile_in.filenameSelected.connect(self._inputFileSelected)
        self.wfile_out.filenameSelected.connect(self._fileSelected)
        self.wfile_psf.filenameSelected.connect(self._psfFileSelected)
        # internal state
        self.qerrmsg = QErrorMessage(self)

    def setModel(self, model):
        nsel = len([src for src in model.sources if src.selected])
        self.wselonly.setVisible(nsel > 0 and nsel < len(model.sources))
        self.model = model
        self._fileSelected(None)

    def _fileSelected(self, filename):
        self.wokbtn.setEnabled(
            bool(self.wfile_in.filename() and self.wfile_out.filename()))

    def _inputFileSelected(self, filename):
        if filename:
            try:
                header = pyfits.open(filename)[0].header
            except Exception as err:
                self.qerrmsg.showMessage("Error reading FITS file %s: %s" %
                                         (filename, str(err)))
                self.wfile_in.setFilename("")
                return
            # try to get beam extents
            gx, gy, grot = [
                header.get(x, None) for x in ('BMAJ', 'BMIN', 'BPA')
            ]
            if all([x is not None for x in (gx, gy, grot)]):
                # if beam size is already set, ask before overwriting
                print([
                    str(x.text()) for x in (self.wbmaj, self.wbmin, self.wbpa)
                ])
                if any([bool(str(x.text())) for x in (self.wbmaj, self.wbmin, self.wbpa)]) and \
                        QMessageBox.question(self, "Set restoring beam",
                                             "Also reset restoring beam size from this FITS file?",
                                             QMessageBox.Yes | QMessageBox.No) != QMessageBox.Yes:
                    return
                self.wbmaj.setText("%.2f" % (gx * 3600))
                self.wbmin.setText("%.2f" % (gy * 3600))
                self.wbpa.setText("%.2f" % grot)

    def _psfFileSelected(self, filename):
        busy = BusyIndicator()
        filename = str(filename)
        self.parent().showMessage("Fitting gaussian to PSF file %s" % filename)
        try:
            bmaj, bmin, pa = [x / DEG for x in Imaging.fitPsf(filename)]
        except Exception as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage("Error fitting PSF file %s: %s" %
                                     (filename, str(err)))
            return
        bmaj *= 3600 * Imaging.FWHM
        bmin *= 3600 * Imaging.FWHM
        self.wbmaj.setText(str(bmaj))
        self.wbmin.setText(str(bmin))
        self.wbpa.setText(str(pa))
        busy.reset_cursor()

    def accept(self):
        """Tries to restore the image, and closes the dialog if successful."""
        # get list of sources to restore
        sources = self.model.sources
        sel_sources = [src for src in sources if src.selected]
        if len(sel_sources) > 0 and len(sel_sources) < len(
                sources) and self.wselonly.isChecked():
            sources = sel_sources
        if not sources:
            self.qerrmsg.showMessage("No sources to restore.")
            return
        busy = BusyIndicator()
        # get filenames
        infile = self.wfile_in.filename()
        outfile = self.wfile_out.filename()
        self.parent().showMessage(
            "Restoring %d model sources to image %s, writing to %s" %
            (len(sources), infile, outfile))
        # read fits file
        try:
            input_hdu = pyfits.open(infile)[0]
        except Exception as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage("Error reading FITS file %s: %s" %
                                     (infile, str(err)))
            return
        # get beam sizes
        try:
            bmaj = float(str(self.wbmaj.text()))
            bmin = float(str(self.wbmin.text()))
            pa = float(str(self.wbpa.text()) or "0")
        except Exception as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage("Invalid beam size specified")
            return
        bmaj = bmaj / (Imaging.FWHM * 3600) * DEG
        bmin = bmin / (Imaging.FWHM * 3600) * DEG
        pa = pa * DEG
        # restore
        try:
            Imaging.restoreSources(input_hdu, sources, bmaj, bmin, pa)
        except Exception as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage("Error restoring model into image: %s" %
                                     str(err))
            return
        # save fits file
        try:
            input_hdu.writeto(outfile, overwrite=True)
        except Exception as err:
            busy.reset_cursor()
            self.qerrmsg.showMessage("Error writing FITS file %s: %s" %
                                     (outfile, str(err)))
            return
        self.parent().loadImage(outfile)
        busy.reset_cursor()
        return QDialog.accept(self)
Example #10
0
class MainWindow(QMainWindow):

    isUpdated = pyqtSignal(bool)
    hasSkyModel = pyqtSignal(bool)
    hasSelection = pyqtSignal(bool)
    modelChanged = pyqtSignal(object)
    closing = pyqtSignal()
    signalShowMessage = pyqtSignal([str, int], [str])
    signalShowErrorMessage = pyqtSignal([str], [str, int])
    ViewModelColumns = [
        "name", "RA", "Dec", "type", "Iapp", "I", "Q", "U", "V", "RM", "spi",
        "shape"
    ]

    def __init__(self,
                 parent,
                 max_width=None,
                 max_height=None,
                 hide_on_close=False):
        QMainWindow.__init__(self, parent)
        self.signalShowMessage.connect(self.showMessage,
                                       type=Qt.QueuedConnection)
        self.signalShowErrorMessage.connect(self.showErrorMessage,
                                            type=Qt.QueuedConnection)
        self.setWindowIcon(pixmaps.tigger_starface.icon())
        self._currier = PersistentCurrier()
        self.hide()
        # init column constants
        for icol, col in enumerate(self.ViewModelColumns):
            setattr(self, "Column%s" % col.capitalize(), icol)
        # init GUI
        self.setWindowTitle("Tigger")
        self.setWindowIcon(QIcon(pixmaps.purr_logo.pm()))
        # central widget setup
        self.cw = QWidget(self)
        # The actual min width of the control dialog is ~396
        self._ctrl_dialog_min_size = 400  # approx value
        # The actual min width of the profile/zoom windows is ~256
        self._profile_and_zoom_widget_min_size = 300  # approx value
        # set usable screen space (90% of available)
        self.max_width = max_width
        self.max_height = max_height
        self.setCentralWidget(self.cw)
        cwlo = QVBoxLayout(self.cw)
        cwlo.setContentsMargins(5, 5, 5, 5)
        # make splitter
        spl1 = self._splitter1 = QSplitter(Qt.Vertical, self.cw)
        spl1.setOpaqueResize(False)
        cwlo.addWidget(spl1)
        # Create listview of LSM entries
        self.tw = SkyModelTreeWidget(spl1)
        self.tw.hide()

        # split bottom pane
        spl2 = self._splitter2 = QSplitter(Qt.Horizontal, spl1)
        spl2.setOpaqueResize(False)
        self._skyplot_stack = QWidget(spl2)
        self._skyplot_stack_lo = QVBoxLayout(self._skyplot_stack)
        self._skyplot_stack_lo.setContentsMargins(0, 0, 0, 0)

        # add plot
        self.skyplot = SkyModelPlotter(self._skyplot_stack, self)
        self.skyplot.resize(128, 128)
        self.skyplot.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
        self._skyplot_stack_lo.addWidget(self.skyplot, 1000)
        self.skyplot.hide()
        self.skyplot.imagesChanged.connect(self._imagesChanged)
        self.skyplot.setupShowMessages(self.signalShowMessage)
        self.skyplot.setupShowErrorMessages(self.signalShowErrorMessage)

        self._grouptab_stack = QWidget(spl2)
        self._grouptab_stack_lo = lo = QVBoxLayout(self._grouptab_stack)
        self._grouptab_stack_lo.setContentsMargins(0, 0, 0, 0)
        # add groupings table
        self.grouptab = ModelGroupsTable(self._grouptab_stack)
        self.grouptab.setSizePolicy(QSizePolicy.Preferred,
                                    QSizePolicy.Preferred)
        self.hasSkyModel.connect(self.grouptab.setEnabled)
        lo.addWidget(self.grouptab, 1000)
        lo.addStretch(1)
        self.grouptab.hide()

        # add image controls -- parentless for now (setLayout will reparent them anyway)
        self.imgman = ImageManager()
        self.imgman.setMainWindow(self)
        self.imgman.setShowMessageSignal(self.signalShowMessage)
        self.imgman.setShowErrorMessageSignal(self.signalShowErrorMessage)
        self.skyplot.setImageManager(self.imgman)
        self.imgman.imagesChanged.connect(self._imagesChanged)

        # enable status line
        self.statusBar().show()
        # Create and populate main menu
        menubar = self.menuBar()
        # File menu
        file_menu = menubar.addMenu("&File")
        qa_open = file_menu.addAction("&Open model...", self._openFileCallback,
                                      Qt.CTRL + Qt.Key_O)
        qa_merge = file_menu.addAction("&Merge in model...",
                                       self._mergeFileCallback,
                                       Qt.CTRL + Qt.SHIFT + Qt.Key_O)
        self.hasSkyModel.connect(qa_merge.setEnabled)
        file_menu.addSeparator()
        qa_save = file_menu.addAction("&Save model", self.saveFile,
                                      Qt.CTRL + Qt.Key_S)
        self.isUpdated.connect(qa_save.setEnabled)
        qa_save_as = file_menu.addAction("Save model &as...", self.saveFileAs)
        self.hasSkyModel.connect(qa_save_as.setEnabled)
        qa_save_selection_as = file_menu.addAction("Save selection as...",
                                                   self.saveSelectionAs)
        self.hasSelection.connect(qa_save_selection_as.setEnabled)
        file_menu.addSeparator()
        qa_close = file_menu.addAction("&Close model", self.closeFile,
                                       Qt.CTRL + Qt.Key_W)
        self.hasSkyModel.connect(qa_close.setEnabled)
        qa_quit = file_menu.addAction("Quit", self.close, Qt.CTRL + Qt.Key_Q)

        # Image menu
        menubar.addMenu(self.imgman.getMenu())
        # Plot menu
        menubar.addMenu(self.skyplot.getMenu())

        # LSM Menu
        em = QMenu("&LSM", self)
        self._qa_em = menubar.addMenu(em)
        self._qa_em.setVisible(False)
        self.hasSkyModel.connect(self._qa_em.setVisible)
        self._column_view_menu = QMenu("&Show columns", self)
        self._qa_cv_menu = em.addMenu(self._column_view_menu)
        em.addSeparator()
        em.addAction("Select &all", self._selectAll, Qt.CTRL + Qt.Key_A)
        em.addAction("U&nselect all", self._unselectAll, Qt.CTRL + Qt.Key_N)
        em.addAction("&Invert selection", self._selectInvert,
                     Qt.CTRL + Qt.Key_I)
        em.addAction("Select b&y attribute...", self._showSourceSelector,
                     Qt.CTRL + Qt.Key_Y)
        em.addSeparator()
        qa_add_tag = em.addAction("&Tag selection...", self.addTagToSelection,
                                  Qt.CTRL + Qt.Key_T)
        self.hasSelection.connect(qa_add_tag.setEnabled)
        qa_del_tag = em.addAction("&Untag selection...",
                                  self.removeTagsFromSelection,
                                  Qt.CTRL + Qt.Key_U)
        self.hasSelection.connect(qa_del_tag.setEnabled)
        qa_del_sel = em.addAction("&Delete selection", self._deleteSelection)
        self.hasSelection.connect(qa_del_sel.setEnabled)

        # Tools menu
        tm = self._tools_menu = QMenu("&Tools", self)
        self._qa_tm = menubar.addMenu(tm)
        self._qa_tm.setVisible(False)
        self.hasSkyModel.connect(self._qa_tm.setVisible)

        # Help menu
        menubar.addSeparator()
        hm = self._help_menu = menubar.addMenu("&Help")
        hm.addAction("&About...", self._showAboutDialog)
        self._about_dialog = None

        # message handlers
        self.qerrmsg = QErrorMessage(self)

        # set initial state
        self.setAcceptDrops(True)
        self.model = None
        self.filename = None
        self._display_filename = None
        self._open_file_dialog = self._merge_file_dialog = self._save_as_dialog = self._save_sel_as_dialog = self._open_image_dialog = None
        self.isUpdated.emit(False)
        self.hasSkyModel.emit(False)
        self.hasSelection.emit(False)
        self._exiting = False

        # set initial layout
        self._current_layout = None
        self.setLayout(self.LayoutEmpty)
        dprint(1, "init complete")

    # layout identifiers
    LayoutEmpty = "empty"
    LayoutImage = "image"
    LayoutImageModel = "model"
    LayoutSplit = "split"

    def _getFilenamesFromDropEvent(self, event):
        """Checks if drop event is valid (i.e. contains a local URL to a FITS file), and returns list of filenames contained therein."""
        dprint(1, "drop event:", event.mimeData().text())
        if not event.mimeData().hasUrls():
            dprint(1, "drop event: no urls")
            return None
        filenames = []
        for url in event.mimeData().urls():
            name = str(url.toLocalFile())
            dprint(2, "drop event: name is", name)
            if name and Images.isFITS(name):
                filenames.append(name)
        dprint(2, "drop event: filenames are", filenames)
        return filenames

    def dragEnterEvent(self, event):
        if self._getFilenamesFromDropEvent(event):
            dprint(1, "drag-enter accepted")
            event.acceptProposedAction()
        else:
            dprint(1, "drag-enter rejected")

    def dropEvent(self, event):
        busy = None
        filenames = self._getFilenamesFromDropEvent(event)
        dprint(1, "dropping", filenames)
        if filenames:
            event.acceptProposedAction()
            busy = BusyIndicator()
            for name in filenames:
                self.imgman.loadImage(name)
        if busy is not None:
            busy.reset_cursor()

    def saveSizes(self):
        if self._current_layout is not None:
            dprint(1, "saving sizes for layout", self._current_layout)
            # save main window size and splitter dimensions
            sz = self.size()
            Config.set('%s-main-window-width' % self._current_layout,
                       sz.width())
            Config.set('%s-main-window-height' % self._current_layout,
                       sz.height())
            for spl, name in ((self._splitter1, "splitter1"), (self._splitter2,
                                                               "splitter2")):
                ssz = spl.sizes()
                for i, sz in enumerate(ssz):
                    Config.set(
                        '%s-%s-size%d' % (self._current_layout, name, i), sz)

    def loadSizes(self):
        if self._current_layout is not None:
            dprint(1, "loading sizes for layout", self._current_layout)
            # get main window size and splitter dimensions
            w = Config.getint('%s-main-window-width' % self._current_layout, 0)
            h = Config.getint('%s-main-window-height' % self._current_layout,
                              0)
            dprint(2, "window size is", w, h)
            if not (w and h):
                return None
            self.resize(QSize(w, h))
            for spl, name in (self._splitter1, "splitter1"), (self._splitter2,
                                                              "splitter2"):
                ssz = [
                    Config.getint(
                        '%s-%s-size%d' % (self._current_layout, name, i), -1)
                    for i in (0, 1)
                ]
                dprint(2, "splitter", name, "sizes", ssz)
                if all([sz >= 0 for sz in ssz]):
                    spl.setSizes(ssz)
                else:
                    return None
        return True

    def setLayout(self, layout):
        """Changes the current window layout. Restores sizes etc. from config file."""
        if self._current_layout is layout:
            return
        dprint(1, "switching to layout", layout)
        # save sizes to config file
        self.saveSizes()
        # remove imgman widget from all layouts
        for lo in self._skyplot_stack_lo, self._grouptab_stack_lo:
            if lo.indexOf(self.imgman) >= 0:
                lo.removeWidget(self.imgman)
        # assign it to appropriate parent and parent's layout
        if layout is self.LayoutImage:
            lo = self._skyplot_stack_lo
            self.setMaximumSize(self.max_width, self.max_height)
            self.setBaseSize(self.max_width, self.max_height)
            size_policy = QSizePolicy()
            size_policy.setVerticalPolicy(QSizePolicy.Minimum)
            size_policy.setHorizontalPolicy(QSizePolicy.Expanding)
            self.setSizePolicy(size_policy)
            # set central widget size - workaround for bug #164
            # self.cw.setFixedSize(self.max_width - self._ctrl_dialog_min_size - self._profile_and_zoom_widget_min_size, self.max_height)
            # self.cw.setGeometry(0, self.max_width - self._ctrl_dialog_min_size - self._profile_and_zoom_widget_min_size / 2,
            # self.max_width - self._ctrl_dialog_min_size - self._profile_and_zoom_widget_min_size, self.max_height)
        elif layout is self.LayoutEmpty:
            lo = self._skyplot_stack_lo
        else:
            lo = self._grouptab_stack_lo
        self.imgman.setParent(lo.parentWidget())
        lo.addWidget(self.imgman, 0)
        # show/hide panels
        if layout is self.LayoutEmpty:
            self.tw.hide()
            self.grouptab.hide()
            # self.skyplot.show()
        elif layout is self.LayoutImage:
            self.tw.hide()
            self.grouptab.hide()
            self.skyplot.show()
            # setup dockable state from config file
            if Config.getbool('livezoom-show'):
                self.skyplot._livezoom.setVisible(True)
                self.skyplot._dockable_livezoom.setVisible(True)
                self.addDockWidget(Qt.LeftDockWidgetArea,
                                   self.skyplot._dockable_livezoom)
            if Config.getbool('liveprofile-show'):
                self.skyplot._liveprofile.setVisible(True)
                self.skyplot._dockable_liveprofile.setVisible(True)
                self.addDockWidget(Qt.LeftDockWidgetArea,
                                   self.skyplot._dockable_liveprofile)

            # resize dock areas
            widget_list = self.findChildren(QDockWidget)
            size_list = []
            result = []
            for widget in widget_list:
                if not isinstance(widget.bind_widget, ImageControlDialog):
                    size_list.append(widget.bind_widget.width())
                    result.append(widget)
                    dprint(2, f"{widget} width {widget.width()}")
                    dprint(
                        2,
                        f"{widget} bind_widget width {widget.bind_widget.width()}"
                    )
                    if isinstance(widget.bind_widget, LiveImageZoom):
                        widget.bind_widget.setMinimumWidth(widget.width())
            widget_list = result
            # resize dock areas
            self.resizeDocks(widget_list, size_list, Qt.Horizontal)
        elif layout is self.LayoutImageModel:
            self.tw.show()
            self.grouptab.show()
            self.skyplot.show()
        # reload sizes
        self._current_layout = layout
        if not self.loadSizes():
            dprint(1, "no sizes loaded, setting defaults")
            if layout is self.LayoutEmpty:
                self.resize(QSize(512, 256))
            elif layout is self.LayoutImage:
                self.resize(QSize(512, 512))
                self._splitter2.setSizes([512, 0])
            elif layout is self.LayoutImageModel:
                self.resize(QSize(1024, 512))
                self._splitter1.setSizes([256, 256])
                self._splitter2.setSizes([256, 256])

    def enableUpdates(self, enable=True):
        """Enables updates of the child widgets. Usually called after startup is completed (i.e. all data loaded)"""
        self.skyplot.enableUpdates(enable)
        if enable:
            if self.model:
                self.setLayout(self.LayoutImageModel)
            elif self.imgman.getImages():
                self.setLayout(self.LayoutImage)
            else:
                self.setLayout(self.LayoutEmpty)
            self.show()

    def _showAboutDialog(self):
        if not self._about_dialog:
            self._about_dialog = AboutDialog.AboutDialog(self)
        self._about_dialog.show()

    def addTool(self, name, callback):
        """Adds a tool to the Tools menu"""
        self._tools_menu.addAction(
            name, self._currier.curry(self._callTool, callback))

    def _callTool(self, callback):
        callback(self, self.model)

    def _imagesChanged(self):
        """Called when the set of loaded images has changed"""
        if self.imgman.getImages():
            if self._current_layout is self.LayoutEmpty:
                self.setLayout(self.LayoutImage)
        else:
            if not self.model:
                self.setLayout(self.LayoutEmpty)

    def _selectAll(self):
        if not self.model:
            return
        busy = BusyIndicator()
        for src in self.model.sources:
            src.selected = True
        self.model.emitSelection(self)
        busy.reset_cursor()

    def _unselectAll(self):
        if not self.model:
            return
        busy = BusyIndicator()
        for src in self.model.sources:
            src.selected = False
        self.model.emitSelection(self)
        busy.reset_cursor()

    def _selectInvert(self):
        if not self.model:
            return
        busy = BusyIndicator()
        for src in self.model.sources:
            src.selected = not src.selected
        self.model.emitSelection(self)
        busy.reset_cursor()

    def _deleteSelection(self):
        unselected = [src for src in self.model.sources if not src.selected]
        nsel = len(self.model.sources) - len(unselected)
        if QMessageBox.question(
                self, "Delete selection",
                """<P>Really deleted %d selected source(s)?
        %d unselected sources will remain in the model.</P>""" %
            (nsel, len(unselected)), QMessageBox.Ok | QMessageBox.Cancel,
                QMessageBox.Cancel) != QMessageBox.Ok:
            return
        self.model.setSources(unselected)
        self.signalShowMessage[str].emit("""Deleted %d sources""" % nsel)
        self.model.emitUpdate(SkyModel.SkyModel.UpdateAll, origin=self)

    def _showSourceSelector(self):
        TigGUI.Tools.source_selector.show_source_selector(self, self.model)

    def _updateModelSelection(self, num, origin=None):
        """Called when the model selection has been updated."""
        self.hasSelection.emit(bool(num))

    import Tigger.Models.Formats
    _formats = [f[1] for f in Tigger.Models.Formats.listFormatsFull()]

    _load_file_types = [(doc, ["*" + ext for ext in extensions], load)
                        for load, save, doc, extensions in _formats if load]
    _save_file_types = [(doc, ["*" + ext for ext in extensions], save)
                        for load, save, doc, extensions in _formats if save]

    def showMessage(self, msg, time=3000):
        self.statusBar().showMessage(msg, time)

    def showErrorMessage(self, msg, time=3000):
        self.qerrmsg.showMessage(msg)

    def loadImage(self, filename):
        return self.imgman.loadImage(filename)

    def setModel(self, model):
        if model is not None:
            self.modelChanged.emit(model)
        if model:
            self.model = model
            self.hasSkyModel.emit(True)
            self.hasSelection.emit(False)
            self.isUpdated.emit(False)
            self.model.enableSignals()
            self.model.connect("updated", self._indicateModelUpdated)
            self.model.connect("selected", self._updateModelSelection)
            # pass to children
            self.tw.setModel(self.model)
            self.grouptab.setModel(self.model)
            self.skyplot.setModel(self.model)
            # add items to View menu
            self._column_view_menu.clear()
            self.tw.addColumnViewActionsTo(self._column_view_menu)
        else:
            self.model = None
            self.setWindowTitle("Tigger")
            self.hasSelection.emit(False)
            self.isUpdated.emit(False)
            self.hasSkyModel.emit(False)
            self.tw.clear()
            self.grouptab.clear()
            self.skyplot.setModel(None)

    def _openFileCallback(self):
        if not self._open_file_dialog:
            filters = ";;".join([
                "%s (%s)" % (name, " ".join(patterns))
                for name, patterns, func in self._load_file_types
            ])
            dialog = self._open_file_dialog = QFileDialog(
                self, "Open sky model", ".", filters)
            dialog.setFileMode(QFileDialog.ExistingFile)
            dialog.setModal(True)
            dialog.filesSelected['QStringList'].connect(self.openFile)
        self._open_file_dialog.exec_()
        return

    def _mergeFileCallback(self):
        if not self._merge_file_dialog:
            filters = ";;".join([
                "%s (%s)" % (name, " ".join(patterns))
                for name, patterns, func in self._load_file_types
            ])
            dialog = self._merge_file_dialog = QFileDialog(
                self, "Merge in sky model", ".", filters)
            dialog.setFileMode(QFileDialog.ExistingFile)
            dialog.setModal(True)
            dialog.filesSelected['QStringList'].connect(
                self._currier.curry(self.openFile, merge=True))
        self._merge_file_dialog.exec_()
        return

    def openFile(self, _filename=None, _format=None, _merge=False, _show=True):
        # check that we can close existing model
        if not _merge and not self._canCloseExistingModel():
            return False
        if isinstance(_filename, QStringList):
            _filename = _filename[0]
        _filename = str(_filename)
        # try to determine the file type
        filetype, import_func, export_func, doc = Tigger.Models.Formats.resolveFormat(
            _filename, _format)
        if import_func is None:
            self.signalShowErrorMessage.emit(
                """Error loading model file %s: unknown file format""" %
                _filename)
            return
        # try to load the specified file
        busy = BusyIndicator()
        self.signalShowMessage.emit(
            """Reading %s file %s""" % (filetype, _filename), 3000)
        QApplication.flush()
        try:
            model = import_func(_filename)
            model.setFilename(_filename)
        except:
            busy.reset_cursor()
            self.signalShowErrorMessage.emit(
                """Error loading '%s' file %s: %s""" %
                (filetype, _filename, str(sys.exc_info()[1])))
            return
        else:
            # set the layout
            if _show:
                self.setLayout(self.LayoutImageModel)
            # add to content
            if _merge and self.model:
                self.model.addSources(model.sources)
                self.signalShowMessage.emit(
                    """Merged in %d sources from '%s' file %s""" %
                    (len(model.sources), filetype, _filename), 3000)
                self.model.emitUpdate(SkyModel.SkyModel.UpdateAll)
            else:
                print("""Loaded %d sources from '%s' file %s""" %
                      (len(model.sources), filetype, _filename))
                self.signalShowMessage.emit(
                    """Loaded %d sources from '%s' file %s""" %
                    (len(model.sources), filetype, _filename), 3000)
                self._display_filename = os.path.basename(_filename)
                self.setModel(model)
                self._indicateModelUpdated(updated=False)
                # only set self.filename if an export function is available for this format. Otherwise set it to None, so that trying to save
                # the file results in a save-as operation (so that we don't save to a file in an unsupported format).
                self.filename = _filename if export_func else None
        finally:
            busy.reset_cursor()

    def closeEvent(self, event):
        dprint(1, "closing")
        self._exiting = True
        self.saveSizes()
        if not self.closeFile():
            self._exiting = False
            event.ignore()
            return
        self.skyplot.close()
        self.imgman.close()
        self.closing.emit()
        dprint(1, "invoking os._exit(0)")
        os._exit(0)
        QMainWindow.closeEvent(self, event)

    def _canCloseExistingModel(self):
        # save model if modified
        if self.model and self._model_updated:
            res = QMessageBox.question(
                self, "Closing sky model",
                "<P>Model has been modified, would you like to save the changes?</P>",
                QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
                QMessageBox.Save)
            if res == QMessageBox.Cancel:
                return False
            elif res == QMessageBox.Save:
                if not self.saveFile(confirm=False, overwrite=True):
                    return False
        # unload model images, unless we are already exiting anyway
        if not self._exiting:
            self.imgman.unloadModelImages()
        return True

    def closeFile(self):
        if not self._canCloseExistingModel():
            return False
        # close model
        self._display_filename = None
        self.setModel(None)
        # set the layout
        self.setLayout(self.LayoutImage if self.imgman.getTopImage() else self.
                       LayoutEmpty)
        return True

    def saveFile(self,
                 filename=None,
                 confirm=False,
                 overwrite=True,
                 non_native=False):
        """Saves file using the specified 'filename'. If filename is None, uses current filename, if
        that is not set, goes to saveFileAs() to open dialog and get a filename.
        If overwrite=False, will ask for confirmation before overwriting an existing file.
        If non_native=False, will ask for confirmation before exporting in non-native format.
        If confirm=True, will ask for confirmation regardless.
        Returns True if saving succeeded, False on error (or if cancelled by user).
        """
        if isinstance(filename, QStringList):
            filename = filename[0]
        filename = (filename and str(filename)) or self.filename
        if filename is None:
            return self.saveFileAs()
        else:
            warning = ''
            # try to determine the file type
            filetype, import_func, export_func, doc = Tigger.Models.Formats.resolveFormat(
                filename, None)
            if export_func is None:
                self.signalShowErrorMessage.emit(
                    """Error saving model file %s: unsupported output format"""
                    % filename)
                return
            if os.path.exists(filename) and not overwrite:
                warning += "<P>The file already exists and will be overwritten.</P>"
            if filetype != 'Tigger' and not non_native:
                warning += """<P>Please note that you are exporting the model using the external format '%s'.
              Source types, tags and other model features not supported by this
              format will be omitted during the export.</P>""" % filetype
            # get confirmation
            if confirm or warning:
                dialog = QMessageBox.warning if warning else QMessageBox.question
                if dialog(self, "Saving sky model",
                          "<P>Save model to %s?</P>%s" % (filename, warning),
                          QMessageBox.Save | QMessageBox.Cancel,
                          QMessageBox.Save) != QMessageBox.Save:
                    return False
            busy = BusyIndicator()
            try:
                export_func(self.model, filename)
                self.model.setFilename(filename)
            except:
                busy.reset_cursor()
                self.signalShowErrorMessage.emit(
                    """Error saving model file %s: %s""" %
                    (filename, str(sys.exc_info()[1])))
                return False
            else:
                self.signalShowMessage.emit(
                    """Saved model to file %s""" % filename, 3000)
                self._display_filename = os.path.basename(filename)
                self._indicateModelUpdated(updated=False)
                self.filename = filename
                return True
            finally:
                busy.reset_cursor()

    def saveFileAs(self, filename=None):
        """Saves file using the specified 'filename'. If filename is None, opens dialog to get a filename.
        Returns True if saving succeeded, False on error (or if cancelled by user).
        """
        if filename is None:
            if not self._save_as_dialog:
                filters = ";;".join([
                    "%s (%s)" % (name, " ".join(patterns))
                    for name, patterns, func in self._save_file_types
                ])
                dialog = self._save_as_dialog = QFileDialog(
                    self, "Save sky model", ".", filters)
                dialog.setDefaultSuffix(ModelHTML.DefaultExtension)
                dialog.setFileMode(QFileDialog.AnyFile)
                dialog.setAcceptMode(QFileDialog.AcceptSave)
                dialog.setOption(QFileDialog.DontConfirmOverwrite, True)
                dialog.setModal(True)
                dialog.filesSelected['QStringList'].connect(self.saveFileAs)
            return self._save_as_dialog.exec_() == QDialog.Accepted
        # filename supplied, so save
        return self.saveFile(filename, confirm=False)

    def saveSelectionAs(self, filename=None, force=False):
        if not self.model:
            return
        if filename is None:
            if not self._save_sel_as_dialog:
                filters = ";;".join([
                    "%s (%s)" % (name, " ".join(patterns))
                    for name, patterns, func in self._save_file_types
                ])
                dialog = self._save_sel_as_dialog = QFileDialog(
                    self, "Save sky model", ".", filters)
                dialog.setDefaultSuffix(ModelHTML.DefaultExtension)
                dialog.setFileMode(QFileDialog.AnyFile)
                dialog.setAcceptMode(QFileDialog.AcceptSave)
                dialog.setOption(QFileDialog.DontConfirmOverwrite, False)
                dialog.setModal(True)
                dialog.filesSelected['QStringList'].connect(
                    self.saveSelectionAs)
            return self._save_sel_as_dialog.exec_() == QDialog.Accepted
        # save selection
        if isinstance(filename, QStringList):
            filename = filename[0]
        filename = str(filename)
        selmodel = self.model.copy()
        sources = [src for src in self.model.sources if src.selected]
        if not sources:
            self.signalShowErrorMessage.emit(
                """You have not selected any sources to save.""")
            return
        # try to determine the file type
        filetype, import_func, export_func, doc = Tigger.Models.Formats.resolveFormat(
            filename, None)
        if export_func is None:
            self.signalShowErrorMessage.emit(
                """Error saving model file %s: unsupported output format""" %
                filename)
            return
        busy = BusyIndicator()
        try:
            export_func(self.model, filename, sources=sources)
        except:
            busy.reset_cursor()
            self.signalShowErrorMessage.emit(
                """Error saving selection to model file %s: %s""" %
                (filename, str(sys.exc_info()[1])))
            return False
        else:
            self.signalShowMessage.emit(
                """Wrote %d selected source%s to file %s""" %
                (len(selmodel.sources),
                 "" if len(selmodel.sources) == 1 else "s", filename), 3000)
        finally:
            busy.reset_cursor()
        pass

    def addTagToSelection(self):
        if not hasattr(self, '_add_tag_dialog'):
            self._add_tag_dialog = Widgets.AddTagDialog(self, modal=True)
        self._add_tag_dialog.setTags(self.model.tagnames)
        self._add_tag_dialog.setValue(True)
        if self._add_tag_dialog.exec_() != QDialog.Accepted:
            return
        tagname, value = self._add_tag_dialog.getTag()
        if tagname is None or value is None:
            return None
        dprint(1, "tagging selected sources with", tagname, value)
        # tag selected sources
        for src in self.model.sources:
            if src.selected:
                src.setAttribute(tagname, value)
        # If tag is not new, set a UpdateSelectionOnly flag on the signal
        dprint(1, "adding tag to model")
        self.model.addTag(tagname)
        dprint(1, "recomputing totals")
        self.model.getTagGrouping(tagname).computeTotal(self.model.sources)
        dprint(1, "emitting update signal")
        what = SkyModel.SkyModel.UpdateSourceContent + SkyModel.SkyModel.UpdateTags + SkyModel.SkyModel.UpdateSelectionOnly
        self.model.emitUpdate(what, origin=self)

    def removeTagsFromSelection(self):
        if not hasattr(self, '_remove_tag_dialog'):
            self._remove_tag_dialog = Widgets.SelectTagsDialog(
                self, modal=True, caption="Remove Tags", ok_button="Remove")
        # get set of all tags in selected sources
        tags = set()
        for src in self.model.sources:
            if src.selected:
                tags.update(src.getTagNames())
        if not tags:
            return
        tags = list(tags)
        tags.sort()
        # show dialog
        self._remove_tag_dialog.setTags(tags)
        if self._remove_tag_dialog.exec_() != QDialog.Accepted:
            return
        tags = self._remove_tag_dialog.getSelectedTags()
        if not tags:
            return
        # ask for confirmation
        plural = (len(tags) > 1 and "s") or ""
        if QMessageBox.question(
                self, "Removing tags",
                "<P>Really remove the tag%s '%s' from selected sources?</P>" %
            (plural, "', '".join(tags)), QMessageBox.Yes | QMessageBox.No,
                QMessageBox.Yes) != QMessageBox.Yes:
            return
        # remove the tags
        for src in self.model.sources:
            if src.selected:
                for tag in tags:
                    src.removeAttribute(tag)
        # update model
        self.model.scanTags()
        self.model.initGroupings()
        # emit signal
        what = SkyModel.SkyModel.UpdateSourceContent + SkyModel.SkyModel.UpdateTags + SkyModel.SkyModel.UpdateSelectionOnly
        self.model.emitUpdate(what, origin=self)

    def _indicateModelUpdated(self, what=None, origin=None, updated=True):
        """Marks model as updated."""
        self._model_updated = updated
        self.isUpdated.emit(updated)
        if self.model:
            self.setWindowTitle("Tigger - %s%s" %
                                ((self._display_filename or "(unnamed)",
                                  " (modified)" if updated else "")))