OSDN Git Service

Merge pull request #262 from opentween/graphql-ratelimit
[opentween/open-tween.git] / OpenTween / TimelineListViewCache.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
8 // All rights reserved.
9 //
10 // This file is part of OpenTween.
11 //
12 // This program is free software; you can redistribute it and/or modify it
13 // under the terms of the GNU General public License as published by the Free
14 // Software Foundation; either version 3 of the License, or (at your option)
15 // any later version.
16 //
17 // This program is distributed in the hope that it will be useful, but
18 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
20 // for more details.
21 //
22 // You should have received a copy of the GNU General public License along
23 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
24 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
25 // Boston, MA 02110-1301, USA.
26
27 #nullable enable
28
29 using System;
30 using System.Collections.Generic;
31 using System.Diagnostics.CodeAnalysis;
32 using System.Linq;
33 using System.Text;
34 using System.Threading;
35 using System.Windows.Forms;
36 using OpenTween.Models;
37 using OpenTween.OpenTweenCustomControl;
38
39 namespace OpenTween
40 {
41     public sealed class TimelineListViewCache : IDisposable
42     {
43         public bool IsDisposed { get; private set; } = false;
44
45         public bool IsListSizeMismatched
46             => this.listView.VirtualListSize != this.tab.AllCount;
47
48         private readonly DetailsListView listView;
49         private readonly TabModel tab;
50         private readonly SettingCommon settings;
51
52         /// <summary>
53         /// 現在表示している発言一覧の <see cref="ListView"/> に対するキャッシュ
54         /// </summary>
55         /// <remarks>
56         /// キャッシュクリアのために null が代入されることがあるため、
57         /// 使用する場合には <see cref="listItemCache"/> に対して直接メソッド等を呼び出さずに
58         /// 一旦ローカル変数に代入してから参照すること。
59         /// </remarks>
60         private ListViewItemCache? listItemCache = null;
61
62         public TimelineListViewCache(
63             DetailsListView listView,
64             TabModel tab,
65             SettingCommon settings
66         )
67         {
68             this.listView = listView;
69             this.tab = tab;
70             this.settings = settings;
71
72             this.RegisterHandlers();
73             this.listView.VirtualMode = true;
74             this.UpdateListSize();
75          }
76
77         private void RegisterHandlers()
78         {
79             this.listView.CacheVirtualItems += this.ListView_CacheVirtualItems;
80             this.listView.RetrieveVirtualItem += this.ListView_RetrieveVirtualItem;
81         }
82
83         private void UnregisterHandlers()
84         {
85             this.listView.CacheVirtualItems -= this.ListView_CacheVirtualItems;
86             this.listView.RetrieveVirtualItem -= this.ListView_RetrieveVirtualItem;
87         }
88
89         public void UpdateListSize()
90         {
91             try
92             {
93                 // リスト件数更新
94                 this.listView.VirtualListSize = this.tab.AllCount;
95             }
96             catch (NullReferenceException ex)
97             {
98                 // WinForms 内部で ListView.set_TopItem が発生させている例外
99                 // https://ja.osdn.net/ticket/browse.php?group_id=6526&tid=36588
100                 MyCommon.TraceOut(ex, $"TabType: {this.tab.TabType}, Count: {this.tab.AllCount}, ListSize: {this.listView.VirtualListSize}");
101             }
102         }
103
104         internal void CreateCache(int startIndex, int endIndex)
105         {
106             if (this.tab.AllCount == 0)
107                 return;
108
109             // インデックスを 0...(tabInfo.AllCount - 1) の範囲内にする
110             int FilterRange(int index)
111                 => Math.Max(Math.Min(index, this.tab.AllCount - 1), 0);
112
113             // キャッシュ要求(要求範囲±30を作成)
114             startIndex = FilterRange(startIndex - 30);
115             endIndex = FilterRange(endIndex + 30);
116
117             var cacheLength = endIndex - startIndex + 1;
118
119             var posts = this.tab[startIndex, endIndex]; // 配列で取得
120             var listItems = Enumerable.Range(0, cacheLength)
121                 .Select(x => this.CreateItem(posts[x]))
122                 .ToArray();
123
124             var listCache = new ListViewItemCache(
125                 startIndex,
126                 endIndex,
127                 listItems
128             );
129
130             Interlocked.Exchange(ref this.listItemCache, listCache);
131         }
132
133         /// <summary>
134         /// DetailsListView のための ListViewItem のキャッシュを消去する
135         /// </summary>
136         public void PurgeCache()
137             => Interlocked.Exchange(ref this.listItemCache, null);
138
139         private (ListViewItem Item, ListItemStyle Style) CreateItem(PostClass post)
140         {
141             var mk = new StringBuilder();
142
143             if (post.FavoritedCount > 0) mk.Append("+" + post.FavoritedCount);
144
145             ListViewItem itm;
146             if (post.RetweetedId == null)
147             {
148                 string[] sitem =
149                 {
150                     "",
151                     post.Nickname,
152                     post.IsDeleted ? "(DELETED)" : post.AccessibleText.Replace('\n', ' '),
153                     post.CreatedAt.ToLocalTimeString(this.settings.DateTimeFormat),
154                     post.ScreenName,
155                     "",
156                     mk.ToString(),
157                     post.Source,
158                 };
159                 itm = new ListViewItem(sitem);
160             }
161             else
162             {
163                 string[] sitem =
164                 {
165                     "",
166                     post.Nickname,
167                     post.IsDeleted ? "(DELETED)" : post.AccessibleText.Replace('\n', ' '),
168                     post.CreatedAt.ToLocalTimeString(this.settings.DateTimeFormat),
169                     post.ScreenName + Environment.NewLine + "(RT:" + post.RetweetedBy + ")",
170                     "",
171                     mk.ToString(),
172                     post.Source,
173                 };
174                 itm = new ListViewItem(sitem);
175             }
176
177             var style = this.DetermineListItemStyle(post);
178             this.ApplyListItemStyle(itm, style);
179
180             return (itm, style);
181         }
182
183         public void RefreshStyle(int index)
184         {
185             var post = this.tab[index];
186             var style = this.DetermineListItemStyle(post);
187
188             var listCache = this.listItemCache;
189             if (listCache != null && listCache.TryGetValue(index, out var item, out var currentStyle))
190             {
191                 // スタイルに変化がない場合は何もせず終了
192                 if (currentStyle == style)
193                     return;
194
195                 listCache.UpdateStyle(index, style);
196             }
197             else
198             {
199                 item = this.listView.Items[index];
200             }
201
202             // ValidateRectが呼ばれる前に選択色などの描画を済ませておく
203             this.listView.Update();
204
205             this.ApplyListItemStyle(item, style);
206             this.listView.RefreshItem(index);
207         }
208
209         public void RefreshStyle()
210         {
211             var listCache = this.listItemCache;
212             if (listCache == null)
213                 return;
214
215             var updatedIndices = new List<int>();
216
217             foreach (var (_, currentStyle, index) in listCache.WithIndex())
218             {
219                 var post = this.tab[index];
220                 var style = this.DetermineListItemStyle(post);
221                 if (currentStyle == style)
222                     continue;
223
224                 listCache.UpdateStyle(index, style);
225                 updatedIndices.Add(index);
226             }
227
228             // ValidateRectが呼ばれる前に選択色などの描画を済ませておく
229             this.listView.Update();
230
231             foreach (var index in updatedIndices)
232             {
233                 if (!listCache.TryGetValue(index, out var item, out var style))
234                     continue;
235
236                 this.ApplyListItemStyle(item, style);
237             }
238
239             updatedIndices.Add(this.tab.SelectedIndex);
240             this.listView.RefreshItems(updatedIndices);
241         }
242
243         public ListViewItem GetItem(int index)
244         {
245             var listCache = this.listItemCache;
246             if (listCache != null)
247             {
248                 if (listCache.TryGetValue(index, out var item, out _))
249                     return item;
250             }
251
252             var post = this.tab[index];
253             return this.CreateItem(post).Item;
254         }
255
256         public ListItemStyle GetStyle(int index)
257         {
258             var listCache = this.listItemCache;
259             if (listCache != null)
260             {
261                 if (listCache.TryGetValue(index, out _, out var style))
262                     return style;
263             }
264
265             var post = this.tab[index];
266             return this.DetermineListItemStyle(post);
267         }
268
269         private void ApplyListItemStyle(ListViewItem item, ListItemStyle style)
270             => item.SubItems[5].Text = this.GetUnreadMark(style.UnreadMark);
271
272         private string GetUnreadMark(bool unreadMark)
273             => unreadMark ? "★" : "";
274
275         private ListItemStyle DetermineListItemStyle(PostClass post)
276         {
277             var unreadManageEnabled = this.tab.UnreadManage && this.settings.UnreadManage;
278             var useUnreadStyle = unreadManageEnabled && this.settings.UseUnreadStyle;
279
280             var basePost = this.tab.AnchorPost ?? this.tab.SelectedPost;
281
282             return new(
283                 this.DetermineUnreadMark(post, unreadManageEnabled),
284                 this.DetermineBackColor(basePost, post),
285                 this.DetermineForeColor(post, useUnreadStyle),
286                 this.DetermineFont(post, useUnreadStyle)
287             );
288         }
289
290         private bool DetermineUnreadMark(PostClass post, bool unreadManageEnabled)
291         {
292             if (!unreadManageEnabled)
293                 return false;
294
295             return !post.IsRead;
296         }
297
298         private ListItemBackColor DetermineBackColor(PostClass? basePost, PostClass post)
299         {
300             if (basePost == null)
301                 return ListItemBackColor.None;
302
303             // @先
304             if (post.StatusId == basePost.InReplyToStatusId)
305                 return ListItemBackColor.AtTo;
306
307             // 自分=発言者
308             if (post.IsMe)
309                 return ListItemBackColor.Self;
310
311             // 自分宛返信
312             if (post.IsReply)
313                 return ListItemBackColor.AtSelf;
314
315             // 返信先
316             if (basePost.ReplyToList.Any(x => x.UserId == post.UserId))
317                 return ListItemBackColor.AtFromTarget;
318
319             // その人への返信
320             if (post.ReplyToList.Any(x => x.UserId == basePost.UserId))
321                 return ListItemBackColor.AtTarget;
322
323             // 発言者
324             if (post.UserId == basePost.UserId)
325                 return ListItemBackColor.Target;
326
327             // その他
328             return ListItemBackColor.None;
329         }
330
331         private ListItemForeColor DetermineForeColor(PostClass post, bool useUnreadStyle)
332         {
333             if (post.IsFav)
334                 return ListItemForeColor.Fav;
335
336             if (post.RetweetedId != null)
337                 return ListItemForeColor.Retweet;
338
339             if (post.IsOwl && (post.IsDm || this.settings.OneWayLove))
340                 return ListItemForeColor.OWL;
341
342             if (useUnreadStyle && !post.IsRead)
343                 return ListItemForeColor.Unread;
344
345             return ListItemForeColor.None;
346         }
347
348         private ListItemFont DetermineFont(PostClass post, bool useUnreadStyle)
349         {
350             if (useUnreadStyle && !post.IsRead)
351                 return ListItemFont.Unread;
352
353             return ListItemFont.Readed;
354         }
355
356         private void ListView_CacheVirtualItems(object sender, CacheVirtualItemsEventArgs e)
357         {
358             var listCache = this.listItemCache;
359             if (listCache != null && listCache.IsSupersetOf(e.StartIndex, e.EndIndex))
360             {
361                 // If the newly requested cache is a subset of the old cache,
362                 // no need to rebuild everything, so do nothing.
363                 return;
364             }
365
366             // Now we need to rebuild the cache.
367             this.CreateCache(e.StartIndex, e.EndIndex);
368         }
369
370         private void ListView_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
371             => e.Item = this.GetItem(e.ItemIndex);
372
373         public void Dispose()
374         {
375             if (this.IsDisposed)
376                 return;
377
378             // RetrieveVirtualItem が呼ばれないようにするため 0 をセットする
379             this.listView.VirtualListSize = 0;
380
381             this.UnregisterHandlers();
382             this.PurgeCache();
383             this.IsDisposed = true;
384         }
385     }
386
387     public enum ListItemBackColor
388     {
389         None,
390         Self,
391         AtSelf,
392         Target,
393         AtTarget,
394         AtFromTarget,
395         AtTo,
396     }
397
398     public enum ListItemForeColor
399     {
400         None,
401         Fav,
402         Retweet,
403         OWL,
404         Unread,
405     }
406
407     public enum ListItemFont
408     {
409         Readed,
410         Unread,
411     }
412
413     public readonly record struct ListItemStyle(
414         bool UnreadMark,
415         ListItemBackColor BackColor,
416         ListItemForeColor ForeColor,
417         ListItemFont Font
418     );
419
420     public class ListViewItemCache
421     {
422         /// <summary>キャッシュする範囲の開始インデックス</summary>
423         public int StartIndex { get; }
424
425         /// <summary>キャッシュする範囲の終了インデックス</summary>
426         public int EndIndex { get; }
427
428         /// <summary>キャッシュされた範囲に対応する <see cref="ListViewItem"/> と <see cref="ListItemStyle"> の配列</summary>
429         public (ListViewItem, ListItemStyle)[] Cache { get; }
430
431         /// <summary>キャッシュされたアイテムの件数</summary>
432         public int Count
433             => this.EndIndex - this.StartIndex + 1;
434
435         public ListViewItemCache(int startIndex, int endIndex, (ListViewItem, ListItemStyle)[] cache)
436         {
437             if (!IsCacheSizeValid(startIndex, endIndex, cache))
438                 throw new ArgumentException("Cache size mismatch", nameof(cache));
439
440             this.StartIndex = startIndex;
441             this.EndIndex = endIndex;
442             this.Cache = cache;
443         }
444
445         /// <summary>指定されたインデックスがキャッシュの範囲内であるか判定します</summary>
446         /// <returns><paramref name="index"/> がキャッシュの範囲内であれば true、それ以外は false</returns>
447         public bool Contains(int index)
448             => index >= this.StartIndex && index <= this.EndIndex;
449
450         /// <summary>指定されたインデックスの範囲が全てキャッシュの範囲内であるか判定します</summary>
451         /// <returns><paramref name="rangeStart"/> から <paramref name="rangeEnd"/> の範囲が全てキャッシュの範囲内であれば true、それ以外は false</returns>
452         public bool IsSupersetOf(int rangeStart, int rangeEnd)
453             => rangeStart >= this.StartIndex && rangeEnd <= this.EndIndex;
454
455         /// <summary>指定されたインデックスの <see cref="ListViewItem"/> をキャッシュから取得することを試みます</summary>
456         /// <returns>取得に成功すれば true、それ以外は false</returns>
457         public bool TryGetValue(int index, [NotNullWhen(true)] out ListViewItem? item, out ListItemStyle style)
458         {
459             if (this.Contains(index))
460             {
461                 (item, style) = this.Cache[index - this.StartIndex];
462                 return true;
463             }
464             else
465             {
466                 item = null;
467                 style = default;
468                 return false;
469             }
470         }
471
472         public IEnumerable<(ListViewItem Item, ListItemStyle Stype, int Index)> WithIndex()
473         {
474             foreach (var ((item, style), index) in this.Cache.WithIndex())
475                 yield return (item, style, index + this.StartIndex);
476         }
477
478         public void UpdateStyle(int index, ListItemStyle style)
479         {
480             if (!this.Contains(index))
481                 return;
482
483             this.Cache[index - this.StartIndex].Item2 = style;
484         }
485
486         private static bool IsCacheSizeValid<T>(int startIndex, int endIndex, T[] cache)
487             => cache.Length == (endIndex - startIndex + 1);
488     }
489 }