Kак ограничить область действия MouseWheel?

Программирование на Visual Basic, главный форум. Обсуждение тем программирования на VB 1—6.
Даже если вы плохо разбираетесь в VB и программировании вообще — тут вам помогут. В разумных пределах, конечно.
Правила форума
Темы, в которых будет сначала написано «что нужно сделать», а затем просьба «помогите», будут закрыты.
Читайте требования к создаваемым темам.
kibernetics
Постоялец
Постоялец
Аватара пользователя
 
Сообщения: 945
Зарегистрирован: 03.05.2006 (Ср) 13:31
Откуда: Minsk

Kак ограничить область действия MouseWheel?

Сообщение kibernetics » 07.05.2020 (Чт) 15:22

Ребят, всем привет!

вот решил освежить один старый проектик, у которого не было события MouseWheel для скролинга фрейма (Frame1).
Пошуршал по проджектобменникам, нашёл одну реализацию с навешиванием MouseWheel, собрал тестовый проектик, и столкнулся с одной досадной проблемой.
Значит, mouse_скрол для Frame1 теперь работает, но вот если навести мышь на рядом расположенный ucListView, то список в нём не скролится, а скролится всё тот же фрейм.
Короче, из любого места формы скролится только этот фрейм. Помогите допилить проект, чтобы скрол фрейма работал, только при событии MouseOver над ним.
И скролинг списка ucListView нормально заработал.
scroll_frame.JPG
scroll_frame.JPG (66.21 Кб) Просмотров: 1394

TestFrameScroll.zip
(45.46 Кб) Скачиваний: 141

Хакер
Телепат
Телепат
Аватара пользователя
 
Сообщения: 16478
Зарегистрирован: 13.11.2005 (Вс) 2:43
Откуда: Казахстан, Петропавловск

Re: Kак ограничить область действия MouseWheel?

Сообщение Хакер » 08.05.2020 (Пт) 0:06

kibernetics писал(а): нашёл одну реализацию с навешиванием MouseWheel,

Реализация — шляпа полная.

Почему он никуда не годится и никогда не должен применяться?
  1. Во-первых, он ломает и перепутывает то, как работа событийно-ориентированной парадигмы соотносится с временем жизни объектов. VB исповедует событийно-ориентированную парадигму: это означает, что код проекта не обязан постоянно выполняться и постоянно что-то делать, а вместо этого код представляет собой коротенькие кусочки-обработчики, которые дёргаются извне лишь тогда, когда надо на что-то отреагировать. VB-шному коду нет нужды постоянно выполняться, постоянно проверяь очередь сообщений, каждое сообщение анализировать и решать, как с ним разобраться.

    Всё это делает рантайм VB. Там крутится цикл прокачки сообщений. Внутри которого для каждого сообщения вызывается DispatchMessage. DispatchMessage сама находит получателя оконного сообщения и вызывает его WindowProc. Для всех встроенных контролов оконную процедуру предоставляет рантайм. Внутри оконной процедуры сообщение анализируется и вызывается соответствующий обработчик события (если он предоставлен VB-программистом) и только в этот момент начинает работать VB-код.

    Когда VB-шный код обработчика заканчивает свою работу — куда происходит возврат? Это очень важно понимать. Возврат происходит в оконную процедуру контрола, а оттуда в DispatchMessage, а из DispatchMessage, а оттуда — обратно в корневой цикл прокачки сообщений. Или не обязательно в корневой — функции, обеспечивающие показ любых модальных окон, запускают внутри себя собственный цикл прокачки сообщений, что возврат из подобных функций не произошёл раньше закрытия модального окна.

    В данном примере внутри обработчика Form_Load запускается новый цикл прокачки сообщений. То что сам по себе этот цикл глубоко кривой — это отдельный разговор, об этом будет пункт 2. Проблема в другом — время жизни экземпляра формы становится завязанным на время жизни (работы) этого цикла, а время жизни этого цикла зависит не только от переменной bCancel, но и от прочих подчинённых циклов — как совершенно других, так и точно таких же.

    Какие у этого последствия?

    Предположим, открываются три экземпляра Form1. Если бы не этот дуракций цикл внутри события Form_Load, как отработали бы обработчики события Form_Load? Был вы вызван обработчик Form_Load первого экземпляра, потом возврат в цикл прокачки сообщений, из него вызов обработчика для второго экземпляра формы, возврат в оконный цикл, и вызов Form_Load для третьего обработчика, и затем возврат в исходный оконный цикл, который продолжил бы крутиться и разгребать приходящие сообщения и доставлять их в нужные обработчики.

    normal_call_flow.png
    normal_call_flow.png (22.17 Кб) Просмотров: 1380


    Когда же внутри Form_Load сидит этот дуракций цикл, всё начинает работать совершенно по другому: после порождения первого экземпляра Form1, из корневого оконного цикла вызывается Form_Load первого экземпляра. Возврата из Form_Load не произойдёт до тех пор, пока не будет закрыт первый экземпляр Form1. Возврата в корневой цикл из самодельного цикла тоже не произойдёт до закрытия первого экземпляра. Тут порождается второй экземпляр Form1 — обработчик Form_Load для второго экземпляра будет вызван из цикла, крутящегося внутри Form_Load первого экземпляра (по пути Form_Load—>DoEvents—>DispatchMessage—>ThunderRT6FormDC—>Form_Load). Начнёт выполняться цикл, связанный со вторым экземпляром формы. Он будет крутиться до тех пор, пока не будет закрыт второй экземпляр, и будет обрабатывать сообщения как для второго экземпляра, так и для первого (так и для вообще всех окон).

    Если в этот момент будет закрыт первый экземпляр формы, то само окошко формы исчезнет, но объект не будет уничтожен, данные не будут выгружены, память не будет освобождена. Потому что объект уничтожается не ранее, чем когда счётчик ссылок на него достигнет 0. Счётчик ссылок на первый экземпляр Form1 никак не достигнет нуля, ибо внутри Form_Load имеется ссылка на этот экземпляр (в переменной Me).

    Тут порождается третий экземпляр, для которого будет опять вызван Form_Load: этот третий Form_Load будет вызван из предыдущего Form_Load и породит третий цикл.

    Этот третий цикл не завершится до тех пор, пока не будет закрыт третий экземпляр Form1. В итоге, имеем три вложенных цикла, к каждому из которых «подвязан» свой экземпляр Form1, причём пока не будет завршён самый «вложенный цикл», никоим образом не могут заврешиться родительские циклы, и не могут быть уничтожены уже никому не нужные объекты, прикованные к этим циклам.

    stupid_call_flow.png
    stupid_call_flow.png (36.61 Кб) Просмотров: 1380


    И если у нас каждую секунду создаётся новый экземпляр формы, и при этом самый старый из существующих закрывается — по принципу FIFO, то такая программа обречена умереть с ошибкой либо Out of stack space, либо Out of memory.

    По сути будет образовываться никому не нужная и плохо контролируемая рекурсия, прекращение которой возможно только тогда, когда окна закрывается в порядке, строго обратном порядку их появления. Во всех остальных случаях рекурсия растёт вглубь, и объекты олицетворяющие закрытые окна и их данные, продолжают жить.

    В принципе, этот недостаток можно было бы исправить, если перенести цикл из Form_Loadв Sub Main.

  2. Во-вторых, этот код беспросветно плох тем, что он работает в общем случае непредсказуемо. Если в очередь падает одно сообщение WM_MOUSWHEEL, то оно будет подцеплено вызовом PeekMessage и обработано дурацким кодом (который не учитывает значение wParam должным образом). Если же пользователь интенсивно крутит колесо — в очередь попадают сразу пачки сообщений WM_MOUSWHEEL. Из них только первое попавшееся обрабатывается вот этим кодом:
    Код: Выделить всё
    If PeekMessage(...)
          ...
    End If

    Остальные сообщения извлекаются из очереди и обрабатываются штатным способом уже в рамках вызова DoEvents.

    У меня это приводит к тому, что при интенсивном оперировании колёсиком скроллятся одновременно и ListView и фрейм. Гадость.

    Казалось бы — этот недостаток можно исправить, перенеся вызов DoEvents в Else-ветку If-блока:
    Код: Выделить всё
    If PeekMessage(...)
          ' Нестандартная обработка WM_MOUSEWHEEL
    Else
          DoEvents ' штатная обработка всех прочих оконных сообщений
    End If


    Тогда если в очередь упадёт не одно, а сразу несколько сообщений WM_MOUSEWHEEL, первое сообщение будет обработано нашим кодом, а обработать все остальные сообщения WM_MOUSEWHEEL нежелательным для нас образом DoEvents шанса уже не получит (ибо вызов будет Else-ветке, которая не выполнится). Тогда повторится следующая итерация, и опять наш код получит возможность обработать скролл-сообщение как выгодно нам.

    Однако — это иллюзия, этому коду ничего не поможет.

    Потому что если в очередь разом упадёт пачка из нескольких сообщений, первое из котороых не WM_MOUSEWHEEL, а второе (и последующие) именно WM_MOUSEWHEEL, то эта наивная логика не сработает: всё-таки будет вызвана функция DoEvents, а она, как следует из описания (не DoSingleEvent, а именно DoEventS) съедает всё, что накопилось в очереди, а не только первое сообщение из очереди.

    Итого: если при скроллинге мыши в очередь будет падать какой-нибудь WM_TIMER, да или просто WM_MOUSEMOVE (попробуйте скроллить не сдвинув мышку ни на пиксель) — сообщение будет обрабатываться то нашим кодом, то не нашим кодом.

    То есть, как я уже сказал, код работает непредсказуемо, потому что то, кем будет обработано сообщения, зависит от стечения случайных обстоятельств.

Так как же решать поставленную задачу?
  • Во-первых, для отлавливания «колёсных» событий надо использовать сабклассинг, а не вот это вот уродское решение собственным циклом прокачки сообщений. У меня есть прекрассный класс, который оборачивает сабклассинг в красивую событийно-ориентированную форму и ловить скролл на контроле можно будет так же легко, как ловить MouseMove или Click. Могу поделиться.

  • Во-вторых, надо уяснить для себя, как вообще работает механизм скроллинга и как устроена доставка WM_MOUSEWHEEL и как это всё стыкуется с концепцией фокуса. Когда пользователь нажимает кнопку на клавиатуре — какое окно должно получить сигнал об этом? Все сразу или какое-то конкретное? В Windows (как и во многих других UI-системах) для этого существует понятие фокуса: в любой конкретный момент времени только одно окно (контрол) имеет фокус, и если происходит клавиатурное событие, только тот, кто обладает в данный момент фокусом, получает сигнал об этом событии.

    Поэтому когда происходят клавиатурные нажатия, каждое нажатие преобразуется в оконное сообщение, при этом получателем оконного сообщения становится именно то окно, которое имеет в данный момент фокус. Фокус определяет получателей клавиатурных сообщений.

    Совершено иначе дела обстоят с мышиными событиями: когда происходит мышиное событие, сообщение об этом получает не то окно, которое имеет фокус, а то, над которым расположен в данный момент указатель, за исключением случая, когда был установлен захват мышиного ввода (SetCapture).

    Но к WM_MOUSEWHEEL это не относится: сообщение WM_MOUSEWHEEL получает не то окно, над которым сейчас находится мышка, а то, которое сейчас обладает фокусом. В этом смысле мышиный скролл приравнен к клавиатурным событиям.

    Поэтому любое окно, которое сейчас имеет фокус, будет получать сообщения о скролле, не важно, находится ли над ним мышка. Если какое-то окно не поддерживает скроллинг концептуально (например кнопка — она может иметь фокус, но как она может реагировать на скроллинг?), то, как это заведено в таких случаях, оконная процедура такого окна вызывает DefWindowProc. В отношении скролл-сообщений DefWindowProc исповедует простую тактику: сообщение пересылается родительскому окну. Если и родительское окно не знает, что с ним делать, оно тоже вызовет DefWindowProc — таким образом сообщение WM_MOUSEWHEEL сначала доставляется окну, имеющему фокус, а если оно не знает, как отреагировать на него, поочерёдно идёт по цепочке родителей от потомков к предкам. В конечном счёте сообщение будет отправлено самому корневому (в иерархии) окну, и если и его оконная процедура не справится с ним, она тоже вызовет DefWindowProc и та уже не будет дальше куда-либо пересылать его.
  • В свете вышесказанного нужно сделать важный вывод: контрол Frame в VB сам по себе не способен иметь фокуса. Если положить рядом с фреймом кнопку, нажать сперва на кнопку, а потом на фрейм — фрейм не отнимет у кнопки фокус, фокус так и останется на кнопке. А раз так: бесполезно сабклассить фрейм сам по себе — ему никогда не придёт WM_MOUSEWHEEL. Вообще-то это стандартная практика для контейнеров: контейнеры обычно не могут иметь фокус, оставляя эту привилегию тем контролам, которые могут лежать внутри контейнера, не являесь при этом тоже контейнерами.

    Однако, если внутрь фрейма положить другие контролы (например кнопки, чексбоксы и т.п.), которые сами не поддерживают скроллинг, они, имея фокус, будут пересылать WM_MOUSEWHEEL своему родителю — то есть до фрейма сообщения таки дойдут, и сабклассить его смысл всё-таки имеет.

    Если же внутри фрейма будут лежать только такие контролы, которые от природы не способны иметь фокус (например Label, другие фреймы и т.п.), то механизм скроллинга для таких фреймов не может быть применён. В этом случае придётся либо сделать свой собственный UserControl, аналогичный фрейму, который при этом обязательно будет иметь CanGetFocus=True, либо обернуть VB-шный фрейм, либо каким-либо другим способом научить VB-шный скролл иметь фокус (WinAPI SetFocus + доработка передачи фокуса между контролами с помощью клавиатуры).
  • Отдельная проблема состоит в том, что выделенные слова:
    kibernetics писал(а):Значит, mouse_скрол для Frame1 теперь работает, но вот если навести мышь на рядом расположенный ucListView, то список в нём не скролится, а скролится всё тот же фрейм.
    Короче, из любого места формы скролится только этот фрейм. Помогите допилить проект, чтобы скрол фрейма работал, только при событии MouseOver над ним.

    противоречат тому, как скроллинг задуман в Windows.

    Как я уже выше писал, система шлёт WM_MOUSEWHEEL не тому, над кем сейчас мышка, а тому, у кого сейчас фокус. Это поведение жестко заложено в Windows, его не поменять. А тот, у кого сейчас фокус, это совершенно не обязательно тот, над кем сейчас мышка.

    И здесь важно решить: хочешь ли ты переть против системы и заставлять вещи работать не так, как они они задуманы работать. С одной стороны — это очень плохо, когда в твоей программе что-то работает не так, как во всей остальной системе. Потому что пользователь-то привык. С другой стороны, во всех браузерах скролл работает именно так, как ты хочешь — на скролл реагирует тот, над кем мышка, а не тот, у кого фокус. Но браузеры не используют оконный механизм Windows для внутристраничных DOM-элементов — с точки зрения Windows браузер это одно окно, внутри которого просто непонятная графика и нет никаких дочерних окон.

    Так вот, либо ты соглашаешься с теми правилами игры, которые установлены в Windows. Тогда тебе будет мешать то обстоятельство, что фрейм штатно не умеет иметь фокус — клик по нему не отбирает фокус у других контролов. Не только в VB6, а вообще в Windows так заведено. Поэтому тебе придётся придумать, при каких обстоятельствах фрейм должен получать фокус (должен ли забирать его на себя при клике? а при навигации Tab-ом?), и как это состыковать с тем, что фрейм-контейнер будет отбирать фокус в том числе и содержащихся внутри него контролов (можно сделать так, что клик по фрейму отбирает фокус у любых контролов, кроме контролов, обадающих фокусом и лежащих внутри фрейма).

    Либо ты не соглашаешься с правилами игры, и постулируешь, что в твоей программе скролл действует не тот контрол, что обладает фокусом, а на тот, что лежит под указатем мышки в данный момент. Тогда часть проблем, описанных выше, перестаёт на тебя действовать. Например, больше не нужно волноваться о том, что Frame не умеет обладать фокусом, ведь тебе больше важно, кто вообще обладает фокусом — у тебя больше обладание фокусом ни на что не влияет (влияет координаты мыши). Но возникают новые проблемы:
    • Как бороться с тем, что система посылает сообщение WM_MOUSEWHEEL не тому, кому хочешь ты.
    • Что делать, если мышка находится на чужом окне чужого процесса? Пересылать ли скролл чужому процессу?
    • Что делать, если мышка находится на чужом окне своего процесса, но окно принадлежит чужому потоку нашего процесса?
    • Что делать, если мышка находится над чужим окном нашего процесса, и окно принадлежит нашему потоку?
    • Что делать, если мышка находится над окном, которые принадлежит нашему процессу и нашему потоку, но является неактивным в данный момент?

    Все вопросы, кроме первого — это сугубо вопросы твоего личного выбора и заботы о том, как бы не поломать поведение сторонних программ или сторонних контролов, которые столкнутся с тем, что сообщения WM_MOUSEWHEEL вдруг стали работать не так, как задумано в системе.

    Первый вопрос — вопрос технический. Он имеет два решения, и в той или иной мере на выбор решения влияют ответы на остальные вопросы.

    Итак, первое техническое решение первого вопроса:
    Сабклассить вообще все подряд окна внутри своего top-level-окна и фильтровать WM_MOUSEWHEEL по следующему принципу: если окно получает WM_MOUSEWHEEL и имеет в данный момент фокус, а указатель мыши находится не в регионе окна-адресата, без исключения из него субрегионов дочерних окон, сообщение WM_MOUSEWHEEL не передаётся в оконную процедуру окна-адресата, а перенаправляется окну, над которым находится сейчас указатель (при этом надо решить, что делать, если указатель находится над окном чужого top-level-окна, или над окном чужого потока, чудого процесса). Если же окно-адресат не имеет фокуса, значит это сообщение пришло в результате перенаправления, и никакого перехвата с нашей стороны не требуется.

    Решение можно доработать, если сабклассить не все окна, а только то окно, которое сейчас обладает фокусом — при потере им фокуса сабклассинг переустанавливается на новое окно, которое получит фокус (если такое есть). Однако, здесь возникает новая проблема: сабклассинги должны сниматься в обратном порядке по отношению к порядку их установки. Таким образом, если мы установим на сабклассинг на окно с фокусом, а кто-то поверх нашего сабклассинга установит ещё сабклассинг, мы не будем иметь право снять наш сабклассинг в момент потери фокуса, не дождавшись снятия сабклассинга, поставленного поверх нас.

    Итак, второе техническое решение первого вопроса:
    Цикл прокачки сообщений внутри VB достаточно сложный, и он имеет свой механизм фильтрации сообщений. Однако, документированного доступа к этому механизму фильтрации нет (в отличие от дотнета, где есть IMessageFilter — не путать с одноимённым интерфейсом в COM, который фильтрует другое). Поэтому всё ж таки возможно придётся сделать собственный цикл прокачки сообщений (но ни в коем случае не в Form_Load, а в Sub Main, как я выше написал). В нём, наткнувшись на WM_MOUSEWHEEL, нужно сделать аналогичную первому решению проверку — если получателем значится окно, которое имеет фокус, но которое своим регионом не перекрывает текущее положение мышки, нужно подменить HWND получателя на окно, полученное из текущего положения мыши. В ином случае менять получателя не нужно. Потому что механизм доставки WM_MOUSEWHEEL вверх по оконной иерархии должен продолжать работать.

Вот, после того, как все вышеописанные проблемы будут осмыслены, по ним будут приняты решения, а решения будут реализованы в коде — задача будет решена.
—We separate their smiling faces from the rest of their body, Captain.
—That's right! We decapitate them.

Хакер
Телепат
Телепат
Аватара пользователя
 
Сообщения: 16478
Зарегистрирован: 13.11.2005 (Вс) 2:43
Откуда: Казахстан, Петропавловск

Re: Kак ограничить область действия MouseWheel?

Сообщение Хакер » 08.05.2020 (Пт) 0:43

Пара небольших дополнений:
  1. То, что на картинках подписано «цикл внутри msvbvm60.dll» следовало бы подписать как «цикл внутри Ruby». Но не все ещё знают, что такое Ruby и что такое EB. После ознакомления со статьёй придёт понимание, что Ruby в одних обстоятельствах будет частью msvbvm60.dll, а в других — частью VB6.EXE. И соответственно, цикл будет крутиться либо там, либо там.
  2. У Microsoft есть гайдлайны по созданию пользовательского интерфейса, и там в разделе по мышь есть интересная секция:
    Секция про прокрутку писал(а):
    • Make the mouse wheel affect the control, pane, or window that the pointer is currently over. Doing so avoids unintended results.
    • Make the mouse wheel take effect without clicking or having input focus. Hovering is sufficient.
    • Make the mouse wheel affect the object with the most specific scope. For example, if the pointer is over a scrollable list box control in a scrollable pane within a scrollable window, the mouse wheel affects the list box control.
    • Don't change the input focus when using the mouse wheel.
    • Give the mouse wheel the following effects:
      • For scrollable windows, panes, and controls:
        • Rotating the mouse wheel scrolls the object vertically, where rotating up scrolls up. For the wheel to have natural mapping, rotating the mouse wheel should never scroll horizontally because doing so is disorienting and unexpected.
        • If the Ctrl key is pressed, rotating the mouse wheel zooms the object, where rotating up zooms in and rotating down zooms out.
        • Tilting the mouse wheel scrolls the object horizontally.
      • For zoomable windows and panes (without scrollbars):
        • Rotating the mouse wheel zooms the object, where rotating up zooms in and rotating down zooms out.
        • Tilting the mouse wheel has no effect.
          For tabs:
          • Rotating the mouse wheel can change the current tab, regardless of the orientation of the tabs.
          • Tilting the mouse wheel has no effect.
        • If the Shift and Alt keys are depressed, the mouse wheel has no effect.
      • Use the Windows system settings for the vertical scroll size (for rotating) and horizontal scroll size (for tilting). These settings are configurable through the Mouse control panel item.
      • Make rotating the mouse wheel more rapidly result in scrolling more rapidly. Doing so allows users to scroll large documents more efficiently.
      • For scrollable windows, consider having clicking the mouse wheel button put the window in "reader mode." Reader mode plants a special scroll origin icon and scrolls the window in a direction and speed relative to the scroll origin.


      Т.е. в подчёркнутых пунктах они призывают делать именно так, как ты хочешь, а не так, как сделано в Windows по умолчанию. Как добиваться такого результата не уточняется.
    —We separate their smiling faces from the rest of their body, Captain.
    —That's right! We decapitate them.

    kibernetics
    Постоялец
    Постоялец
    Аватара пользователя
     
    Сообщения: 945
    Зарегистрирован: 03.05.2006 (Ср) 13:31
    Откуда: Minsk

    Re: Kак ограничить область действия MouseWheel?

    Сообщение kibernetics » 08.05.2020 (Пт) 1:13

    В последнее время меня кардинально поражает один момент, что чем больше я "кодю" на Visual Basic, и мне кажется, что он вот такой простой и ясный, хоть и довольной древний, я постоянно ловлю себя на мысли, что он, в то же время, такой безграничный и непознанный. Может быть поэтому, у него, до сих пор хватает поклонников, и их постоянные попытки улучшить инструменты по его использованию.

    И, может быть, наш всеми горячо любимый Хакер, именно поэтому исследует каждую инструкцию этого сложного vb-механизма, что способен, более, чем полностью, разложить любую проблему на лопатки.
    Сейчас, мне даже как-то неловко, что я своим вопросом (как я наивно полагал, простым), отнял у человека столько времени на подробный материал по этому поводу.
    Искренне ценю сей труд, и, конечно, я честно признаюсь, понял для себя примерно 80%, думаю, что тот, кто занимается исследованием языка откроет для себя уже проторенную Хакером дорогу.

    Кстати, этот факт ещё подчёркивает, что форумы, всё-таки, ещё долго не должны потерять своей актуальности, в отличии, от тех же тематических групп в Телеграмме. Подобный опус затеряется уже на следующие сутки или даже раньше.
    А тут вот, есть ветка, с подробным описанием. Заходи, студент, ознакамливайся.


    В общем, как я немного для себя проясняю, идея никуда не годится, и надо использовать саб-классинг. Просить прекрассный класс уже как-то чересчур, но если это возможно, конечно, буду польщён.

    Ещё, я покопал немного в сети, и нашёл такую http://www.planet-source-code.com/vb/scripts/ShowZip.asp?lngWId=1&lngCodeId=59863&strZipAccessCode=tp%2FI598633914 реализацию на сабклассинге. Вот тут прошу Хакера тоже вынести вердикт. Отмечу тот момент, что однажды запущенное приложение зависло, и ни на что не реагировало, ни иде, ни окно... Короче, только kill process помог. Это обстоятельство и заставило дальше идти искать другие реализации.

    Спасибо Хакер.


    Вернуться в Visual Basic 1–6

    Кто сейчас на конференции

    Сейчас этот форум просматривают: AhrefsBot и гости: 44

        TopList