details:   https://code.tryton.org/tryton/commit/9c009cac1512
branch:    default
user:      Cédric Krier <[email protected]>
date:      Tue Mar 03 19:48:24 2026 +0100
description:
        Store column widths per occurrence

        Closes #14645
diffstat:

 sao/src/view/tree.js                                |   41 +++++-
 tryton/tryton/gui/window/view_form/screen/screen.py |    3 +-
 tryton/tryton/gui/window/view_form/view/list.py     |   34 ++++-
 trytond/trytond/ir/message.xml                      |    4 +
 trytond/trytond/ir/ui/view.py                       |  119 ++++++++++++++-----
 trytond/trytond/ir/view/ui_view_tree_width_form.xml |    5 +-
 trytond/trytond/ir/view/ui_view_tree_width_list.xml |    1 +
 trytond/trytond/model/modelview.py                  |   19 +-
 trytond/trytond/tests/test_ir.py                    |   30 ++---
 9 files changed, 177 insertions(+), 79 deletions(-)

diffs (545 lines):

diff -r 9bd2b4e3d941 -r 9c009cac1512 sao/src/view/tree.js
--- a/sao/src/view/tree.js      Tue Mar 03 18:50:22 2026 +0100
+++ b/sao/src/view/tree.js      Tue Mar 03 19:48:24 2026 +0100
@@ -455,6 +455,23 @@
             }
             Sao.Screen.tree_column_optional[this.view_id] = fields;
         },
+        _get_column_occurence: function(column) {
+            let occurrence = 0;
+            let found = false;
+            for (let col of this.columns) {
+                if (col.attributes.name == column.attributes.name) {
+                    occurrence += 1;
+                    if (col === column) {
+                        found = true;
+                        break;
+                    }
+                }
+            }
+            if (!found) {
+                occurrence += 1;
+            }
+            return occurrence;
+        },
         _set_column_width: function(column) {
             let default_width = {
                 'integer': 8,
@@ -475,9 +492,10 @@
             default_width = default_width * 100 * factor  + '%';
             column.col.data('default-width', default_width);
 
+            let index = this._get_column_occurence(column) - 1;
             let tree_column_width = (
                 Sao.Screen.tree_column_width[this.screen.model_name] || {});
-            let width = tree_column_width[column.attributes.name];
+            let width = (tree_column_width[column.attributes.name] || 
[])[index];
             if (width || column.attributes.width) {
                 if (!width) {
                     width = column.attributes.width;
@@ -489,9 +507,9 @@
             column.col.css('width', width);
         },
         save_width: function() {
-            var widths = {};
+            let fields = {};
             for (let column of this.columns) {
-                if (!column.get_visible() || !column.attributes.name ||
+                if (!column.attributes.name ||
                     column instanceof Sao.View.Tree.ButtonColumn) {
                     continue;
                 }
@@ -499,25 +517,30 @@
                 // Use the DOM element to retrieve the exact style set
                 var width = column.col[0].style.width;
                 let custom_width = column.col.data('custom-width');
-                if (width.endsWith('px') && (width != custom_width)) {
-                    widths[column.attributes.name] = Number(width.slice(0, 
-2));
+                if (!width.endsWith('px') ||
+                    (width == custom_width) ||
+                    !column.get_visible()) {
+                    width = null;
+                } else {
+                    width = Number(width.slice(0, -2));
                 }
+                Sao.setdefault(fields, column.attributes.name, []).push(width);
             }
 
-            if (!jQuery.isEmptyObject(widths)) {
+            if (Object.values(fields).some(w => w.some(v => v))) {
                 let model_name = this.screen.model_name;
                 let TreeWidth = new Sao.Model('ir.ui.view_tree_width');
                 TreeWidth.execute(
                     'set_width',
-                    [model_name, widths, window.screen.width],
+                    [model_name, fields, window.screen.width],
                     {});
                 if (Object.prototype.hasOwnProperty.call(
                         Sao.Screen.tree_column_width, model_name)) {
                     Object.assign(
                         Sao.Screen.tree_column_width[model_name],
-                        widths);
+                        fields);
                 } else {
-                    Sao.Screen.tree_column_width[model_name] = widths;
+                    Sao.Screen.tree_column_width[model_name] = fields;
                 }
             }
         },
diff -r 9bd2b4e3d941 -r 9c009cac1512 
tryton/tryton/gui/window/view_form/screen/screen.py
--- a/tryton/tryton/gui/window/view_form/screen/screen.py       Tue Mar 03 
18:50:22 2026 +0100
+++ b/tryton/tryton/gui/window/view_form/screen/screen.py       Tue Mar 03 
19:48:24 2026 +0100
@@ -38,7 +38,8 @@
 
     # Width of tree columns per model
     # It is shared with all connection but it is the price for speed.
-    tree_column_width = collections.defaultdict(lambda: {})
+    tree_column_width = collections.defaultdict(
+        lambda: collections.defaultdict(list))
     tree_column_optional = {}
 
     def __init__(self, model_name, **attributes):
diff -r 9bd2b4e3d941 -r 9c009cac1512 
tryton/tryton/gui/window/view_form/view/list.py
--- a/tryton/tryton/gui/window/view_form/view/list.py   Tue Mar 03 18:50:22 
2026 +0100
+++ b/tryton/tryton/gui/window/view_form/view/list.py   Tue Mar 03 19:48:24 
2026 +0100
@@ -434,6 +434,17 @@
         column.set_widget(widget)
         column.set_alignment(align)
 
+    def _get_column_occurence(self, column):
+        occurrence = 0
+        for col in self.view.treeview.get_columns():
+            if col.name == column.name:
+                occurrence += 1
+                if col == column:
+                    break
+        else:
+            occurrence += 1
+        return occurrence
+
     def _set_column_width(self, column, attributes):
         default_width = {
             'integer': 80,
@@ -454,7 +465,13 @@
             computed_width = 80
 
         screen = self.view.screen
-        width = screen.tree_column_width[screen.model_name].get(column.name)
+        index = self._get_column_occurence(column) - 1
+        widths = screen.tree_column_width[screen.model_name].get(
+            column.name, [])
+        try:
+            width = widths[index]
+        except IndexError:
+            width = None
         if width or attributes.get('width'):
             if not width:
                 width = int(attributes['width'])
@@ -1117,23 +1134,24 @@
     def save_width(self):
         if not CONFIG['client.save_tree_width']:
             return
-        fields = {}
+        fields = defaultdict(list)
         last_col = None
         for col in self.treeview.get_columns():
             if col.get_visible():
                 last_col = col
             if not hasattr(col, 'name') or not hasattr(col, 'width'):
                 continue
-            if (col.get_width() != col.width and col.get_visible()
-                    and not col.get_expand()):
-                fields[col.name] = col.get_width()
+            width = col.get_width()
+            if width == col.width or not col.get_visible() or col.get_expand():
+                width = None
+            fields[col.name].append(width)
         # Don't set width for last visible columns
         # as it depends of the screen size
-        if last_col and last_col.name in fields:
-            del fields[last_col.name]
+        if last_col and fields[last_col.name]:
+            fields[last_col.name][-1] = None
 
         screen_width, _ = get_monitor_size()
-        if fields and any(fields.values()):
+        if any(any(w) for w in fields.values()):
             model_name = self.screen.model_name
             try:
                 RPCExecute('model', 'ir.ui.view_tree_width', 'set_width',
diff -r 9bd2b4e3d941 -r 9c009cac1512 trytond/trytond/ir/message.xml
--- a/trytond/trytond/ir/message.xml    Tue Mar 03 18:50:22 2026 +0100
+++ b/trytond/trytond/ir/message.xml    Tue Mar 03 19:48:24 2026 +0100
@@ -287,6 +287,10 @@
             <field name="text">Invalid XML for view "%(name)s".</field>
         </record>
 
+        <record model="ir.message" 
id="msg_view_tree_width_field_occurrence_user_unique">
+            <field name="text">A user can set only one width per occurrence of 
field.</field>
+        </record>
+
         <record model="ir.message" id="msg_action_wrong_wizard_model">
             <field name="text">Wrong wizard model in keyword action 
"%(name)s".</field>
         </record>
diff -r 9bd2b4e3d941 -r 9c009cac1512 trytond/trytond/ir/ui/view.py
--- a/trytond/trytond/ir/ui/view.py     Tue Mar 03 18:50:22 2026 +0100
+++ b/trytond/trytond/ir/ui/view.py     Tue Mar 03 19:48:24 2026 +0100
@@ -1,15 +1,21 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
+
+import copy
 import json
 import logging
 import os
+from collections import defaultdict
 
 from lxml import etree
 from sql import Literal, Null
+from sql.conditionals import Coalesce
+from sql.operators import Equal
 
 from trytond.cache import Cache, MemoryCache
 from trytond.i18n import gettext
-from trytond.model import Index, ModelSQL, ModelView, fields, sequence_ordered
+from trytond.model import (
+    Exclude, Index, ModelSQL, ModelView, fields, sequence_ordered)
 from trytond.model.exceptions import ValidationError
 from trytond.pool import Pool
 from trytond.pyson import PYSON, Bool, Eval, If, PYSONDecoder
@@ -456,10 +462,20 @@
     __name__ = 'ir.ui.view_tree_width'
     model = fields.Char('Model', required=True)
     field = fields.Char('Field', required=True)
+    occurrence = fields.Integer("Occurrence", required=True)
     user = fields.Many2One('res.user', 'User', required=True,
         ondelete='CASCADE')
-    screen_width = fields.Integer("Screen Width")
-    width = fields.Integer('Width', required=True)
+    screen_width = fields.Integer(
+        "Screen Width",
+        domain=[
+            ('screen_width', '>=', 0),
+            ])
+    width = fields.Integer(
+        "Width",
+        domain=['OR',
+            ('width', '=', None),
+            ('width', '>=', 0),
+            ])
 
     @classmethod
     def __setup__(cls):
@@ -469,12 +485,36 @@
                 'set_width': RPC(readonly=False),
                 'reset_width': RPC(readonly=False),
                 })
+        cls._sql_constraints += [
+            ('field_occurrence_user_unique',
+                Exclude(table,
+                    (table.model, Equal),
+                    (table.field, Equal),
+                    (table.occurrence, Equal),
+                    (table.user, Equal),
+                    (Coalesce(table.screen_width, -1), Equal)),
+                'ir.msg_view_tree_width_field_occurrence_user_unique'),
+            ]
         cls._sql_indexes.add(
             Index(
                 table,
                 (table.user, Index.Range()),
                 (table.model, Index.Equality()),
-                (table.field, Index.Equality())))
+                (table.field, Index.Equality()),
+                (table.occurrence, Index.Equality())))
+
+    @classmethod
+    def __register__(cls, module):
+        table_h = cls.__table_handler__(module)
+
+        super().__register__(module)
+
+        # Migration from 7.8: remove required on width
+        table_h.not_null_action('width', 'remove')
+
+    @classmethod
+    def default_occurrence(cls):
+        return 1
 
     def get_rec_name(self, name):
         return f'{self.field_ref.rec_name} @ {self.model_ref.rec_name}'
@@ -502,14 +542,15 @@
             if width >= screen_width:
                 break
         else:
-            screen_width = 0
+            screen_width = None
 
         user = Transaction().user
         records = cls.search([
             ('user', '=', user),
             ('model', '=', model),
             ('screen_width', '=', screen_width),
-            ])
+            ],
+            order=[('occurrence', 'ASC')])
 
         if not records:
             records = cls.search([
@@ -522,58 +563,70 @@
                 ],
                 order=[
                     ('screen_width', 'DESC NULLS LAST'),
+                    ('occurrence', 'ASC'),
                     ])
-        widths = {}
+        screen_width = None
+        widths = defaultdict(list)
         for width in records:
-            if width.field not in widths:
-                widths[width.field] = width.width
+            if screen_width is None:
+                screen_width = width.screen_width
+            if screen_width != width.screen_width:
+                break
+            if len(widths[width.field]) + 1 < width.occurrence:
+                for _ in range(
+                        width.occurrence - len(widths[width.field]) - 1):
+                    widths[width.field].append(None)
+            widths[width.field].insert(width.occurrence, width.width)
         return widths
 
     @classmethod
     def set_width(cls, model, fields, width):
         '''
         Set width for the current user on the model.
-        fields is a dictionary with key: field name and value: width.
+        fields is dictionary with field name as key and a list of widths
+        as value.
+        width is the screen width.
         '''
         for screen_width in WIDTH_BREAKPOINTS:
             if width >= screen_width:
                 break
         else:
-            screen_width = 0
+            screen_width = None
 
         user_id = Transaction().user
         records = cls.search([
                 ('user', '=', user_id),
                 ('model', '=', model),
                 ('field', 'in', list(fields.keys())),
-                ['OR',
-                    ('screen_width', '=', screen_width),
-                    ('screen_width', '=', None),
-                    ],
-                ])
+                ('screen_width', '=', screen_width),
+                ],
+            order=[('occurrence', 'DESC')])
 
-        fields = fields.copy()
-        to_save, to_delete = [], []
+        fields = copy.deepcopy(fields)
+        to_save = []
         for tree_width in records:
             if tree_width.screen_width == screen_width:
-                if tree_width.field in fields:
-                    tree_width.width = fields.pop(tree_width.field)
-                    to_save.append(tree_width)
-                else:
-                    to_delete.append(tree_width)
+                index = tree_width.occurrence - 1
+                if index <= len(fields[tree_width.field]):
+                    width = fields[tree_width.field][index]
+                    fields[tree_width.field][index] = None
+                    if width is not None:
+                        tree_width.width = width
+                        to_save.append(tree_width)
 
-        for name, width in fields.items():
-            to_save.append(cls(
-                    user=user_id,
-                    model=model,
-                    field=name,
-                    screen_width=screen_width,
-                    width=width))
+        for name, widths in fields.items():
+            for occurrence, width in enumerate(widths, start=1):
+                if width is not None:
+                    to_save.append(cls(
+                            user=user_id,
+                            model=model,
+                            field=name,
+                            occurrence=occurrence,
+                            screen_width=screen_width,
+                            width=width))
 
         if to_save:
             cls.save(to_save)
-        if to_delete:
-            cls.delete(to_delete)
 
     @classmethod
     def reset_width(cls, model, width):
@@ -581,7 +634,7 @@
             if width >= screen_width:
                 break
         else:
-            screen_width = 0
+            screen_width = None
 
         user_id = Transaction().user
         records = cls.search([
diff -r 9bd2b4e3d941 -r 9c009cac1512 
trytond/trytond/ir/view/ui_view_tree_width_form.xml
--- a/trytond/trytond/ir/view/ui_view_tree_width_form.xml       Tue Mar 03 
18:50:22 2026 +0100
+++ b/trytond/trytond/ir/view/ui_view_tree_width_form.xml       Tue Mar 03 
19:48:24 2026 +0100
@@ -5,7 +5,10 @@
     <label name="model_ref"/>
     <field name="model_ref"/>
     <label name="field_ref"/>
-    <field name="field_ref"/>
+    <group id="field" col="-1">
+        <field name="field_ref"/>
+        <field name="occurrence"/>
+    </group>
     <label name="user"/>
     <field name="user"/>
     <label name="screen_width"/>
diff -r 9bd2b4e3d941 -r 9c009cac1512 
trytond/trytond/ir/view/ui_view_tree_width_list.xml
--- a/trytond/trytond/ir/view/ui_view_tree_width_list.xml       Tue Mar 03 
18:50:22 2026 +0100
+++ b/trytond/trytond/ir/view/ui_view_tree_width_list.xml       Tue Mar 03 
19:48:24 2026 +0100
@@ -4,6 +4,7 @@
 <tree>
     <field name="model_ref" expand="1"/>
     <field name="field_ref" expand="1"/>
+    <field name="occurrence" optional="1"/>
     <field name="user" expand="1"/>
     <field name="screen_width"/>
     <field name="width"/>
diff -r 9bd2b4e3d941 -r 9c009cac1512 trytond/trytond/model/modelview.py
--- a/trytond/trytond/model/modelview.py        Tue Mar 03 18:50:22 2026 +0100
+++ b/trytond/trytond/model/modelview.py        Tue Mar 03 19:48:24 2026 +0100
@@ -430,7 +430,7 @@
                 if nodes and depends:
                     view_depends.extend(depends)
 
-        fields_width = {}
+        fields_width = collections.defaultdict(list)
         fields_optional = {}
         tree_root = tree.getroottree().getroot()
 
@@ -497,10 +497,7 @@
             width, _ = Transaction().context.get('screen_size', (None, None))
             if Transaction().context.get('view_tree_width'):
                 ViewTreeWidth = pool.get('ir.ui.view_tree_width')
-                col_widths = ViewTreeWidth.get_width(cls.__name__, width)
-                fields_width.update({fname: w
-                        for fname, w in col_widths.items()
-                        if w > 0})
+                fields_width = ViewTreeWidth.get_width(cls.__name__, width)
 
             if view_id:
                 ViewTreeOptional = pool.get('ir.ui.view_tree_optional')
@@ -571,7 +568,7 @@
         ActionWindow = pool.get('ir.action.act_window')
 
         if fields_width is None:
-            fields_width = {}
+            fields_width = collections.defaultdict(list)
         if fields_optional is None:
             fields_optional = {}
         if _fields_attrs is None:
@@ -639,9 +636,13 @@
                     fields_attrs[fname].setdefault('views', {}).update(views)
 
             if type == 'tree':
-                if element.get('name') in fields_width:
-                    element.set(
-                        'width', str(fields_width[element.get('name')]))
+                try:
+                    width = fields_width[element.get('name')].pop(0)
+                except IndexError:
+                    pass
+                else:
+                    if width is not None:
+                        element.set('width', str(width))
                 if element.get('optional'):
                     if element.get('name') in fields_optional:
                         optional = str(int(
diff -r 9bd2b4e3d941 -r 9c009cac1512 trytond/trytond/tests/test_ir.py
--- a/trytond/trytond/tests/test_ir.py  Tue Mar 03 18:50:22 2026 +0100
+++ b/trytond/trytond/tests/test_ir.py  Tue Mar 03 19:48:24 2026 +0100
@@ -214,8 +214,8 @@
 
         model = 'ir.ui.view_tree_width'
         ViewTreeWidth.set_width(model, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 }, 1000)
 
         records = ViewTreeWidth.search([
@@ -242,16 +242,10 @@
                     'field': 'user',
                     'screen_width': 992,
                     'width': 200,
-                    }, {
-                    'user': Transaction().user,
-                    'model': model,
-                    'field': 'user',
-                    'screen_width': 992,
-                    'width': 300,
                     }])
         ViewTreeWidth.set_width(model, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 }, 1000)
 
         records = ViewTreeWidth.search([
@@ -307,16 +301,16 @@
 
         model = 'ir.ui.view_tree_width'
         ViewTreeWidth.set_width(model, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 }, 1000)
 
         widths = ViewTreeWidth.get_width(model, 1000)
 
         self.assertEqual(
             widths, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 })
 
     @with_transaction()
@@ -327,16 +321,16 @@
 
         model = 'ir.ui.view_tree_width'
         ViewTreeWidth.set_width(model, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 }, 500)
 
         widths = ViewTreeWidth.get_width(model, 1000)
 
         self.assertEqual(
             widths, {
-                'user': 100,
-                'screen_width': 50,
+                'user': [100],
+                'screen_width': [50],
                 })
 
     @with_transaction()

Reply via email to