[{"data":1,"prerenderedAt":2237},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002Fadd-layer-tree-indicator-pyqgis":3},{"id":4,"title":5,"body":6,"description":2226,"extension":2227,"meta":2228,"navigation":258,"path":2233,"seo":2234,"stem":2235,"__hash__":2236},"docs\u002Fqgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002Fadd-layer-tree-indicator-pyqgis\u002Findex.md","Add a Layer Tree Indicator in PyQGIS",{"type":7,"value":8,"toc":2212},"minimark",[9,13,17,26,159,164,179,183,186,353,367,371,374,480,1026,1043,1047,1050,1373,1397,1401,1404,1798,1808,1812,1822,1978,1986,1990,1993,2068,2071,2075,2097,2101,2131,2135,2141,2145,2151,2157,2167,2173,2177,2208],[10,11,5],"h1",{"id":12},"add-a-layer-tree-indicator-in-pyqgis",[14,15,16],"p",{},"The Layers panel already shows small icons beside layers: a pencil for layers in edit mode, a filter funnel for layers with a subset, a warning for layers with a CRS problem or missing data. These indicators tell users something important about a layer without them having to open anything. Plugins can add their own: a clock for data older than its refresh interval, a red mark for a layer that failed a validation rule, a lock for a layer under review, a cloud for data served from a remote source.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fqgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002F","Extending QGIS with Custom Classes",". It adds an indicator to a layer node, decides which layers get one, keeps indicators current as layers are added and changed, responds to clicks, and removes everything when the plugin unloads.",[14,27,28],{},[29,30,35,39,43,50,59,69,76,84,90,93,97,100,103,107,115,119,122,126,134,140,145,149,154],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 280","img","The Layers panel with built-in edit and filter indicators and a plugin's stale-data indicator with a tooltip","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Indicators in the Layers panel",[40,41,42],"desc",{},"A sketch of the Layers panel with four layers. Built-in indicators show a pencil for editing and a funnel for a filter. A plugin's indicator, a clock icon, marks one layer as stale with a tooltip giving the data's age. Clicking the indicator opens the plugin's refresh dialog.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","280","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","Small icons, important status",[44,60],{"x":61,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":67,"style":68},"60","52","400","210","6","#fffdf7","#59645f","stroke-width:1.5",[44,70],{"x":71,"y":72,"width":73,"height":73,"rx":74,"fill":75,"stroke":75,"style":68},"80","75","12","2","#0f766e",[51,77,83],{"x":78,"y":79,"style":80,"fill":81,"textAnchor":82},"102","86","text-anchor:start;font-size:11.0px;font-family:sans-serif","#2f3b35","start","parcels",[51,85,89],{"x":86,"y":79,"style":87,"fill":67,"textAnchor":88},"424","text-anchor:end;font-size:10.0px;font-family:sans-serif","end","edit",[44,91],{"x":71,"y":92,"width":73,"height":73,"rx":74,"fill":75,"stroke":75,"style":68},"115",[51,94,96],{"x":78,"y":95,"style":80,"fill":81,"textAnchor":82},"126","buildings",[51,98,99],{"x":86,"y":95,"style":87,"fill":67,"textAnchor":88},"filter",[44,101],{"x":71,"y":102,"width":73,"height":73,"rx":74,"fill":75,"stroke":75,"style":68},"155",[51,104,106],{"x":78,"y":105,"style":80,"fill":81,"textAnchor":82},"166","traffic counts",[108,109],"circle",{"cx":110,"cy":111,"r":112,"fill":113,"stroke":114,"style":68},"412","162","9","#fdf2e2","#b45309",[51,116,118],{"x":110,"y":105,"style":117,"fill":114,"textAnchor":57},"text-anchor:middle;font-size:10.0px;font-family:sans-serif;font-weight:bold","!",[44,120],{"x":71,"y":121,"width":73,"height":73,"rx":74,"fill":75,"stroke":75,"style":68},"195",[51,123,125],{"x":78,"y":124,"style":80,"fill":81,"textAnchor":82},"206","basemap",[44,127],{"x":128,"y":129,"width":130,"height":131,"rx":132,"fill":113,"stroke":114,"style":133},"486","128","250","70","8","stroke-width:2",[51,135,139],{"x":136,"y":137,"style":138,"fill":114,"textAnchor":57},"611","146.6","text-anchor:middle;font-size:11.0px;font-family:sans-serif;font-weight:bold","tooltip",[51,141,144],{"x":136,"y":142,"style":143,"fill":67,"textAnchor":57},"166.6","text-anchor:middle;font-size:10.0px;font-family:sans-serif","data is 9 days old",[51,146,148],{"x":136,"y":147,"style":143,"fill":67,"textAnchor":57},"186.6","click to refresh",[150,151],"line",{"x1":86,"y1":111,"x2":152,"y2":111,"stroke":114,"style":153},"482","stroke-width:1.2;stroke-dasharray:3 3",[51,155,158],{"x":156,"y":157,"style":143,"fill":67,"textAnchor":57},"260","248","built-in: edit · filter   plugin: stale data",[160,161,163],"h2",{"id":162},"prerequisites","Prerequisites",[165,166,167,171],"ul",{},[168,169,170],"li",{},"QGIS 3.34 LTR or newer, or the QGIS 4 series, with code running inside QGIS Desktop.",[168,172,173,174,178],{},"An icon file — SVG works best, since the panel scales icons for high-DPI screens. Plugins usually ship icons in their folder, as described in ",[21,175,177],{"href":176},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002Fadd-plugin-icon-and-resources-pyqgis\u002F","adding a plugin icon and resources",".",[160,180,182],{"id":181},"add-an-indicator-to-one-layer","Add an indicator to one layer",[14,184,185],{},"An indicator is a small object with an icon and a tooltip, attached to a node in a specific layer tree view.",[187,188,193],"pre",{"className":189,"code":190,"language":191,"meta":192,"style":192},"language-python shiki shiki-themes github-dark","from qgis.gui import QgsLayerTreeViewIndicator\nfrom qgis.core import QgsProject\nfrom qgis.PyQt.QtGui import QIcon\nfrom qgis.utils import iface\n\nview = iface.layerTreeView()\nlayer = QgsProject.instance().mapLayersByName(\"traffic_counts\")[0]\nnode = QgsProject.instance().layerTreeRoot().findLayer(layer.id())\n\nindicator = QgsLayerTreeViewIndicator(view)\nindicator.setIcon(QIcon(\"\u002Fpath\u002Fto\u002Fplugin\u002Ficons\u002Fstale.svg\"))\nindicator.setToolTip(\"Data is 9 days old (refresh interval: 7 days)\")\nview.addIndicator(node, indicator)\n","python","",[194,195,196,214,227,240,253,260,272,296,307,312,323,335,347],"code",{"__ignoreMap":192},[197,198,200,204,208,211],"span",{"class":150,"line":199},1,[197,201,203],{"class":202},"snl16","from",[197,205,207],{"class":206},"s95oV"," qgis.gui ",[197,209,210],{"class":202},"import",[197,212,213],{"class":206}," QgsLayerTreeViewIndicator\n",[197,215,217,219,222,224],{"class":150,"line":216},2,[197,218,203],{"class":202},[197,220,221],{"class":206}," qgis.core ",[197,223,210],{"class":202},[197,225,226],{"class":206}," QgsProject\n",[197,228,230,232,235,237],{"class":150,"line":229},3,[197,231,203],{"class":202},[197,233,234],{"class":206}," qgis.PyQt.QtGui ",[197,236,210],{"class":202},[197,238,239],{"class":206}," QIcon\n",[197,241,243,245,248,250],{"class":150,"line":242},4,[197,244,203],{"class":202},[197,246,247],{"class":206}," qgis.utils ",[197,249,210],{"class":202},[197,251,252],{"class":206}," iface\n",[197,254,256],{"class":150,"line":255},5,[197,257,259],{"emptyLinePlaceholder":258},true,"\n",[197,261,263,266,269],{"class":150,"line":262},6,[197,264,265],{"class":206},"view ",[197,267,268],{"class":202},"=",[197,270,271],{"class":206}," iface.layerTreeView()\n",[197,273,275,278,280,283,287,290,293],{"class":150,"line":274},7,[197,276,277],{"class":206},"layer ",[197,279,268],{"class":202},[197,281,282],{"class":206}," QgsProject.instance().mapLayersByName(",[197,284,286],{"class":285},"sU2Wk","\"traffic_counts\"",[197,288,289],{"class":206},")[",[197,291,46],{"class":292},"sDLfK",[197,294,295],{"class":206},"]\n",[197,297,299,302,304],{"class":150,"line":298},8,[197,300,301],{"class":206},"node ",[197,303,268],{"class":202},[197,305,306],{"class":206}," QgsProject.instance().layerTreeRoot().findLayer(layer.id())\n",[197,308,310],{"class":150,"line":309},9,[197,311,259],{"emptyLinePlaceholder":258},[197,313,315,318,320],{"class":150,"line":314},10,[197,316,317],{"class":206},"indicator ",[197,319,268],{"class":202},[197,321,322],{"class":206}," QgsLayerTreeViewIndicator(view)\n",[197,324,326,329,332],{"class":150,"line":325},11,[197,327,328],{"class":206},"indicator.setIcon(QIcon(",[197,330,331],{"class":285},"\"\u002Fpath\u002Fto\u002Fplugin\u002Ficons\u002Fstale.svg\"",[197,333,334],{"class":206},"))\n",[197,336,338,341,344],{"class":150,"line":337},12,[197,339,340],{"class":206},"indicator.setToolTip(",[197,342,343],{"class":285},"\"Data is 9 days old (refresh interval: 7 days)\"",[197,345,346],{"class":206},")\n",[197,348,350],{"class":150,"line":349},13,[197,351,352],{"class":206},"view.addIndicator(node, indicator)\n",[14,354,355,359,360,363,364,178],{},[356,357,358],"strong",{},"Breakdown:"," ",[194,361,362],{},"findLayer"," returns the layer's node in the project's layer tree; indicators are attached to nodes, not layers, so a layer that appears twice in the tree would need two. Passing the view as parent makes it own the indicator's lifetime on the Qt side. The tooltip is the indicator's real message — keep it specific: what is wrong and what to do about it. Indicators are view-specific; the main Layers panel is ",[194,365,366],{},"iface.layerTreeView()",[160,368,370],{"id":369},"decide-which-layers-get-an-indicator","Decide which layers get an indicator",[14,372,373],{},"Indicators should reflect a rule evaluated per layer. The example marks layers whose custom property records a last-refresh time older than their refresh interval.",[14,375,376],{},[29,377,380,383,386,389,404,407,412,418,423,427,432,436,441,445,450,454,458,463,467,471,476],{"viewBox":378,"role":32,"ariaLabel":379,"xmlns":34},"0 0 760 204","For each layer a rule compares last refresh time with an interval and adds or removes the indicator accordingly",[36,381,382],{},"A rule per layer",[40,384,385],{},"For each layer in the project, the plugin reads a custom property holding the last refresh time and an interval. If the data is older than the interval, the layer gets an indicator with a tooltip stating the age; otherwise any existing indicator is removed. The rule runs when layers are added, when the property changes, and on a timer.",[44,387],{"x":46,"y":46,"width":47,"height":388,"fill":49},"204",[390,391,392],"defs",{},[393,394,400],"marker",{"id":395,"viewBox":396,"refX":132,"refY":397,"markerWidth":398,"markerHeight":398,"orient":399},"indRuleArrow","0 0 10 10","5","7","auto-start-reverse",[401,402],"path",{"d":403,"fill":81},"M0 0 L10 5 L0 10 z",[51,405,406],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Evaluate, then add or remove",[44,408],{"x":409,"y":61,"width":410,"height":411,"rx":132,"fill":66,"stroke":67,"style":133},"24","200","120",[51,413,417],{"x":414,"y":415,"style":416,"fill":56,"textAnchor":57},"124","99.78","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","each layer",[51,419,422],{"x":414,"y":420,"style":421,"fill":67,"textAnchor":57},"123.78","text-anchor:middle;font-size:10.5px;font-family:sans-serif","customProperty",[51,424,426],{"x":414,"y":425,"style":421,"fill":67,"textAnchor":57},"147.78","last_refresh",[150,428],{"x1":429,"y1":411,"x2":430,"y2":411,"stroke":81,"style":431},"224","266","stroke-width:1.8;marker-end:url(#indRuleArrow)",[44,433],{"x":434,"y":61,"width":410,"height":411,"rx":132,"fill":435,"stroke":75,"style":133},"270","#eef7f4",[51,437,440],{"x":438,"y":439,"style":416,"fill":75,"textAnchor":57},"370","110.78","older than",[51,442,444],{"x":438,"y":443,"style":421,"fill":81,"textAnchor":57},"136.78","interval?",[150,446],{"x1":447,"y1":448,"x2":449,"y2":71,"stroke":81,"style":431},"470","100","512",[150,451],{"x1":447,"y1":452,"x2":449,"y2":453,"stroke":81,"style":431},"140","160",[44,455],{"x":456,"y":62,"width":457,"height":61,"rx":132,"fill":113,"stroke":114,"style":133},"516","220",[51,459,462],{"x":460,"y":461,"style":416,"fill":114,"textAnchor":57},"626","75.78","yes: add \u002F update",[51,464,466],{"x":460,"y":465,"style":421,"fill":67,"textAnchor":57},"95.78","tooltip with age",[44,468],{"x":456,"y":129,"width":457,"height":61,"rx":132,"fill":469,"stroke":470,"style":133},"#e8efe6","#15803d",[51,472,475],{"x":460,"y":473,"style":416,"fill":474,"textAnchor":57},"151.78","#166534","no: remove",[51,477,479],{"x":460,"y":478,"style":421,"fill":67,"textAnchor":57},"171.78","if one exists",[187,481,483],{"className":189,"code":482,"language":191,"meta":192,"style":192},"from datetime import datetime, timedelta\n\nclass StaleIndicators:\n    def __init__(self, iface, icon_path):\n        self.iface = iface\n        self.view = iface.layerTreeView()\n        self.icon = QIcon(icon_path)\n        self.indicators = {}                     # layer id -> indicator\n\n    def check_layer(self, layer):\n        stamp = layer.customProperty(\"myplugin\u002Flast_refresh\")\n        days = int(layer.customProperty(\"myplugin\u002Finterval_days\", 7))\n        if not stamp:\n            return self.remove(layer.id())\n        age = datetime.now() - datetime.fromisoformat(stamp)\n        if age > timedelta(days=days):\n            self.add_or_update(layer, f\"Data is {age.days} days old \"\n                                      f\"(refresh interval: {days} days). Click to refresh.\")\n        else:\n            self.remove(layer.id())\n\n    def add_or_update(self, layer, text):\n        node = QgsProject.instance().layerTreeRoot().findLayer(layer.id())\n        if node is None:\n            return\n        ind = self.indicators.get(layer.id())\n        if ind is None:\n            ind = QgsLayerTreeViewIndicator(self.view)\n            ind.setIcon(self.icon)\n            ind.clicked.connect(lambda index, lid=layer.id(): self.on_click(lid))\n            self.view.addIndicator(node, ind)\n            self.indicators[layer.id()] = ind\n        ind.setToolTip(text)\n\n    def remove(self, layer_id):\n        ind = self.indicators.pop(layer_id, None)\n        node = QgsProject.instance().layerTreeRoot().findLayer(layer_id)\n        if ind is not None and node is not None:\n            self.view.removeIndicator(node, ind)\n\n    def on_click(self, layer_id):\n        self.iface.messageBar().pushInfo(\"Refresh\", f\"Refreshing layer {layer_id} …\")\n",[194,484,485,497,501,513,524,536,547,559,575,579,589,604,627,638,650,667,690,717,737,745,752,757,768,778,794,800,813,827,844,855,877,885,898,904,909,920,937,947,973,981,986,996],{"__ignoreMap":192},[197,486,487,489,492,494],{"class":150,"line":199},[197,488,203],{"class":202},[197,490,491],{"class":206}," datetime ",[197,493,210],{"class":202},[197,495,496],{"class":206}," datetime, timedelta\n",[197,498,499],{"class":150,"line":216},[197,500,259],{"emptyLinePlaceholder":258},[197,502,503,506,510],{"class":150,"line":229},[197,504,505],{"class":202},"class",[197,507,509],{"class":508},"svObZ"," StaleIndicators",[197,511,512],{"class":206},":\n",[197,514,515,518,521],{"class":150,"line":242},[197,516,517],{"class":202},"    def",[197,519,520],{"class":292}," __init__",[197,522,523],{"class":206},"(self, iface, icon_path):\n",[197,525,526,529,532,534],{"class":150,"line":255},[197,527,528],{"class":292},"        self",[197,530,531],{"class":206},".iface ",[197,533,268],{"class":202},[197,535,252],{"class":206},[197,537,538,540,543,545],{"class":150,"line":262},[197,539,528],{"class":292},[197,541,542],{"class":206},".view ",[197,544,268],{"class":202},[197,546,271],{"class":206},[197,548,549,551,554,556],{"class":150,"line":274},[197,550,528],{"class":292},[197,552,553],{"class":206},".icon ",[197,555,268],{"class":202},[197,557,558],{"class":206}," QIcon(icon_path)\n",[197,560,561,563,566,568,571],{"class":150,"line":298},[197,562,528],{"class":292},[197,564,565],{"class":206},".indicators ",[197,567,268],{"class":202},[197,569,570],{"class":206}," {}                     ",[197,572,574],{"class":573},"sjoCn","# layer id -> indicator\n",[197,576,577],{"class":150,"line":309},[197,578,259],{"emptyLinePlaceholder":258},[197,580,581,583,586],{"class":150,"line":314},[197,582,517],{"class":202},[197,584,585],{"class":508}," check_layer",[197,587,588],{"class":206},"(self, layer):\n",[197,590,591,594,596,599,602],{"class":150,"line":325},[197,592,593],{"class":206},"        stamp ",[197,595,268],{"class":202},[197,597,598],{"class":206}," layer.customProperty(",[197,600,601],{"class":285},"\"myplugin\u002Flast_refresh\"",[197,603,346],{"class":206},[197,605,606,609,611,614,617,620,623,625],{"class":150,"line":337},[197,607,608],{"class":206},"        days ",[197,610,268],{"class":202},[197,612,613],{"class":292}," int",[197,615,616],{"class":206},"(layer.customProperty(",[197,618,619],{"class":285},"\"myplugin\u002Finterval_days\"",[197,621,622],{"class":206},", ",[197,624,398],{"class":292},[197,626,334],{"class":206},[197,628,629,632,635],{"class":150,"line":349},[197,630,631],{"class":202},"        if",[197,633,634],{"class":202}," not",[197,636,637],{"class":206}," stamp:\n",[197,639,641,644,647],{"class":150,"line":640},14,[197,642,643],{"class":202},"            return",[197,645,646],{"class":292}," self",[197,648,649],{"class":206},".remove(layer.id())\n",[197,651,653,656,658,661,664],{"class":150,"line":652},15,[197,654,655],{"class":206},"        age ",[197,657,268],{"class":202},[197,659,660],{"class":206}," datetime.now() ",[197,662,663],{"class":202},"-",[197,665,666],{"class":206}," datetime.fromisoformat(stamp)\n",[197,668,670,672,675,678,681,685,687],{"class":150,"line":669},16,[197,671,631],{"class":202},[197,673,674],{"class":206}," age ",[197,676,677],{"class":202},">",[197,679,680],{"class":206}," timedelta(",[197,682,684],{"class":683},"s9osk","days",[197,686,268],{"class":202},[197,688,689],{"class":206},"days):\n",[197,691,693,696,699,702,705,708,711,714],{"class":150,"line":692},17,[197,694,695],{"class":292},"            self",[197,697,698],{"class":206},".add_or_update(layer, ",[197,700,701],{"class":202},"f",[197,703,704],{"class":285},"\"Data is ",[197,706,707],{"class":292},"{",[197,709,710],{"class":206},"age.days",[197,712,713],{"class":292},"}",[197,715,716],{"class":285}," days old \"\n",[197,718,720,723,726,728,730,732,735],{"class":150,"line":719},18,[197,721,722],{"class":202},"                                      f",[197,724,725],{"class":285},"\"(refresh interval: ",[197,727,707],{"class":292},[197,729,684],{"class":206},[197,731,713],{"class":292},[197,733,734],{"class":285}," days). Click to refresh.\"",[197,736,346],{"class":206},[197,738,740,743],{"class":150,"line":739},19,[197,741,742],{"class":202},"        else",[197,744,512],{"class":206},[197,746,748,750],{"class":150,"line":747},20,[197,749,695],{"class":292},[197,751,649],{"class":206},[197,753,755],{"class":150,"line":754},21,[197,756,259],{"emptyLinePlaceholder":258},[197,758,760,762,765],{"class":150,"line":759},22,[197,761,517],{"class":202},[197,763,764],{"class":508}," add_or_update",[197,766,767],{"class":206},"(self, layer, text):\n",[197,769,771,774,776],{"class":150,"line":770},23,[197,772,773],{"class":206},"        node ",[197,775,268],{"class":202},[197,777,306],{"class":206},[197,779,781,783,786,789,792],{"class":150,"line":780},24,[197,782,631],{"class":202},[197,784,785],{"class":206}," node ",[197,787,788],{"class":202},"is",[197,790,791],{"class":292}," None",[197,793,512],{"class":206},[197,795,797],{"class":150,"line":796},25,[197,798,799],{"class":202},"            return\n",[197,801,803,806,808,810],{"class":150,"line":802},26,[197,804,805],{"class":206},"        ind ",[197,807,268],{"class":202},[197,809,646],{"class":292},[197,811,812],{"class":206},".indicators.get(layer.id())\n",[197,814,816,818,821,823,825],{"class":150,"line":815},27,[197,817,631],{"class":202},[197,819,820],{"class":206}," ind ",[197,822,788],{"class":202},[197,824,791],{"class":292},[197,826,512],{"class":206},[197,828,830,833,835,838,841],{"class":150,"line":829},28,[197,831,832],{"class":206},"            ind ",[197,834,268],{"class":202},[197,836,837],{"class":206}," QgsLayerTreeViewIndicator(",[197,839,840],{"class":292},"self",[197,842,843],{"class":206},".view)\n",[197,845,847,850,852],{"class":150,"line":846},29,[197,848,849],{"class":206},"            ind.setIcon(",[197,851,840],{"class":292},[197,853,854],{"class":206},".icon)\n",[197,856,858,861,864,867,869,872,874],{"class":150,"line":857},30,[197,859,860],{"class":206},"            ind.clicked.connect(",[197,862,863],{"class":202},"lambda",[197,865,866],{"class":206}," index, lid",[197,868,268],{"class":202},[197,870,871],{"class":206},"layer.id(): ",[197,873,840],{"class":292},[197,875,876],{"class":206},".on_click(lid))\n",[197,878,880,882],{"class":150,"line":879},31,[197,881,695],{"class":292},[197,883,884],{"class":206},".view.addIndicator(node, ind)\n",[197,886,888,890,893,895],{"class":150,"line":887},32,[197,889,695],{"class":292},[197,891,892],{"class":206},".indicators[layer.id()] ",[197,894,268],{"class":202},[197,896,897],{"class":206}," ind\n",[197,899,901],{"class":150,"line":900},33,[197,902,903],{"class":206},"        ind.setToolTip(text)\n",[197,905,907],{"class":150,"line":906},34,[197,908,259],{"emptyLinePlaceholder":258},[197,910,912,914,917],{"class":150,"line":911},35,[197,913,517],{"class":202},[197,915,916],{"class":508}," remove",[197,918,919],{"class":206},"(self, layer_id):\n",[197,921,923,925,927,929,932,935],{"class":150,"line":922},36,[197,924,805],{"class":206},[197,926,268],{"class":202},[197,928,646],{"class":292},[197,930,931],{"class":206},".indicators.pop(layer_id, ",[197,933,934],{"class":292},"None",[197,936,346],{"class":206},[197,938,940,942,944],{"class":150,"line":939},37,[197,941,773],{"class":206},[197,943,268],{"class":202},[197,945,946],{"class":206}," QgsProject.instance().layerTreeRoot().findLayer(layer_id)\n",[197,948,950,952,954,956,958,960,963,965,967,969,971],{"class":150,"line":949},38,[197,951,631],{"class":202},[197,953,820],{"class":206},[197,955,788],{"class":202},[197,957,634],{"class":202},[197,959,791],{"class":292},[197,961,962],{"class":202}," and",[197,964,785],{"class":206},[197,966,788],{"class":202},[197,968,634],{"class":202},[197,970,791],{"class":292},[197,972,512],{"class":206},[197,974,976,978],{"class":150,"line":975},39,[197,977,695],{"class":292},[197,979,980],{"class":206},".view.removeIndicator(node, ind)\n",[197,982,984],{"class":150,"line":983},40,[197,985,259],{"emptyLinePlaceholder":258},[197,987,989,991,994],{"class":150,"line":988},41,[197,990,517],{"class":202},[197,992,993],{"class":508}," on_click",[197,995,919],{"class":206},[197,997,999,1001,1004,1007,1009,1011,1014,1016,1019,1021,1024],{"class":150,"line":998},42,[197,1000,528],{"class":292},[197,1002,1003],{"class":206},".iface.messageBar().pushInfo(",[197,1005,1006],{"class":285},"\"Refresh\"",[197,1008,622],{"class":206},[197,1010,701],{"class":202},[197,1012,1013],{"class":285},"\"Refreshing layer ",[197,1015,707],{"class":292},[197,1017,1018],{"class":206},"layer_id",[197,1020,713],{"class":292},[197,1022,1023],{"class":285}," …\"",[197,1025,346],{"class":206},[14,1027,1028,1030,1031,1034,1035,1038,1039,178],{},[356,1029,358],{}," Storing the refresh time as a layer custom property keeps the state with the layer and in the project file, so it survives restarts. A dictionary from layer id to indicator lets the plugin update an existing indicator's tooltip instead of stacking duplicates — a common bug when the rule runs repeatedly. The ",[194,1032,1033],{},"clicked"," signal passes the model index of the node; capturing the layer id in the lambda's default argument binds each indicator to its own layer. In a real plugin, ",[194,1036,1037],{},"on_click"," would open a refresh dialog or start a ",[21,1040,1042],{"href":1041},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Frun-background-task-with-qgstask-pyqgis\u002F","background task",[160,1044,1046],{"id":1045},"keep-indicators-current","Keep indicators current",[14,1048,1049],{},"Rules must run when the situation changes: when layers are added or loaded with a project, when the relevant property changes, and periodically for time-based rules.",[187,1051,1053],{"className":189,"code":1052,"language":191,"meta":192,"style":192},"from qgis.PyQt.QtCore import QTimer\n\nclass StaleIndicators(StaleIndicators):\n    def start(self):\n        project = QgsProject.instance()\n        project.layersAdded.connect(self._check_many)\n        project.readProject.connect(lambda *_: self._check_all())\n        project.layerWillBeRemoved.connect(self.remove)\n        self.timer = QTimer()\n        self.timer.timeout.connect(self._check_all)\n        self.timer.start(60 * 60 * 1000)          # hourly\n        self._check_all()\n\n    def _check_many(self, layers):\n        for lyr in layers:\n            self.check_layer(lyr)\n\n    def _check_all(self):\n        for lyr in QgsProject.instance().mapLayers().values():\n            self.check_layer(lyr)\n\n    def stop(self):\n        project = QgsProject.instance()\n        for sig, slot in ((project.layersAdded, self._check_many),\n                          (project.layerWillBeRemoved, self.remove)):\n            try:\n                sig.disconnect(slot)\n            except TypeError:\n                pass\n        self.timer.stop()\n        for lid in list(self.indicators):\n            self.remove(lid)\n",[194,1054,1055,1067,1071,1086,1096,1106,1116,1134,1144,1156,1168,1193,1200,1204,1214,1228,1235,1239,1248,1259,1265,1269,1278,1286,1303,1313,1320,1325,1335,1340,1347,1366],{"__ignoreMap":192},[197,1056,1057,1059,1062,1064],{"class":150,"line":199},[197,1058,203],{"class":202},[197,1060,1061],{"class":206}," qgis.PyQt.QtCore ",[197,1063,210],{"class":202},[197,1065,1066],{"class":206}," QTimer\n",[197,1068,1069],{"class":150,"line":216},[197,1070,259],{"emptyLinePlaceholder":258},[197,1072,1073,1075,1077,1080,1083],{"class":150,"line":229},[197,1074,505],{"class":202},[197,1076,509],{"class":508},[197,1078,1079],{"class":206},"(",[197,1081,1082],{"class":508},"StaleIndicators",[197,1084,1085],{"class":206},"):\n",[197,1087,1088,1090,1093],{"class":150,"line":242},[197,1089,517],{"class":202},[197,1091,1092],{"class":508}," start",[197,1094,1095],{"class":206},"(self):\n",[197,1097,1098,1101,1103],{"class":150,"line":255},[197,1099,1100],{"class":206},"        project ",[197,1102,268],{"class":202},[197,1104,1105],{"class":206}," QgsProject.instance()\n",[197,1107,1108,1111,1113],{"class":150,"line":262},[197,1109,1110],{"class":206},"        project.layersAdded.connect(",[197,1112,840],{"class":292},[197,1114,1115],{"class":206},"._check_many)\n",[197,1117,1118,1121,1123,1126,1129,1131],{"class":150,"line":274},[197,1119,1120],{"class":206},"        project.readProject.connect(",[197,1122,863],{"class":202},[197,1124,1125],{"class":202}," *",[197,1127,1128],{"class":206},"_: ",[197,1130,840],{"class":292},[197,1132,1133],{"class":206},"._check_all())\n",[197,1135,1136,1139,1141],{"class":150,"line":298},[197,1137,1138],{"class":206},"        project.layerWillBeRemoved.connect(",[197,1140,840],{"class":292},[197,1142,1143],{"class":206},".remove)\n",[197,1145,1146,1148,1151,1153],{"class":150,"line":309},[197,1147,528],{"class":292},[197,1149,1150],{"class":206},".timer ",[197,1152,268],{"class":202},[197,1154,1155],{"class":206}," QTimer()\n",[197,1157,1158,1160,1163,1165],{"class":150,"line":314},[197,1159,528],{"class":292},[197,1161,1162],{"class":206},".timer.timeout.connect(",[197,1164,840],{"class":292},[197,1166,1167],{"class":206},"._check_all)\n",[197,1169,1170,1172,1175,1177,1179,1182,1184,1187,1190],{"class":150,"line":325},[197,1171,528],{"class":292},[197,1173,1174],{"class":206},".timer.start(",[197,1176,61],{"class":292},[197,1178,1125],{"class":202},[197,1180,1181],{"class":292}," 60",[197,1183,1125],{"class":202},[197,1185,1186],{"class":292}," 1000",[197,1188,1189],{"class":206},")          ",[197,1191,1192],{"class":573},"# hourly\n",[197,1194,1195,1197],{"class":150,"line":337},[197,1196,528],{"class":292},[197,1198,1199],{"class":206},"._check_all()\n",[197,1201,1202],{"class":150,"line":349},[197,1203,259],{"emptyLinePlaceholder":258},[197,1205,1206,1208,1211],{"class":150,"line":640},[197,1207,517],{"class":202},[197,1209,1210],{"class":508}," _check_many",[197,1212,1213],{"class":206},"(self, layers):\n",[197,1215,1216,1219,1222,1225],{"class":150,"line":652},[197,1217,1218],{"class":202},"        for",[197,1220,1221],{"class":206}," lyr ",[197,1223,1224],{"class":202},"in",[197,1226,1227],{"class":206}," layers:\n",[197,1229,1230,1232],{"class":150,"line":669},[197,1231,695],{"class":292},[197,1233,1234],{"class":206},".check_layer(lyr)\n",[197,1236,1237],{"class":150,"line":692},[197,1238,259],{"emptyLinePlaceholder":258},[197,1240,1241,1243,1246],{"class":150,"line":719},[197,1242,517],{"class":202},[197,1244,1245],{"class":508}," _check_all",[197,1247,1095],{"class":206},[197,1249,1250,1252,1254,1256],{"class":150,"line":739},[197,1251,1218],{"class":202},[197,1253,1221],{"class":206},[197,1255,1224],{"class":202},[197,1257,1258],{"class":206}," QgsProject.instance().mapLayers().values():\n",[197,1260,1261,1263],{"class":150,"line":747},[197,1262,695],{"class":292},[197,1264,1234],{"class":206},[197,1266,1267],{"class":150,"line":754},[197,1268,259],{"emptyLinePlaceholder":258},[197,1270,1271,1273,1276],{"class":150,"line":759},[197,1272,517],{"class":202},[197,1274,1275],{"class":508}," stop",[197,1277,1095],{"class":206},[197,1279,1280,1282,1284],{"class":150,"line":770},[197,1281,1100],{"class":206},[197,1283,268],{"class":202},[197,1285,1105],{"class":206},[197,1287,1288,1290,1293,1295,1298,1300],{"class":150,"line":780},[197,1289,1218],{"class":202},[197,1291,1292],{"class":206}," sig, slot ",[197,1294,1224],{"class":202},[197,1296,1297],{"class":206}," ((project.layersAdded, ",[197,1299,840],{"class":292},[197,1301,1302],{"class":206},"._check_many),\n",[197,1304,1305,1308,1310],{"class":150,"line":796},[197,1306,1307],{"class":206},"                          (project.layerWillBeRemoved, ",[197,1309,840],{"class":292},[197,1311,1312],{"class":206},".remove)):\n",[197,1314,1315,1318],{"class":150,"line":802},[197,1316,1317],{"class":202},"            try",[197,1319,512],{"class":206},[197,1321,1322],{"class":150,"line":815},[197,1323,1324],{"class":206},"                sig.disconnect(slot)\n",[197,1326,1327,1330,1333],{"class":150,"line":829},[197,1328,1329],{"class":202},"            except",[197,1331,1332],{"class":292}," TypeError",[197,1334,512],{"class":206},[197,1336,1337],{"class":150,"line":846},[197,1338,1339],{"class":202},"                pass\n",[197,1341,1342,1344],{"class":150,"line":857},[197,1343,528],{"class":292},[197,1345,1346],{"class":206},".timer.stop()\n",[197,1348,1349,1351,1354,1356,1359,1361,1363],{"class":150,"line":879},[197,1350,1218],{"class":202},[197,1352,1353],{"class":206}," lid ",[197,1355,1224],{"class":202},[197,1357,1358],{"class":292}," list",[197,1360,1079],{"class":206},[197,1362,840],{"class":292},[197,1364,1365],{"class":206},".indicators):\n",[197,1367,1368,1370],{"class":150,"line":887},[197,1369,695],{"class":292},[197,1371,1372],{"class":206},".remove(lid)\n",[14,1374,1375,359,1377,1380,1381,1384,1385,1388,1389,1392,1393,178],{},[356,1376,358],{},[194,1378,1379],{},"layersAdded"," covers layers added by users or scripts; ",[194,1382,1383],{},"readProject"," covers opening a project, when indicators from the previous project are gone and new layers need checking. Removing the indicator when a layer is about to be removed avoids holding references to nodes that no longer exist. An hourly timer re-evaluates time-based rules without user action. ",[194,1386,1387],{},"stop"," disconnects signals and removes every indicator — essential in ",[194,1390,1391],{},"unload()",", or a reloaded plugin will add a second set alongside the first, as discussed in ",[21,1394,1396],{"href":1395},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002Freload-qgis-plugin-without-restart\u002F","reloading a plugin without restarting",[160,1398,1400],{"id":1399},"flag-layers-that-fail-validation","Flag layers that fail validation",[14,1402,1403],{},"Staleness is one rule; data quality is another common one. An indicator that appears on layers failing a quick validation — invalid geometries, missing required values — puts quality problems in front of users the moment a layer is loaded, rather than when an analysis goes wrong.",[187,1405,1407],{"className":189,"code":1406,"language":191,"meta":192,"style":192},"from qgis.core import QgsVectorLayer, QgsFeatureRequest\n\ndef quick_issues(layer, required=(\"asset_id\",), sample=2000):\n    if not isinstance(layer, QgsVectorLayer) or not layer.isSpatial():\n        return []\n    req = QgsFeatureRequest().setLimit(sample)\n    invalid = missing = 0\n    names = [n for n in required if n in layer.fields().names()]\n    for f in layer.getFeatures(req):\n        if f.hasGeometry() and not f.geometry().isGeosValid():\n            invalid += 1\n        if any(f[n] is None or (hasattr(f[n], \"isNull\") and f[n].isNull()) for n in names):\n            missing += 1\n    issues = []\n    if invalid:\n        issues.append(f\"{invalid} invalid geometries\")\n    if missing:\n        issues.append(f\"{missing} features missing {', '.join(names)}\")\n    return issues\n\nclass QualityIndicators(StaleIndicators):\n    def check_layer(self, layer):\n        issues = quick_issues(layer)\n        if issues:\n            self.add_or_update(layer, \"Quality check (first 2,000 features): \"\n                                      + \"; \".join(issues) + \". Click for the full report.\")\n        else:\n            self.remove(layer.id())\n",[194,1408,1409,1420,1424,1452,1473,1481,1491,1506,1537,1550,1565,1576,1622,1631,1640,1647,1669,1676,1708,1716,1720,1733,1741,1751,1758,1767,1786,1792],{"__ignoreMap":192},[197,1410,1411,1413,1415,1417],{"class":150,"line":199},[197,1412,203],{"class":202},[197,1414,221],{"class":206},[197,1416,210],{"class":202},[197,1418,1419],{"class":206}," QgsVectorLayer, QgsFeatureRequest\n",[197,1421,1422],{"class":150,"line":216},[197,1423,259],{"emptyLinePlaceholder":258},[197,1425,1426,1429,1432,1435,1437,1439,1442,1445,1447,1450],{"class":150,"line":229},[197,1427,1428],{"class":202},"def",[197,1430,1431],{"class":508}," quick_issues",[197,1433,1434],{"class":206},"(layer, required",[197,1436,268],{"class":202},[197,1438,1079],{"class":206},[197,1440,1441],{"class":285},"\"asset_id\"",[197,1443,1444],{"class":206},",), sample",[197,1446,268],{"class":202},[197,1448,1449],{"class":292},"2000",[197,1451,1085],{"class":206},[197,1453,1454,1457,1459,1462,1465,1468,1470],{"class":150,"line":242},[197,1455,1456],{"class":202},"    if",[197,1458,634],{"class":202},[197,1460,1461],{"class":292}," isinstance",[197,1463,1464],{"class":206},"(layer, QgsVectorLayer) ",[197,1466,1467],{"class":202},"or",[197,1469,634],{"class":202},[197,1471,1472],{"class":206}," layer.isSpatial():\n",[197,1474,1475,1478],{"class":150,"line":255},[197,1476,1477],{"class":202},"        return",[197,1479,1480],{"class":206}," []\n",[197,1482,1483,1486,1488],{"class":150,"line":262},[197,1484,1485],{"class":206},"    req ",[197,1487,268],{"class":202},[197,1489,1490],{"class":206}," QgsFeatureRequest().setLimit(sample)\n",[197,1492,1493,1496,1498,1501,1503],{"class":150,"line":274},[197,1494,1495],{"class":206},"    invalid ",[197,1497,268],{"class":202},[197,1499,1500],{"class":206}," missing ",[197,1502,268],{"class":202},[197,1504,1505],{"class":292}," 0\n",[197,1507,1508,1511,1513,1516,1519,1522,1524,1527,1530,1532,1534],{"class":150,"line":298},[197,1509,1510],{"class":206},"    names ",[197,1512,268],{"class":202},[197,1514,1515],{"class":206}," [n ",[197,1517,1518],{"class":202},"for",[197,1520,1521],{"class":206}," n ",[197,1523,1224],{"class":202},[197,1525,1526],{"class":206}," required ",[197,1528,1529],{"class":202},"if",[197,1531,1521],{"class":206},[197,1533,1224],{"class":202},[197,1535,1536],{"class":206}," layer.fields().names()]\n",[197,1538,1539,1542,1545,1547],{"class":150,"line":309},[197,1540,1541],{"class":202},"    for",[197,1543,1544],{"class":206}," f ",[197,1546,1224],{"class":202},[197,1548,1549],{"class":206}," layer.getFeatures(req):\n",[197,1551,1552,1554,1557,1560,1562],{"class":150,"line":314},[197,1553,631],{"class":202},[197,1555,1556],{"class":206}," f.hasGeometry() ",[197,1558,1559],{"class":202},"and",[197,1561,634],{"class":202},[197,1563,1564],{"class":206}," f.geometry().isGeosValid():\n",[197,1566,1567,1570,1573],{"class":150,"line":325},[197,1568,1569],{"class":206},"            invalid ",[197,1571,1572],{"class":202},"+=",[197,1574,1575],{"class":292}," 1\n",[197,1577,1578,1580,1583,1586,1588,1590,1593,1596,1599,1602,1605,1608,1610,1613,1615,1617,1619],{"class":150,"line":337},[197,1579,631],{"class":202},[197,1581,1582],{"class":292}," any",[197,1584,1585],{"class":206},"(f[n] ",[197,1587,788],{"class":202},[197,1589,791],{"class":292},[197,1591,1592],{"class":202}," or",[197,1594,1595],{"class":206}," (",[197,1597,1598],{"class":292},"hasattr",[197,1600,1601],{"class":206},"(f[n], ",[197,1603,1604],{"class":285},"\"isNull\"",[197,1606,1607],{"class":206},") ",[197,1609,1559],{"class":202},[197,1611,1612],{"class":206}," f[n].isNull()) ",[197,1614,1518],{"class":202},[197,1616,1521],{"class":206},[197,1618,1224],{"class":202},[197,1620,1621],{"class":206}," names):\n",[197,1623,1624,1627,1629],{"class":150,"line":349},[197,1625,1626],{"class":206},"            missing ",[197,1628,1572],{"class":202},[197,1630,1575],{"class":292},[197,1632,1633,1636,1638],{"class":150,"line":640},[197,1634,1635],{"class":206},"    issues ",[197,1637,268],{"class":202},[197,1639,1480],{"class":206},[197,1641,1642,1644],{"class":150,"line":652},[197,1643,1456],{"class":202},[197,1645,1646],{"class":206}," invalid:\n",[197,1648,1649,1652,1654,1657,1659,1662,1664,1667],{"class":150,"line":669},[197,1650,1651],{"class":206},"        issues.append(",[197,1653,701],{"class":202},[197,1655,1656],{"class":285},"\"",[197,1658,707],{"class":292},[197,1660,1661],{"class":206},"invalid",[197,1663,713],{"class":292},[197,1665,1666],{"class":285}," invalid geometries\"",[197,1668,346],{"class":206},[197,1670,1671,1673],{"class":150,"line":692},[197,1672,1456],{"class":202},[197,1674,1675],{"class":206}," missing:\n",[197,1677,1678,1680,1682,1684,1686,1689,1691,1694,1696,1699,1702,1704,1706],{"class":150,"line":719},[197,1679,1651],{"class":206},[197,1681,701],{"class":202},[197,1683,1656],{"class":285},[197,1685,707],{"class":292},[197,1687,1688],{"class":206},"missing",[197,1690,713],{"class":292},[197,1692,1693],{"class":285}," features missing ",[197,1695,707],{"class":292},[197,1697,1698],{"class":285},"', '",[197,1700,1701],{"class":206},".join(names)",[197,1703,713],{"class":292},[197,1705,1656],{"class":285},[197,1707,346],{"class":206},[197,1709,1710,1713],{"class":150,"line":739},[197,1711,1712],{"class":202},"    return",[197,1714,1715],{"class":206}," issues\n",[197,1717,1718],{"class":150,"line":747},[197,1719,259],{"emptyLinePlaceholder":258},[197,1721,1722,1724,1727,1729,1731],{"class":150,"line":754},[197,1723,505],{"class":202},[197,1725,1726],{"class":508}," QualityIndicators",[197,1728,1079],{"class":206},[197,1730,1082],{"class":508},[197,1732,1085],{"class":206},[197,1734,1735,1737,1739],{"class":150,"line":759},[197,1736,517],{"class":202},[197,1738,585],{"class":508},[197,1740,588],{"class":206},[197,1742,1743,1746,1748],{"class":150,"line":770},[197,1744,1745],{"class":206},"        issues ",[197,1747,268],{"class":202},[197,1749,1750],{"class":206}," quick_issues(layer)\n",[197,1752,1753,1755],{"class":150,"line":780},[197,1754,631],{"class":202},[197,1756,1757],{"class":206}," issues:\n",[197,1759,1760,1762,1764],{"class":150,"line":796},[197,1761,695],{"class":292},[197,1763,698],{"class":206},[197,1765,1766],{"class":285},"\"Quality check (first 2,000 features): \"\n",[197,1768,1769,1772,1775,1778,1781,1784],{"class":150,"line":802},[197,1770,1771],{"class":202},"                                      +",[197,1773,1774],{"class":285}," \"; \"",[197,1776,1777],{"class":206},".join(issues) ",[197,1779,1780],{"class":202},"+",[197,1782,1783],{"class":285}," \". Click for the full report.\"",[197,1785,346],{"class":206},[197,1787,1788,1790],{"class":150,"line":815},[197,1789,742],{"class":202},[197,1791,512],{"class":206},[197,1793,1794,1796],{"class":150,"line":829},[197,1795,695],{"class":292},[197,1797,649],{"class":206},[14,1799,1800,1802,1803,1807],{},[356,1801,358],{}," Checking a limited sample keeps the rule fast enough to run whenever a layer is added; the tooltip says it is a sample so users do not mistake it for a full audit. Subclassing the stale-data manager reuses all of its bookkeeping — the dictionary, signal connections and cleanup — and replaces only the rule. Clicking can launch the full ",[21,1804,1806],{"href":1805},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis\u002F","data quality report"," as a background task. Running the geometry check on very large or remote layers still costs time, so restrict this rule to local file formats if your users work with big PostGIS tables.",[160,1809,1811],{"id":1810},"wire-it-into-the-plugin","Wire it into the plugin",[14,1813,1814,1815,1818,1819,178],{},"The plugin creates the manager in ",[194,1816,1817],{},"initGui",", starts it, and stops it in ",[194,1820,1821],{},"unload",[187,1823,1825],{"className":189,"code":1824,"language":191,"meta":192,"style":192},"import os\n\nclass StaleDataPlugin:\n    def __init__(self, iface):\n        self.iface = iface\n        self.indicators = None\n\n    def initGui(self):\n        icon = os.path.join(os.path.dirname(__file__), \"icons\", \"stale.svg\")\n        self.indicators = StaleIndicators(self.iface, icon)\n        self.indicators.start()\n\n    def unload(self):\n        if self.indicators:\n            self.indicators.stop()\n            self.indicators = None\n",[194,1826,1827,1834,1838,1847,1856,1866,1877,1881,1890,1916,1932,1939,1943,1952,1961,1968],{"__ignoreMap":192},[197,1828,1829,1831],{"class":150,"line":199},[197,1830,210],{"class":202},[197,1832,1833],{"class":206}," os\n",[197,1835,1836],{"class":150,"line":216},[197,1837,259],{"emptyLinePlaceholder":258},[197,1839,1840,1842,1845],{"class":150,"line":229},[197,1841,505],{"class":202},[197,1843,1844],{"class":508}," StaleDataPlugin",[197,1846,512],{"class":206},[197,1848,1849,1851,1853],{"class":150,"line":242},[197,1850,517],{"class":202},[197,1852,520],{"class":292},[197,1854,1855],{"class":206},"(self, iface):\n",[197,1857,1858,1860,1862,1864],{"class":150,"line":255},[197,1859,528],{"class":292},[197,1861,531],{"class":206},[197,1863,268],{"class":202},[197,1865,252],{"class":206},[197,1867,1868,1870,1872,1874],{"class":150,"line":262},[197,1869,528],{"class":292},[197,1871,565],{"class":206},[197,1873,268],{"class":202},[197,1875,1876],{"class":292}," None\n",[197,1878,1879],{"class":150,"line":274},[197,1880,259],{"emptyLinePlaceholder":258},[197,1882,1883,1885,1888],{"class":150,"line":298},[197,1884,517],{"class":202},[197,1886,1887],{"class":508}," initGui",[197,1889,1095],{"class":206},[197,1891,1892,1895,1897,1900,1903,1906,1909,1911,1914],{"class":150,"line":309},[197,1893,1894],{"class":206},"        icon ",[197,1896,268],{"class":202},[197,1898,1899],{"class":206}," os.path.join(os.path.dirname(",[197,1901,1902],{"class":292},"__file__",[197,1904,1905],{"class":206},"), ",[197,1907,1908],{"class":285},"\"icons\"",[197,1910,622],{"class":206},[197,1912,1913],{"class":285},"\"stale.svg\"",[197,1915,346],{"class":206},[197,1917,1918,1920,1922,1924,1927,1929],{"class":150,"line":314},[197,1919,528],{"class":292},[197,1921,565],{"class":206},[197,1923,268],{"class":202},[197,1925,1926],{"class":206}," StaleIndicators(",[197,1928,840],{"class":292},[197,1930,1931],{"class":206},".iface, icon)\n",[197,1933,1934,1936],{"class":150,"line":325},[197,1935,528],{"class":292},[197,1937,1938],{"class":206},".indicators.start()\n",[197,1940,1941],{"class":150,"line":337},[197,1942,259],{"emptyLinePlaceholder":258},[197,1944,1945,1947,1950],{"class":150,"line":349},[197,1946,517],{"class":202},[197,1948,1949],{"class":508}," unload",[197,1951,1095],{"class":206},[197,1953,1954,1956,1958],{"class":150,"line":640},[197,1955,631],{"class":202},[197,1957,646],{"class":292},[197,1959,1960],{"class":206},".indicators:\n",[197,1962,1963,1965],{"class":150,"line":652},[197,1964,695],{"class":292},[197,1966,1967],{"class":206},".indicators.stop()\n",[197,1969,1970,1972,1974,1976],{"class":150,"line":669},[197,1971,695],{"class":292},[197,1973,565],{"class":206},[197,1975,268],{"class":202},[197,1977,1876],{"class":292},[14,1979,1980,1982,1983,1985],{},[356,1981,358],{}," Resolving the icon path relative to the plugin file keeps it working wherever the plugin is installed. Everything the plugin added is removed in ",[194,1984,1821],{},", leaving the Layers panel exactly as it was. That symmetry — every connect has a disconnect, every add has a remove — is the most important habit in plugins that extend QGIS's interface.",[160,1987,1989],{"id":1988},"design-indicators-users-understand","Design indicators users understand",[14,1991,1992],{},"Indicators compete for a small space. A few rules keep them useful.",[14,1994,1995],{},[29,1996,1999,2002,2005,2008,2011,2015,2020,2024,2028,2032,2036,2039,2042,2045,2049,2052,2055,2058,2062,2065],{"viewBox":1997,"role":32,"ariaLabel":1998,"xmlns":34},"0 0 760 212","Four design rules for indicators: distinct icon, informative tooltip, exceptional states only, and an obvious click action",[36,2000,2001],{},"Indicator design rules",[40,2003,2004],{},"Use a distinct, simple icon that does not imitate built-in indicators. Put the explanation and the action in the tooltip. Show an indicator only for exceptional states, never for the normal case, so its presence means something. Make clicking do the obvious thing, such as opening the fix.",[44,2006],{"x":46,"y":46,"width":47,"height":2007,"fill":49},"212",[51,2009,2010],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Rare, clear, actionable",[44,2012],{"x":409,"y":2013,"width":2014,"height":452,"rx":132,"fill":435,"stroke":75,"style":133},"56","168",[51,2016,2019],{"x":2017,"y":2018,"style":416,"fill":75,"textAnchor":57},"108","103.78","distinct",[51,2021,2023],{"x":2017,"y":2022,"style":421,"fill":81,"textAnchor":57},"129.78","simple icon",[51,2025,2027],{"x":2017,"y":2026,"style":421,"fill":67,"textAnchor":57},"155.78","not a built-in",[44,2029],{"x":124,"y":2013,"width":2014,"height":452,"rx":132,"fill":2030,"stroke":2031,"style":133},"#eff3ff","#2563eb",[51,2033,2035],{"x":2034,"y":2018,"style":416,"fill":2031,"textAnchor":57},"290","explained",[51,2037,2038],{"x":2034,"y":2022,"style":421,"fill":81,"textAnchor":57},"tooltip says",[51,2040,2041],{"x":2034,"y":2026,"style":421,"fill":67,"textAnchor":57},"what and why",[44,2043],{"x":2044,"y":2013,"width":2014,"height":452,"rx":132,"fill":113,"stroke":114,"style":133},"388",[51,2046,2048],{"x":2047,"y":2018,"style":416,"fill":114,"textAnchor":57},"472","exceptional",[51,2050,2051],{"x":2047,"y":2022,"style":421,"fill":81,"textAnchor":57},"only when",[51,2053,2054],{"x":2047,"y":2026,"style":421,"fill":67,"textAnchor":57},"something's wrong",[44,2056],{"x":2057,"y":2013,"width":105,"height":452,"rx":132,"fill":469,"stroke":470,"style":133},"570",[51,2059,2061],{"x":2060,"y":2018,"style":416,"fill":474,"textAnchor":57},"653","actionable",[51,2063,2064],{"x":2060,"y":2022,"style":421,"fill":81,"textAnchor":57},"click opens",[51,2066,2067],{"x":2060,"y":2026,"style":421,"fill":67,"textAnchor":57},"the fix",[14,2069,2070],{},"Show indicators for exceptions, never for the normal state — an icon on every layer stops meaning anything. Avoid icons that resemble QGIS's own indicators, so users do not confuse a plugin's warning with a CRS problem. Put the reason and the remedy in the tooltip. And make the click do the obvious thing: open the dialog that resolves the issue, or the documentation that explains it.",[160,2072,2074],{"id":2073},"qgis-version-compatibility","QGIS version compatibility",[14,2076,2077,2080,2081,2084,2085,2088,2089,2092,2093,2096],{},[194,2078,2079],{},"QgsLayerTreeViewIndicator"," and ",[194,2082,2083],{},"addIndicator","\u002F",[194,2086,2087],{},"removeIndicator"," exist since QGIS 3.2 and work unchanged on 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, ",[194,2090,2091],{},"QTimer"," and other Qt classes come from PyQt6 through ",[194,2094,2095],{},"qgis.PyQt"," with no code changes for this example.",[160,2098,2100],{"id":2099},"troubleshooting","Troubleshooting",[165,2102,2103,2109,2117,2123],{},[168,2104,2105,2108],{},[356,2106,2107],{},"Indicators multiply."," The rule adds a new indicator each run; keep a dictionary and update existing ones.",[168,2110,2111,2114,2115,178],{},[356,2112,2113],{},"Indicators vanish after opening a project."," The new project's layers were never checked; connect to ",[194,2116,1383],{},[168,2118,2119,2122],{},[356,2120,2121],{},"Clicks hit the wrong layer."," The lambda captured a loop variable by reference; bind it with a default argument.",[168,2124,2125,2128,2129,178],{},[356,2126,2127],{},"Indicators remain after the plugin unloads."," They were not removed in ",[194,2130,1391],{},[160,2132,2134],{"id":2133},"conclusion","Conclusion",[14,2136,2137,2138,2140],{},"Attach indicators to layer tree nodes with ",[194,2139,2079],{},", evaluate a clear rule per layer and update rather than duplicate, re-run the rule on layer, project and timer events, make clicks open the fix, and remove every indicator and connection when the plugin unloads.",[160,2142,2144],{"id":2143},"frequently-asked-questions","Frequently Asked Questions",[14,2146,2147,2150],{},[356,2148,2149],{},"Can indicators be added to groups?","\nYes; attach them to group nodes the same way.",[14,2152,2153,2156],{},[356,2154,2155],{},"Do indicators show in the layer tree of a print layout legend?","\nNo. They are part of the Layers panel view only.",[14,2158,2159,2162,2163,2166],{},[356,2160,2161],{},"Can I change the icon dynamically?","\nYes; call ",[194,2164,2165],{},"setIcon"," on the existing indicator, for example to show severity levels.",[14,2168,2169,2172],{},[356,2170,2171],{},"Are indicators saved in the project?","\nNo. Store the underlying state, such as a custom property, and recompute indicators on load.",[160,2174,2176],{"id":2175},"related","Related",[165,2178,2179,2184,2190,2196,2202],{},[168,2180,2181,2183],{},[21,2182,24],{"href":23}," — the guide this recipe belongs to",[168,2185,2186],{},[21,2187,2189],{"href":2188},"\u002Fqgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002Fadd-custom-layer-properties-page-pyqgis\u002F","Add a Custom Layer Properties Page in PyQGIS",[168,2191,2192],{},[21,2193,2195],{"href":2194},"\u002Fqgis-plugin-development\u002Fcustom-map-tools-and-canvas-interaction\u002Fadd-layer-context-menu-action-pyqgis\u002F","Add a Layer Context Menu Action in PyQGIS",[168,2197,2198],{},[21,2199,2201],{"href":2200},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Forganise-layer-tree-groups-pyqgis\u002F","Organise Layer Tree Groups in PyQGIS",[168,2203,2204],{},[21,2205,2207],{"href":2206},"\u002Fqgis-plugin-development\u002Fcustom-map-tools-and-canvas-interaction\u002Fconnect-layer-signals-pyqgis\u002F","Connect Layer Signals in PyQGIS",[2209,2210,2211],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":192,"searchDepth":216,"depth":216,"links":2213},[2214,2215,2216,2217,2218,2219,2220,2221,2222,2223,2224,2225],{"id":162,"depth":216,"text":163},{"id":181,"depth":216,"text":182},{"id":369,"depth":216,"text":370},{"id":1045,"depth":216,"text":1046},{"id":1399,"depth":216,"text":1400},{"id":1810,"depth":216,"text":1811},{"id":1988,"depth":216,"text":1989},{"id":2073,"depth":216,"text":2074},{"id":2099,"depth":216,"text":2100},{"id":2133,"depth":216,"text":2134},{"id":2143,"depth":216,"text":2144},{"id":2175,"depth":216,"text":2176},"Show status icons next to layers in the QGIS Layers panel with QgsLayerTreeViewIndicator — flagging stale data, unsaved edits or validation failures, updating icons and tooltips as layers change, handling clicks, and cleaning up when the plugin unloads.","md",{"slug":2229,"type":2230,"breadcrumb":2231,"datePublished":2232,"dateModified":2232},"add-layer-tree-indicator-pyqgis","article","Add a Layer Tree Indicator","2026-10-02","\u002Fqgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002Fadd-layer-tree-indicator-pyqgis",{"title":5,"description":2226},"qgis-plugin-development\u002Fextending-qgis-with-custom-classes\u002Fadd-layer-tree-indicator-pyqgis\u002Findex","vMvPiNWWS7mOXtCk_7g_FcxQ37YxwTfnag9r85VGEe8",1790966257269]