OSDN Git Service

MediaSelectorクラスにWinFormsに依存しない処理を分離
authorKimura Youichi <kim.upsilon@bucyou.net>
Wed, 18 Jan 2023 22:14:49 +0000 (07:14 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Thu, 19 Jan 2023 01:00:31 +0000 (10:00 +0900)
OpenTween.Tests/MediaSelectorTest.cs
OpenTween/Extensions.cs
OpenTween/MediaSelector.cs [new file with mode: 0644]
OpenTween/MediaSelectorPanel.cs
OpenTween/Tween.cs

index 2030855..e8e101a 100644 (file)
@@ -1,13 +1,33 @@
-using System;
+// OpenTween - Client of Twitter
+// Copyright (c) 2014 spx (@5px)
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
 using System.Collections.Generic;
-using System.ComponentModel;
 using System.Drawing;
 using System.IO;
 using System.Linq;
 using System.Reflection;
 using System.Runtime.InteropServices;
 using System.Text;
-using System.Text.RegularExpressions;
 using Moq;
 using OpenTween.Api;
 using OpenTween.Api.DataModel;
@@ -29,438 +49,224 @@ namespace OpenTween
         }
 
         [Fact]
-        public void Initialize_TwitterTest()
+        public void SelectMediaService_TwitterTest()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel();
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
-            Assert.NotEqual(-1, mediaSelector.ImageServiceCombo.Items.IndexOf("Twitter"));
+            Assert.Contains(mediaSelector.MediaServices, x => x.Key == "Twitter");
 
             // 投稿先に Twitter が選択されている
-            Assert.Equal("Twitter", mediaSelector.ImageServiceCombo.Text);
+            Assert.Equal("Twitter", mediaSelector.SelectedMediaServiceName);
 
-            // ページ番号が初期化された状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1" }, pages.Cast<object>().Select(x => x.ToString()));
-
-            // 代替テキストの入力欄が表示された状態
-            Assert.True(mediaSelector.AlternativeTextPanel.Visible);
+            // 代替テキストが入力可能な状態
+            Assert.True(mediaSelector.CanUseAltText);
         }
 
         [Fact]
-        public void Initialize_ImgurTest()
+        public void SelectMediaService_ImgurTest()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel();
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Imgur");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Imgur");
 
             // 投稿先に Imgur が選択されている
-            Assert.Equal("Imgur", mediaSelector.ImageServiceCombo.Text);
-
-            // ページ番号が初期化された状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1" }, pages.Cast<object>().Select(x => x.ToString()));
-
-            // 代替テキストの入力欄が非表示の状態
-            Assert.False(mediaSelector.AlternativeTextPanel.Visible);
-        }
-
-        [Fact]
-        public void BeginSelection_BlankTest()
-        {
-            using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
-            using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
-            twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-
-            Assert.Raises<EventArgs>(
-                x => mediaSelector.BeginSelecting += x,
-                x => mediaSelector.BeginSelecting -= x,
-                () => mediaSelector.BeginSelection()
-            );
-
-            Assert.True(mediaSelector.Visible);
-            Assert.True(mediaSelector.Enabled);
+            Assert.Equal("Imgur", mediaSelector.SelectedMediaServiceName);
 
-            // 1 ページ目のみ選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1" }, pages.Cast<object>().Select(x => x.ToString()));
-
-            // 1 ページ目が表示されている
-            Assert.Equal("1", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal("", mediaSelector.ImagefilePathText.Text);
-            Assert.Null(mediaSelector.ImageSelectedPicture.Image);
+            // 代替テキストが入力できない状態
+            Assert.False(mediaSelector.CanUseAltText);
         }
 
         [Fact]
-        public void BeginSelection_FilePathTest()
+        public void AddMediaItem_FilePath_SingleTest()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
             var images = new[] { "Resources/re.gif" };
+            mediaSelector.AddMediaItemFromFilePath(images);
 
-            Assert.Raises<EventArgs>(
-                x => mediaSelector.BeginSelecting += x,
-                x => mediaSelector.BeginSelecting -= x,
-                () => mediaSelector.BeginSelection(images)
-            );
-
-            Assert.True(mediaSelector.Visible);
-            Assert.True(mediaSelector.Enabled);
-
-            // 2 ページ目まで選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2" }, pages.Cast<object>().Select(x => x.ToString()));
+            // 画像が 1 つ追加された状態
+            Assert.Single(mediaSelector.MediaItems);
 
-            // 1 ページ目が表示されている
-            Assert.Equal("1", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal(Path.GetFullPath("Resources/re.gif"), mediaSelector.ImagefilePathText.Text);
+            // 1 枚目の画像が表示されている
+            Assert.Equal(0, mediaSelector.SelectedMediaItemIndex);
+            Assert.Equal(Path.GetFullPath("Resources/re.gif"), mediaSelector.SelectedMediaItem!.Path);
 
             using var imageStream = File.OpenRead("Resources/re.gif");
-            using var image = MemoryImage.CopyFromStream(imageStream);
-            Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
+            using var expectedImage = MemoryImage.CopyFromStream(imageStream);
+            using var actualImage = mediaSelector.SelectedMediaItem.CreateImage();
+            Assert.Equal(expectedImage, actualImage);
         }
 
         [Fact]
-        public void BeginSelection_MemoryImageTest()
+        public void AddMediaItem_MemoryImageTest()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
             using (var bitmap = new Bitmap(width: 200, height: 200))
-            {
-                Assert.Raises<EventArgs>(
-                    x => mediaSelector.BeginSelecting += x,
-                    x => mediaSelector.BeginSelecting -= x,
-                    () => mediaSelector.BeginSelection(bitmap)
-                );
-            }
-
-            Assert.True(mediaSelector.Visible);
-            Assert.True(mediaSelector.Enabled);
+                mediaSelector.AddMediaItemFromImage(bitmap);
 
-            // 2 ページ目まで選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2" }, pages.Cast<object>().Select(x => x.ToString()));
+            // 画像が 1 つ追加された状態
+            Assert.Single(mediaSelector.MediaItems);
 
-            // 1 ページ目が表示されている
-            Assert.Equal("1", mediaSelector.ImagePageCombo.Text);
-            Assert.Matches(@"^<>MemoryImage://\d+.png$", mediaSelector.ImagefilePathText.Text);
+            // 1 枚目の画像が表示されている
+            Assert.Equal(0, mediaSelector.SelectedMediaItemIndex);
+            Assert.Matches(@"^<>MemoryImage://\d+.png$", mediaSelector.SelectedMediaItem!.Path);
 
             using (var bitmap = new Bitmap(width: 200, height: 200))
             {
-                using var image = MemoryImage.CopyFromImage(bitmap);
-                Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
+                using var expectedImage = MemoryImage.CopyFromImage(bitmap);
+                using var actualImage = mediaSelector.SelectedMediaItem.CreateImage();
+                Assert.Equal(expectedImage, actualImage);
             }
         }
 
         [Fact]
-        public void BeginSelection_MultiImageTest()
+        public void AddMediaItem_FilePath_MultipleTest()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
             var images = new[] { "Resources/re.gif", "Resources/re1.png" };
-            mediaSelector.BeginSelection(images);
+            mediaSelector.AddMediaItemFromFilePath(images);
 
-            // 3 ページ目まで選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2", "3" }, pages.Cast<object>().Select(x => x.ToString()));
+            // 画像が 2 つ追加された状態
+            Assert.Equal(2, mediaSelector.MediaItems.Count);
 
-            // 1 ページ目が表示されている
-            Assert.Equal("1", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal(Path.GetFullPath("Resources/re.gif"), mediaSelector.ImagefilePathText.Text);
+            // 最後の画像(2 枚目)が表示されている
+            Assert.Equal(1, mediaSelector.SelectedMediaItemIndex);
+            Assert.Equal(Path.GetFullPath("Resources/re1.png"), mediaSelector.SelectedMediaItem!.Path);
 
-            using var imageStream = File.OpenRead("Resources/re.gif");
-            using var image = MemoryImage.CopyFromStream(imageStream);
-            Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
+            using var imageStream = File.OpenRead("Resources/re1.png");
+            using var expectedImage = MemoryImage.CopyFromStream(imageStream);
+            using var actualImage = mediaSelector.SelectedMediaItem.CreateImage();
+            Assert.Equal(expectedImage, actualImage);
         }
 
         [Fact]
-        public void EndSelection_Test()
+        public void ClearMediaItems_Test()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-            mediaSelector.BeginSelection(new[] { "Resources/re.gif" });
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
-            var displayImage = mediaSelector.ImageSelectedPicture.Image; // 表示中の画像
+            mediaSelector.AddMediaItemFromFilePath(new[] { "Resources/re.gif" });
 
-            Assert.Raises<EventArgs>(
-                x => mediaSelector.EndSelecting += x,
-                x => mediaSelector.EndSelecting -= x,
-                () => mediaSelector.EndSelection()
-            );
+            var thumbnailImages = mediaSelector.ThumbnailList.ToArray(); // 表示中の画像
 
-            Assert.False(mediaSelector.Visible);
-            Assert.False(mediaSelector.Enabled);
+            mediaSelector.ClearMediaItems();
 
-            Assert.True(displayImage!.IsDisposed);
+            Assert.True(thumbnailImages.All(x => x.IsDisposed));
         }
 
         [Fact]
-        public void PageChange_Test()
+        public void DetachMediaItems_Test()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-
-            var images = new[] { "Resources/re.gif", "Resources/re1.png" };
-            mediaSelector.BeginSelection(images);
-
-            mediaSelector.ImagePageCombo.SelectedIndex = 0;
-
-            // 1 ページ目
-            Assert.Equal("1", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal(Path.GetFullPath("Resources/re.gif"), mediaSelector.ImagefilePathText.Text);
-
-            using (var imageStream = File.OpenRead("Resources/re.gif"))
-            {
-                using var image = MemoryImage.CopyFromStream(imageStream);
-                Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
-            }
-
-            mediaSelector.ImagePageCombo.SelectedIndex = 1;
-
-            // 2 ページ目
-            Assert.Equal("2", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal(Path.GetFullPath("Resources/re1.png"), mediaSelector.ImagefilePathText.Text);
-
-            using (var imageStream = File.OpenRead("Resources/re1.png"))
-            {
-                using var image = MemoryImage.CopyFromStream(imageStream);
-                Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
-            }
-
-            mediaSelector.ImagePageCombo.SelectedIndex = 2;
-
-            // 3 ページ目 (新規ページ)
-            Assert.Equal("3", mediaSelector.ImagePageCombo.Text);
-            Assert.Equal("", mediaSelector.ImagefilePathText.Text);
-            Assert.Null(mediaSelector.ImageSelectedPicture.Image);
-        }
-
-        [Fact]
-        public void PageChange_AlternativeTextTest()
-        {
-            using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
-            using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
-            twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-
-            var images = new[] { "Resources/re.gif", "Resources/re1.png" };
-            mediaSelector.BeginSelection(images);
-
-            // 1 ページ目
-            mediaSelector.ImagePageCombo.SelectedIndex = 0;
-            mediaSelector.AlternativeTextBox.Text = "Page 1";
-            mediaSelector.ValidateChildren();
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
-            // 2 ページ目
-            mediaSelector.ImagePageCombo.SelectedIndex = 1;
-            mediaSelector.AlternativeTextBox.Text = "Page 2";
-            mediaSelector.ValidateChildren();
+            mediaSelector.AddMediaItemFromFilePath(new[] { "Resources/re.gif" });
 
-            // 3 ページ目 (新規ページ)
-            mediaSelector.ImagePageCombo.SelectedIndex = 2;
-            mediaSelector.AlternativeTextBox.Text = "Page 3";
-            mediaSelector.ValidateChildren();
+            var thumbnailImages = mediaSelector.ThumbnailList.ToArray();
 
-            mediaSelector.ImagePageCombo.SelectedIndex = 0;
-            Assert.Equal("Page 1", mediaSelector.AlternativeTextBox.Text);
+            var detachedMediaItems = mediaSelector.DetachMediaItems();
 
-            mediaSelector.ImagePageCombo.SelectedIndex = 1;
-            Assert.Equal("Page 2", mediaSelector.AlternativeTextBox.Text);
+            Assert.Empty(mediaSelector.MediaItems);
+            Assert.True(thumbnailImages.All(x => x.IsDisposed));
 
-            // 画像が指定されていないページは入力した代替テキストも保持されない
-            mediaSelector.ImagePageCombo.SelectedIndex = 2;
-            Assert.Equal("", mediaSelector.AlternativeTextBox.Text);
+            // DetachMediaItems で切り離された MediaItem は破棄しない
+            Assert.True(detachedMediaItems.All(x => !x.IsDisposed));
         }
 
         [Fact]
-        public void PageChange_ImageDisposeTest()
+        public void SelectedMediaItemChange_Test()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
             var images = new[] { "Resources/re.gif", "Resources/re1.png" };
-            mediaSelector.BeginSelection(images);
+            mediaSelector.AddMediaItemFromFilePath(images);
 
-            mediaSelector.ImagePageCombo.SelectedIndex = 0;
+            mediaSelector.SelectedMediaItemIndex = 0;
 
             // 1 ページ目
-            var page1Image = mediaSelector.ImageSelectedPicture.Image;
-
-            mediaSelector.ImagePageCombo.SelectedIndex = 1;
-
-            // 2 ページ目
-            var page2Image = mediaSelector.ImageSelectedPicture.Image;
-            Assert.True(page1Image!.IsDisposed); // 前ページの画像が破棄されているか
-
-            mediaSelector.ImagePageCombo.SelectedIndex = 2;
-
-            // 3 ページ目 (新規ページ)
-            Assert.True(page2Image!.IsDisposed); // 前ページの画像が破棄されているか
-        }
-
-        [Fact]
-        public void ImagePathInput_Test()
-        {
-            using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
-            using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
-            twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-            mediaSelector.BeginSelection();
-
-            // 画像のファイルパスを入力
-            mediaSelector.ImagefilePathText.Text = Path.GetFullPath("Resources/re1.png");
-            TestUtils.Validate(mediaSelector.ImagefilePathText);
+            Assert.Equal(Path.GetFullPath("Resources/re.gif"), mediaSelector.SelectedMediaItem!.Path);
 
-            // 入力したパスの画像が表示される
-            using (var imageStream = File.OpenRead("Resources/re1.png"))
+            using (var imageStream = File.OpenRead("Resources/re.gif"))
             {
-                using var image = MemoryImage.CopyFromStream(imageStream);
-                Assert.Equal(image, mediaSelector.ImageSelectedPicture.Image);
+                using var expectedImage = MemoryImage.CopyFromStream(imageStream);
+                using var actualImage = mediaSelector.SelectedMediaItem.CreateImage();
+                Assert.Equal(expectedImage, actualImage);
             }
 
-            // 2 ページ目まで選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2" }, pages.Cast<object>().Select(x => x.ToString()));
-        }
-
-        [Fact]
-        public void ImagePathInput_ReplaceFileMediaItemTest()
-        {
-            using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
-            using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
-            twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
+            mediaSelector.SelectedMediaItemIndex = 1;
 
-            mediaSelector.BeginSelection(new[] { "Resources/re.gif" });
-
-            // 既に入力されているファイルパスの画像
-            var image1 = mediaSelector.ImageSelectedPicture.Image;
-
-            // 別の画像のファイルパスを入力
-            mediaSelector.ImagefilePathText.Text = Path.GetFullPath("Resources/re1.png");
-            TestUtils.Validate(mediaSelector.ImagefilePathText);
+            // 2 ページ目
+            Assert.Equal(Path.GetFullPath("Resources/re1.png"), mediaSelector.SelectedMediaItem!.Path);
 
-            // 入力したパスの画像が表示される
             using (var imageStream = File.OpenRead("Resources/re1.png"))
             {
-                using var image2 = MemoryImage.CopyFromStream(imageStream);
-                Assert.Equal(image2, mediaSelector.ImageSelectedPicture.Image);
+                using var expectedImage = MemoryImage.CopyFromStream(imageStream);
+                using var actualImage = mediaSelector.SelectedMediaItem.CreateImage();
+                Assert.Equal(expectedImage, actualImage);
             }
-
-            // 最初に入力されていたファイルパスの表示用の MemoryImage は破棄される
-            Assert.True(image1!.IsDisposed);
         }
 
         [Fact]
-        public void ImagePathInput_ReplaceMemoryImageMediaItemTest()
+        public void SetSelectedMediaAltText_Test()
         {
             using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
             using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
+            using var mediaSelector = new MediaSelector();
             twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-
-            using (var bitmap = new Bitmap(width: 200, height: 200))
-            {
-                mediaSelector.BeginSelection(bitmap);
-            }
-
-            // 既に入力されているファイルパスの画像
-            var image1 = mediaSelector.ImageSelectedPicture.Image;
+            mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration());
+            mediaSelector.SelectMediaService("Twitter");
 
-            // 内部で保持されている MemoryImageMediaItem を取り出す
-            var selectedMedia = mediaSelector.ImagePageCombo.SelectedItem;
-            var mediaProperty = selectedMedia.GetType().GetProperty("Item");
-            var mediaItem = (MemoryImageMediaItem)mediaProperty.GetValue(selectedMedia);
-
-            // 別の画像のファイルパスを入力
-            mediaSelector.ImagefilePathText.Text = Path.GetFullPath("Resources/re1.png");
-            TestUtils.Validate(mediaSelector.ImagefilePathText);
-
-            // 入力したパスの画像が表示される
-            using (var imageStream = File.OpenRead("Resources/re1.png"))
-            {
-                using var image2 = MemoryImage.CopyFromStream(imageStream);
-                Assert.Equal(image2, mediaSelector.ImageSelectedPicture.Image);
-            }
+            var images = new[] { "Resources/re.gif", "Resources/re1.png" };
+            mediaSelector.AddMediaItemFromFilePath(images);
 
-            // 最初に入力されていたファイルパスの表示用の MemoryImage は破棄される
-            Assert.True(image1!.IsDisposed);
+            // 1 ページ目
+            mediaSelector.SelectedMediaItemIndex = 0;
+            mediaSelector.SetSelectedMediaAltText("Page 1");
 
-            // 参照されなくなった MemoryImageMediaItem も破棄される
-            Assert.True(mediaItem.IsDisposed);
-        }
+            // 2 ページ目
+            mediaSelector.SelectedMediaItemIndex = 1;
+            mediaSelector.SetSelectedMediaAltText("Page 2");
 
-        [Fact]
-        public void ImageServiceChange_Test()
-        {
-            using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create(""));
-            using var twitter = new Twitter(twitterApi);
-            using var mediaSelector = new MediaSelectorPanel { Visible = false, Enabled = false };
-            twitter.Initialize("", "", "", 0L);
-            mediaSelector.Initialize(twitter, TwitterConfiguration.DefaultConfiguration(), "Twitter");
-
-            Assert.Equal("Twitter", mediaSelector.ServiceName);
-
-            mediaSelector.BeginSelection(new[] { "Resources/re.gif", "Resources/re1.png" });
-
-            // 3 ページ目まで選択可能な状態
-            var pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2", "3" }, pages.Cast<object>().Select(x => x.ToString()));
-            Assert.True(mediaSelector.ImagePageCombo.Enabled);
-
-            // 投稿先を Imgur に変更
-            var imgurIndex = mediaSelector.ImageServiceCombo.Items.IndexOf("Imgur");
-            Assert.Raises<EventArgs>(
-                x => mediaSelector.SelectedServiceChanged += x,
-                x => mediaSelector.SelectedServiceChanged -= x,
-                () => mediaSelector.ImageServiceCombo.SelectedIndex = imgurIndex
-            );
-
-            // 1 ページ目のみ選択可能な状態 (Disabled)
-            pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1" }, pages.Cast<object>().Select(x => x.ToString()));
-            Assert.False(mediaSelector.ImagePageCombo.Enabled);
-
-            // 投稿先を Twitter に変更
-            mediaSelector.ImageServiceCombo.SelectedIndex =
-                mediaSelector.ImageServiceCombo.Items.IndexOf("Twitter");
-
-            // 2 ページ目まで選択可能な状態
-            pages = mediaSelector.ImagePageCombo.Items;
-            Assert.Equal(new[] { "1", "2" }, pages.Cast<object>().Select(x => x.ToString()));
-            Assert.True(mediaSelector.ImagePageCombo.Enabled);
+            Assert.Equal("Page 1", mediaSelector.MediaItems[0].AltText);
+            Assert.Equal("Page 2", mediaSelector.MediaItems[1].AltText);
         }
     }
 }
index 788fed1..6456957 100644 (file)
@@ -158,6 +158,19 @@ namespace OpenTween
             return count;
         }
 
+        public static bool TryInvoke(this Control control, Action action)
+        {
+            if (control.IsDisposed)
+                return false;
+
+            if (control.InvokeRequired)
+                control.Invoke(action);
+            else
+                action();
+
+            return true;
+        }
+
         public static Task ForEachAsync<T>(this IObservable<T> observable, Action<T> subscriber)
         {
             return ForEachAsync(observable, value =>
diff --git a/OpenTween/MediaSelector.cs b/OpenTween/MediaSelector.cs
new file mode 100644 (file)
index 0000000..943fc02
--- /dev/null
@@ -0,0 +1,341 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2014 spx (@5px)
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using OpenTween.Api.DataModel;
+using OpenTween.MediaUploadServices;
+
+namespace OpenTween
+{
+    public sealed class MediaSelector : NotifyPropertyChangedBase, IDisposable
+    {
+        private KeyValuePair<string, IMediaUploadService>[] pictureServices = Array.Empty<KeyValuePair<string, IMediaUploadService>>();
+        private readonly BindingList<IMediaItem> mediaItems = new();
+        private string selectedMediaServiceName = "";
+        private Guid? selectedMediaItemId = null;
+
+        public bool IsDisposed { get; private set; } = false;
+
+        public KeyValuePair<string, IMediaUploadService>[] MediaServices
+        {
+            get => this.pictureServices;
+            private set => this.SetProperty(ref this.pictureServices, value);
+        }
+
+        public BindingList<IMediaItem> MediaItems
+            => this.mediaItems;
+
+        public MemoryImageList ThumbnailList { get; } = new();
+
+        /// <summary>
+        /// 選択されている投稿先名を取得する。
+        /// </summary>
+        public string SelectedMediaServiceName
+        {
+            get => this.selectedMediaServiceName;
+            set => this.SetProperty(ref this.selectedMediaServiceName, value);
+        }
+
+        /// <summary>
+        /// 選択されている投稿先を示すインデックスを取得する。
+        /// </summary>
+        public int SelectedMediaServiceIndex
+            => this.MediaServices.FindIndex(x => x.Key == this.SelectedMediaServiceName);
+
+        /// <summary>
+        /// 選択されている投稿先の IMediaUploadService を取得する。
+        /// </summary>
+        public IMediaUploadService? SelectedMediaService
+            => this.GetService(this.SelectedMediaServiceName);
+
+        public bool CanUseAltText
+            => this.SelectedMediaService?.CanUseAltText ?? false;
+
+        public Guid? SelectedMediaItemId
+        {
+            get => this.selectedMediaItemId;
+            set => this.SetProperty(ref this.selectedMediaItemId, value);
+        }
+
+        public IMediaItem? SelectedMediaItem
+            => this.SelectedMediaItemId != null ? this.MediaItems.First(x => x.Id == this.SelectedMediaItemId) : null;
+
+        public int SelectedMediaItemIndex
+        {
+            get => this.MediaItems.FindIndex(x => x.Id == this.SelectedMediaItemId);
+            set => this.SelectedMediaItemId = value != -1 ? this.MediaItems[value].Id : null;
+        }
+
+        /// <summary>
+        /// 指定された投稿先名から、作成済みの IMediaUploadService インスタンスを取得する。
+        /// </summary>
+        public IMediaUploadService? GetService(string serviceName)
+        {
+            var index = this.MediaServices.FindIndex(x => x.Key == serviceName);
+            return index != -1 ? this.MediaServices[index].Value : null;
+        }
+
+        public void InitializeServices(Twitter tw, TwitterConfiguration twitterConfig)
+        {
+            this.MediaServices = new KeyValuePair<string, IMediaUploadService>[]
+            {
+                new("Twitter", new TwitterPhoto(tw, twitterConfig)),
+                new("Imgur", new Imgur(twitterConfig)),
+                new("Mobypicture", new Mobypicture(tw, twitterConfig)),
+            };
+        }
+
+        public void SelectMediaService(string serviceName, int? index = null)
+        {
+            int idx;
+            if (MyCommon.IsNullOrEmpty(serviceName))
+            {
+                // 引数の index は serviceName が空の場合のみ使用する
+                idx = index ?? 0;
+            }
+            else
+            {
+                idx = this.MediaServices.FindIndex(x => x.Key == serviceName);
+
+                // svc が空白以外かつ存在しないサービス名の場合は Twitter を選択させる
+                // (廃止されたサービスを選択していた場合の対応)
+                if (idx == -1)
+                    idx = 0;
+            }
+
+            this.SelectedMediaServiceName = this.MediaServices[idx].Key;
+        }
+
+        /// <summary>
+        /// 指定されたファイルの投稿に対応した投稿先があるかどうかを示す値を取得する。
+        /// </summary>
+        public bool HasUploadableService(string fileName, bool ignoreSize)
+        {
+            var fl = new FileInfo(fileName);
+            var ext = fl.Extension;
+            var size = ignoreSize ? (long?)null : fl.Length;
+
+            return this.GetAvailableServiceNames(ext, size).Any();
+        }
+
+        public string[] GetAvailableServiceNames(string extension, long? fileSize)
+            => this.MediaServices
+                .Where(x => x.Value.CheckFileExtension(extension) && (fileSize == null || x.Value.CheckFileSize(extension, fileSize.Value)))
+                .Select(x => x.Key)
+                .ToArray();
+
+        public void AddMediaItemFromImage(Image image)
+        {
+            var mediaItem = this.CreateMemoryImageMediaItem(image);
+            if (mediaItem == null)
+                return;
+
+            this.AddMediaItem(mediaItem);
+            this.SelectedMediaItemId = mediaItem.Id;
+        }
+
+        public void AddMediaItemFromFilePath(string[] filePathArray)
+        {
+            if (filePathArray.Length == 0)
+                return;
+
+            var mediaItems = new IMediaItem[filePathArray.Length];
+
+            // 連番のファイル名を一括でアップロードする場合の利便性のためソートする
+            var sortedFilePath = filePathArray.OrderBy(x => x);
+
+            foreach (var (path, index) in sortedFilePath.WithIndex())
+            {
+                var mediaItem = this.CreateFileMediaItem(path);
+                if (mediaItem == null)
+                    continue;
+
+                mediaItems[index] = mediaItem;
+            }
+
+            // 全ての IMediaItem の生成に成功した場合のみ追加する
+            foreach (var mediaItem in mediaItems)
+                this.AddMediaItem(mediaItem);
+
+            this.SelectedMediaItemId = mediaItems.Last().Id;
+        }
+
+        public void AddMediaItem(IMediaItem item)
+        {
+            MemoryImage thumbnailImage;
+            try
+            {
+                thumbnailImage = item.CreateImage();
+            }
+            catch (InvalidImageException)
+            {
+                thumbnailImage = MemoryImage.CopyFromImage(Properties.Resources.MultiMediaImage);
+            }
+
+            var id = item.Id.ToString();
+            this.ThumbnailList.Add(id, thumbnailImage);
+            this.MediaItems.Add(item);
+        }
+
+        public void ClearMediaItems()
+        {
+            this.SelectedMediaItemId = null;
+
+            var mediaItems = this.MediaItems.ToList();
+            this.MediaItems.Clear();
+
+            foreach (var mediaItem in mediaItems)
+                this.DisposeMediaItem(mediaItem);
+
+            var thumbnailImages = this.ThumbnailList.ToList();
+            this.ThumbnailList.Clear();
+
+            foreach (var image in thumbnailImages)
+                image.Dispose();
+        }
+
+        public IMediaItem[] DetachMediaItems()
+        {
+            // ClearMediaItems では MediaItem が破棄されるため、外部で使用する場合はこのメソッドを使用して MediaItems から切り離す
+            var mediaItems = this.MediaItems.ToArray();
+            this.MediaItems.Clear();
+            this.ClearMediaItems();
+
+            return mediaItems;
+        }
+
+        private MemoryImageMediaItem? CreateMemoryImageMediaItem(Image image)
+        {
+            if (image == null)
+                return null;
+
+            MemoryImage? memoryImage = null;
+            try
+            {
+                // image から png 形式の MemoryImage を生成
+                memoryImage = MemoryImage.CopyFromImage(image);
+
+                return new MemoryImageMediaItem(memoryImage);
+            }
+            catch
+            {
+                memoryImage?.Dispose();
+                return null;
+            }
+        }
+
+        private FileMediaItem? CreateFileMediaItem(string path)
+        {
+            if (MyCommon.IsNullOrEmpty(path))
+                return null;
+
+            try
+            {
+                return new FileMediaItem(path);
+            }
+            catch
+            {
+                return null;
+            }
+        }
+
+        private void DisposeMediaItem(IMediaItem? item)
+        {
+            var disposableItem = item as IDisposable;
+            disposableItem?.Dispose();
+        }
+
+        public void SetSelectedMediaAltText(string altText)
+        {
+            var selectedMedia = this.SelectedMediaItem;
+            if (selectedMedia == null)
+                return;
+
+            selectedMedia.AltText = altText.Trim();
+        }
+
+        public MediaSelectorErrorType Validate(out IMediaItem? rejectedMedia)
+        {
+            rejectedMedia = null;
+
+            if (this.MediaItems.Count == 0)
+                return MediaSelectorErrorType.MediaItemNotSet;
+
+            var uploadService = this.SelectedMediaService;
+            if (uploadService == null)
+                return MediaSelectorErrorType.ServiceNotSelected;
+
+            foreach (var mediaItem in this.MediaItems)
+            {
+                var error = this.ValidateMediaItem(uploadService, mediaItem);
+                if (error != MediaSelectorErrorType.None)
+                {
+                    rejectedMedia = mediaItem;
+                    return error;
+                }
+            }
+
+            return MediaSelectorErrorType.None;
+        }
+
+        private MediaSelectorErrorType ValidateMediaItem(IMediaUploadService imageService, IMediaItem item)
+        {
+            var ext = item.Extension;
+            var size = item.Size;
+
+            if (!imageService.CheckFileExtension(ext))
+                return MediaSelectorErrorType.UnsupportedFileExtension;
+
+            if (!imageService.CheckFileSize(ext, size))
+                return MediaSelectorErrorType.FileSizeExceeded;
+
+            return MediaSelectorErrorType.None;
+        }
+
+        public void Dispose()
+        {
+            if (this.IsDisposed)
+                return;
+
+            this.IsDisposed = true;
+            this.ThumbnailList.Dispose();
+        }
+    }
+
+    public enum MediaSelectorErrorType
+    {
+        None,
+        MediaItemNotSet,
+        ServiceNotSelected,
+        UnsupportedFileExtension,
+        FileSizeExceeded,
+    }
+}
index 3fbbe86..7521e23 100644 (file)
@@ -1,5 +1,6 @@
 // OpenTween - Client of Twitter
 // Copyright (c) 2014 spx (@5px)
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
 // All rights reserved.
 //
 // This file is part of OpenTween.
@@ -26,14 +27,10 @@ using System.Collections.Generic;
 using System.ComponentModel;
 using System.Data;
 using System.Diagnostics.CodeAnalysis;
-using System.Drawing;
-using System.IO;
 using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
 using System.Windows.Forms;
-using OpenTween.Api.DataModel;
-using OpenTween.MediaUploadServices;
 
 namespace OpenTween
 {
@@ -51,237 +48,30 @@ namespace OpenTween
 
         [Browsable(false)]
         [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
-        public OpenFileDialog? FilePickDialog { get; set; }
+        public MediaSelector Model { get; } = new();
 
-        /// <summary>
-        /// 選択されている投稿先名を取得する。
-        /// </summary>
-        [Browsable(false)]
-        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
-        public string ServiceName
-            => this.ImageServiceCombo.Text;
-
-        /// <summary>
-        /// 選択されている投稿先を示すインデックスを取得する。
-        /// </summary>
-        [Browsable(false)]
-        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
-        public int ServiceIndex
-            => this.ImageServiceCombo.SelectedIndex;
-
-        /// <summary>
-        /// 選択されている投稿先の IMediaUploadService を取得する。
-        /// </summary>
         [Browsable(false)]
         [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
-        public IMediaUploadService? SelectedService
-        {
-            get
-            {
-                var serviceName = this.ServiceName;
-                if (MyCommon.IsNullOrEmpty(serviceName))
-                    return null;
-
-                return this.pictureService.TryGetValue(serviceName, out var service)
-                    ? service : null;
-            }
-        }
-
-        /// <summary>
-        /// 指定された投稿先名から、作成済みの IMediaUploadService インスタンスを取得する。
-        /// </summary>
-        public IMediaUploadService GetService(string serviceName)
-        {
-            this.pictureService.TryGetValue(serviceName, out var service);
-            return service;
-        }
-
-        /// <summary>
-        /// 利用可能な全ての IMediaUploadService インスタンスを取得する。
-        /// </summary>
-        public ICollection<IMediaUploadService> GetServices()
-            => this.pictureService.Values;
-
-        private Dictionary<string, IMediaUploadService> pictureService = new();
-
-        private readonly List<IMediaItem> mediaItems = new();
-        private readonly MemoryImageList thumbnailList = new();
-        private Guid? selectedMediaItemId = null;
-
-        private void CreateServices(Twitter tw, TwitterConfiguration twitterConfig)
-        {
-            this.pictureService?.Clear();
-
-            this.pictureService = new Dictionary<string, IMediaUploadService>
-            {
-                ["Twitter"] = new TwitterPhoto(tw, twitterConfig),
-                ["Imgur"] = new Imgur(twitterConfig),
-                ["Mobypicture"] = new Mobypicture(tw, twitterConfig),
-            };
-        }
+        public OpenFileDialog? FilePickDialog { get; set; }
 
         public MediaSelectorPanel()
         {
             this.InitializeComponent();
 
             this.ImageSelectedPicture.InitialImage = Properties.Resources.InitialImage;
-            this.MediaListView.LargeImageList = this.thumbnailList.ImageList;
-
-            var thumbnailWidth = 75 * this.DeviceDpi / 96;
-            this.thumbnailList.ImageList.ImageSize = new(thumbnailWidth, thumbnailWidth);
 
-            this.UpdateSelectedMedia();
-            this.UpdateAltTextPanelVisible();
-        }
-
-        /// <summary>
-        /// 投稿先サービスなどを初期化する。
-        /// </summary>
-        public void Initialize(Twitter tw, TwitterConfiguration twitterConfig, string svc, int? index = null)
-        {
-            this.CreateServices(tw, twitterConfig);
-
-            this.SetImageServiceCombo();
-            this.SelectImageServiceComboItem(svc, index);
-        }
-
-        /// <summary>
-        /// 投稿先サービスを再作成する。
-        /// </summary>
-        public void Reset(Twitter tw, TwitterConfiguration twitterConfig)
-        {
-            this.CreateServices(tw, twitterConfig);
-
-            this.SetImageServiceCombo();
-        }
+            this.MediaListView.LargeImageList = this.Model.ThumbnailList.ImageList;
 
-        /// <summary>
-        /// 指定されたファイルの投稿に対応した投稿先があるかどうかを示す値を取得する。
-        /// </summary>
-        public bool HasUploadableService(string fileName, bool ignoreSize)
-        {
-            var fl = new FileInfo(fileName);
-            var ext = fl.Extension;
-            var size = ignoreSize ? (long?)null : fl.Length;
-
-            if (this.IsUploadable(this.ServiceName, ext, size))
-                return true;
-
-            foreach (string svc in this.ImageServiceCombo.Items)
-            {
-                if (this.IsUploadable(svc, ext, size))
-                    return true;
-            }
-
-            return false;
-        }
-
-        /// <summary>
-        /// 指定された投稿先に投稿可能かどうかを示す値を取得する。
-        /// ファイルサイズの指定がなければ拡張子だけで判定する。
-        /// </summary>
-        private bool IsUploadable(string serviceName, string ext, long? size)
-        {
-            if (!MyCommon.IsNullOrEmpty(serviceName))
-            {
-                var imageService = this.pictureService[serviceName];
-                if (imageService.CheckFileExtension(ext))
-                {
-                    if (!size.HasValue)
-                        return true;
+            var thumbnailWidth = 75 * this.DeviceDpi / 96;
+            this.Model.ThumbnailList.ImageList.ImageSize = new(thumbnailWidth, thumbnailWidth);
 
-                    if (imageService.CheckFileSize(ext, size.Value))
-                        return true;
-                }
-            }
-            return false;
-        }
+            this.Model.PropertyChanged +=
+                (s, e) => this.TryInvoke(() => this.Model_PropertyChanged(s, e));
+            this.Model.MediaItems.ListChanged +=
+                (s, e) => this.TryInvoke(() => this.Model_MediaItems_ListChanged(s, e));
 
-        private void ClearMediaItems()
-        {
-            this.selectedMediaItemId = null;
             this.UpdateSelectedMedia();
-
-            this.MediaListView.Items.Clear();
-
-            var mediaItems = this.mediaItems.ToList();
-            this.mediaItems.Clear();
-
-            foreach (var mediaItem in mediaItems)
-                this.DisposeMediaItem(mediaItem);
-
-            var thumbnailImages = this.thumbnailList.ToList();
-            this.thumbnailList.Clear();
-
-            foreach (var image in thumbnailImages)
-                image.Dispose();
-        }
-
-        public void AddMediaItemFromImage(Image image)
-        {
-            var mediaItem = this.CreateMemoryImageMediaItem(image, noMsgBox: false);
-            if (mediaItem == null)
-                return;
-
-            this.AddMediaItem(mediaItem);
-            this.SelectMediaItem(mediaItem.Id);
-        }
-
-        public void AddMediaItemFromFilePath(string[] filePathArray)
-        {
-            if (filePathArray.Length == 0)
-                return;
-
-            var mediaItems = new IMediaItem[filePathArray.Length];
-
-            // 連番のファイル名を一括でアップロードする場合の利便性のためソートする
-            var sortedFilePath = filePathArray.OrderBy(x => x);
-
-            foreach (var (path, index) in sortedFilePath.WithIndex())
-            {
-                var mediaItem = this.CreateFileMediaItem(path, noMsgBox: false);
-                if (mediaItem == null)
-                    return;
-
-                mediaItems[index] = mediaItem;
-            }
-
-            // 全ての IMediaItem の生成に成功した場合のみ追加する
-            foreach (var mediaItem in mediaItems)
-                this.AddMediaItem(mediaItem);
-
-            this.SelectMediaItem(mediaItems.Last().Id);
-        }
-
-        public void AddMediaItem(IMediaItem item)
-        {
-            this.mediaItems.Add(item);
-
-            MemoryImage thumbnailImage;
-            try
-            {
-                thumbnailImage = item.CreateImage();
-            }
-            catch (InvalidImageException)
-            {
-                thumbnailImage = MemoryImage.CopyFromImage(Properties.Resources.MultiMediaImage);
-            }
-
-            var id = item.Id.ToString();
-            this.thumbnailList.Add(id, thumbnailImage);
-
-            this.MediaListView.Items.Add(item.Name, id);
-        }
-
-        public void SelectMediaItem(Guid id)
-        {
-            var index = this.mediaItems.FindIndex(x => x.Id == id);
-            if (index == -1)
-                return;
-
-            // selectedMediaItemId は ImageListView のイベントハンドラ内でセットされる
-            this.MediaListView.SelectedIndices.Clear();
-            this.MediaListView.SelectedIndices.Add(index);
+            this.UpdateAltTextPanelVisible();
         }
 
         /// <summary>
@@ -302,7 +92,7 @@ namespace OpenTween
             this.EndSelecting?.Invoke(this, EventArgs.Empty);
             this.Visible = false;
             this.Enabled = false;
-            this.ClearMediaItems();
+            this.Model.ClearMediaItems();
         }
 
         /// <summary>
@@ -310,76 +100,113 @@ namespace OpenTween
         /// </summary>
         public bool TryGetSelectedMedia([NotNullWhen(true)] out string? imageService, [NotNullWhen(true)] out IMediaItem[]? mediaItems)
         {
-            imageService = null;
-            mediaItems = null;
+            var selectedServiceName = this.Model.SelectedMediaServiceName;
 
-            var uploadService = this.SelectedService;
-            if (uploadService == null || this.mediaItems.Count == 0)
+            var error = this.Model.Validate(out var rejectedMedia);
+            if (error != MediaSelectorErrorType.None)
             {
-                MessageBox.Show(Properties.Resources.PostPictureWarn1, Properties.Resources.PostPictureWarn2);
-                return false;
-            }
+                var message = error switch
+                {
+                    MediaSelectorErrorType.MediaItemNotSet
+                        => Properties.Resources.PostPictureWarn1,
+                    MediaSelectorErrorType.ServiceNotSelected
+                        => Properties.Resources.PostPictureWarn1,
+                    MediaSelectorErrorType.UnsupportedFileExtension
+                        => string.Format(
+                            Properties.Resources.PostPictureWarn3,
+                            selectedServiceName,
+                            this.MakeAvailableServiceText(rejectedMedia!),
+                            rejectedMedia!.Extension,
+                            rejectedMedia!.Name
+                        ),
+                    MediaSelectorErrorType.FileSizeExceeded
+                        => string.Format(
+                            Properties.Resources.PostPictureWarn5,
+                            selectedServiceName,
+                            this.MakeAvailableServiceText(rejectedMedia!),
+                            rejectedMedia!.Name
+                        ),
+                    _ => throw new NotImplementedException(),
+                };
 
-            foreach (var mediaItem in this.mediaItems)
-            {
-                if (!this.ValidateMediaItem(uploadService, mediaItem))
-                    return false;
-            }
+                MessageBox.Show(
+                    message,
+                    Properties.Resources.PostPictureWarn2,
+                    MessageBoxButtons.OK,
+                    MessageBoxIcon.Warning
+                );
 
-            // 収集した MediaItem が破棄されないように、ClearMediaItems を呼ぶ前に mediaItems を空にしておく
-            this.mediaItems.Clear();
+                imageService = null;
+                mediaItems = null;
+                return false;
+            }
 
-            imageService = this.ServiceName;
-            mediaItems = this.mediaItems.ToArray();
-            this.EndSelection();
+            imageService = selectedServiceName;
+            mediaItems = this.Model.DetachMediaItems();
             return true;
         }
 
-        private MemoryImageMediaItem? CreateMemoryImageMediaItem(Image image, bool noMsgBox)
+        private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
         {
-            if (image == null) return null;
-
-            MemoryImage? memoryImage = null;
-            try
-            {
-                // image から png 形式の MemoryImage を生成
-                memoryImage = MemoryImage.CopyFromImage(image);
-
-                return new MemoryImageMediaItem(memoryImage);
-            }
-            catch
+            switch (e.PropertyName)
             {
-                memoryImage?.Dispose();
-
-                if (!noMsgBox) MessageBox.Show("Unable to create MemoryImage.");
-                return null;
+                case nameof(MediaSelector.MediaServices):
+                    this.UpdateImageServiceComboItems();
+                    break;
+                case nameof(MediaSelector.SelectedMediaServiceName):
+                    this.UpdateImageServiceComboSelection();
+                    this.UpdateAltTextPanelVisible();
+                    this.SelectedServiceChanged?.Invoke(this, EventArgs.Empty);
+                    break;
+                case nameof(MediaSelector.SelectedMediaItemId):
+                    this.UpdateSelectedMedia();
+                    break;
+                default:
+                    break;
             }
         }
 
-        private IMediaItem? CreateFileMediaItem(string path, bool noMsgBox)
+        private void Model_MediaItems_ListChanged(object sender, ListChangedEventArgs e)
         {
-            if (MyCommon.IsNullOrEmpty(path)) return null;
+            void AddMediaListViewItem(IMediaItem media, int index)
+                => this.MediaListView.Items.Insert(index, media.Name, media.Id.ToString());
 
-            try
-            {
-                return new FileMediaItem(path);
-            }
-            catch
+            switch (e.ListChangedType)
             {
-                if (!noMsgBox) MessageBox.Show("Invalid file path: " + path);
-                return null;
+                case ListChangedType.ItemAdded:
+                    var addedMedia = this.Model.MediaItems[e.NewIndex];
+                    AddMediaListViewItem(addedMedia, e.NewIndex);
+                    break;
+                case ListChangedType.Reset:
+                    this.MediaListView.Items.Clear();
+                    foreach (var (media, index) in this.Model.MediaItems.WithIndex())
+                        AddMediaListViewItem(media, index);
+                    break;
+                default:
+                    throw new NotImplementedException();
             }
         }
 
-        private void DisposeMediaItem(IMediaItem? item)
+        private void UpdateImageServiceComboItems()
         {
-            var disposableItem = item as IDisposable;
-            disposableItem?.Dispose();
+            using (ControlTransaction.Update(this.ImageServiceCombo))
+            {
+                this.ImageServiceCombo.Items.Clear();
+
+                // Add service names to combobox
+                var serviceNames = this.Model.MediaServices.Select(x => x.Key).ToArray();
+                this.ImageServiceCombo.Items.AddRange(serviceNames);
+
+                this.UpdateImageServiceComboSelection();
+            }
         }
 
+        private void UpdateImageServiceComboSelection()
+            => this.ImageServiceCombo.SelectedIndex = this.Model.SelectedMediaServiceIndex;
+
         private void AddMediaButton_Click(object sender, EventArgs e)
         {
-            var service = this.SelectedService;
+            var service = this.Model.SelectedMediaService;
 
             if (this.FilePickDialog == null || service == null) return;
             this.FilePickDialog.Filter = service.SupportedFormatsStrForDialog;
@@ -397,117 +224,33 @@ namespace OpenTween
                 this.FilePickDialogClosed?.Invoke(this, EventArgs.Empty);
             }
 
-            this.AddMediaItemFromFilePath(this.FilePickDialog.FileNames);
+            this.Model.AddMediaItemFromFilePath(this.FilePickDialog.FileNames);
         }
 
-        private bool ValidateMediaItem(IMediaUploadService imageService, IMediaItem item)
+        private string MakeAvailableServiceText(IMediaItem media)
         {
-            var ext = item.Extension;
-            var size = item.Size;
-
-            if (!imageService.CheckFileExtension(ext))
-            {
-                // 画像以外の形式
-                MessageBox.Show(
-                    string.Format(Properties.Resources.PostPictureWarn3, this.ServiceName, this.MakeAvailableServiceText(ext, size), ext, item.Name),
-                    Properties.Resources.PostPictureWarn4,
-                    MessageBoxButtons.OK,
-                    MessageBoxIcon.Warning
-                );
-                return false;
-            }
-
-            if (!imageService.CheckFileSize(ext, size))
-            {
-                // ファイルサイズが大きすぎる
-                MessageBox.Show(
-                    string.Format(Properties.Resources.PostPictureWarn5, this.ServiceName, this.MakeAvailableServiceText(ext, size), item.Name),
-                    Properties.Resources.PostPictureWarn4,
-                    MessageBoxButtons.OK,
-                    MessageBoxIcon.Warning
-                );
-                return false;
-            }
+            var ext = media.Extension;
+            var fileSize = media.Size;
 
-            return true;
-        }
-
-        private string MakeAvailableServiceText(string ext, long fileSize)
-        {
-            var text = string.Join(", ",
-                this.ImageServiceCombo.Items.Cast<string>()
-                    .Where(serviceName =>
-                        !MyCommon.IsNullOrEmpty(serviceName) &&
-                        this.pictureService[serviceName].CheckFileExtension(ext) &&
-                        this.pictureService[serviceName].CheckFileSize(ext, fileSize)));
-
-            if (MyCommon.IsNullOrEmpty(text))
+            var availableServiceNames = this.Model.GetAvailableServiceNames(ext, fileSize);
+            if (availableServiceNames.Length == 0)
                 return Properties.Resources.PostPictureWarn6;
 
-            return text;
+            return string.Join(", ", availableServiceNames);
         }
 
         private void ImageCancelButton_Click(object sender, EventArgs e)
             => this.EndSelection();
 
-        private void SetImageServiceCombo()
-        {
-            using (ControlTransaction.Update(this.ImageServiceCombo))
-            {
-                var svc = "";
-                if (this.ImageServiceCombo.SelectedIndex > -1) svc = this.ImageServiceCombo.Text;
-                this.ImageServiceCombo.Items.Clear();
-
-                // Add service names to combobox
-                foreach (var key in this.pictureService.Keys)
-                {
-                    this.ImageServiceCombo.Items.Add(key);
-                }
-
-                this.SelectImageServiceComboItem(svc);
-            }
-        }
-
-        private void SelectImageServiceComboItem(string svc, int? index = null)
-        {
-            int idx;
-            if (MyCommon.IsNullOrEmpty(svc))
-            {
-                idx = index ?? 0;
-            }
-            else
-            {
-                idx = this.ImageServiceCombo.Items.IndexOf(svc);
-
-                // svc が空白以外かつ存在しないサービス名の場合は Twitter を選択させる
-                // (廃止されたサービスを選択していた場合の対応)
-                if (idx == -1) idx = 0;
-            }
-
-            try
-            {
-                this.ImageServiceCombo.SelectedIndex = idx;
-            }
-            catch (ArgumentOutOfRangeException)
-            {
-                this.ImageServiceCombo.SelectedIndex = 0;
-            }
-
-            this.UpdateAltTextPanelVisible();
-        }
-
         private void UpdateAltTextPanelVisible()
-            => this.AlternativeTextPanel.Visible = this.SelectedService switch
-            {
-                null => false,
-                var service => service.CanUseAltText,
-            };
+            => this.AlternativeTextPanel.Visible = this.Model.CanUseAltText;
 
         private void UpdateSelectedMedia()
         {
             using (ControlTransaction.Update(this))
             {
-                if (this.selectedMediaItemId == null)
+                var selectedMedia = this.Model.SelectedMediaItem;
+                if (selectedMedia == null)
                 {
                     this.AlternativeTextBox.Text = "";
                     this.AlternativeTextPanel.Enabled = false;
@@ -515,53 +258,34 @@ namespace OpenTween
                 }
                 else
                 {
-                    var media = this.mediaItems.First(x => x.Id == this.selectedMediaItemId);
-
-                    this.AlternativeTextBox.Text = media.AltText;
+                    this.AlternativeTextBox.Text = selectedMedia.AltText;
                     this.AlternativeTextPanel.Enabled = true;
-                    this.ImageSelectedPicture.Image = media.CreateImage();
+                    this.ImageSelectedPicture.Image = selectedMedia.CreateImage();
                 }
             }
         }
 
         private void ImageServiceCombo_SelectedIndexChanged(object sender, EventArgs e)
-        {
-            this.UpdateAltTextPanelVisible();
-            this.SelectedServiceChanged?.Invoke(this, EventArgs.Empty);
-        }
+            => this.Model.SelectedMediaServiceName = this.ImageServiceCombo.Text;
 
         private void MediaListView_SelectedIndexChanged(object sender, EventArgs e)
         {
             var indices = this.MediaListView.SelectedIndices;
             if (indices.Count == 0)
-            {
-                this.selectedMediaItemId = null;
-            }
-            else
-            {
-                var media = this.mediaItems[indices[0]];
-                this.selectedMediaItemId = media.Id;
-            }
+                return;
 
-            this.UpdateSelectedMedia();
+            this.Model.SelectedMediaItemIndex = indices[0];
         }
 
         private void AlternativeTextBox_Validated(object sender, EventArgs e)
-        {
-            if (this.selectedMediaItemId == null)
-                return;
-
-            var media = this.mediaItems.First(x => x.Id == this.selectedMediaItemId);
-            media.AltText = this.AlternativeTextBox.Text.Trim();
-        }
+            => this.Model.SetSelectedMediaAltText(this.AlternativeTextBox.Text);
 
         protected override void Dispose(bool disposing)
         {
             if (disposing)
             {
-                this.ClearMediaItems();
-                this.thumbnailList.Dispose();
                 this.components?.Dispose();
+                this.Model.Dispose();
             }
 
             base.Dispose(disposing);
index cc726ae..6649f9b 100644 (file)
@@ -555,7 +555,8 @@ namespace OpenTween
             Thumbnail.Services.TonTwitterCom.GetApiConnection = () => this.tw.Api.Connection;
 
             // 画像投稿サービス
-            this.ImageSelector.Initialize(this.tw, this.tw.Configuration, this.settings.Common.UseImageServiceName, this.settings.Common.UseImageService);
+            this.ImageSelector.Model.InitializeServices(this.tw, this.tw.Configuration);
+            this.ImageSelector.Model.SelectMediaService(this.settings.Common.UseImageServiceName, this.settings.Common.UseImageService);
 
             this.tweetThumbnail1.Initialize(this.thumbGenerator);
 
@@ -1282,7 +1283,8 @@ namespace OpenTween
                 if (!this.ImageSelector.TryGetSelectedMedia(out var serviceName, out uploadItems))
                     return;
 
-                uploadService = this.ImageSelector.GetService(serviceName);
+                this.ImageSelector.EndSelection();
+                uploadService = this.ImageSelector.Model.GetService(serviceName);
             }
 
             this.inReplyTo = null;
@@ -1950,7 +1952,7 @@ namespace OpenTween
 
                 if (this.tw.Configuration.PhotoSizeLimit != 0)
                 {
-                    foreach (var service in this.ImageSelector.GetServices())
+                    foreach (var (_, service) in this.ImageSelector.Model.MediaServices)
                     {
                         service.UpdateTwitterConfiguration(this.tw.Configuration);
                     }
@@ -2583,7 +2585,7 @@ namespace OpenTween
                     this.tw.RestrictFavCheck = this.settings.Common.RestrictFavCheck;
                     this.tw.ReadOwnPost = this.settings.Common.ReadOwnPost;
 
-                    this.ImageSelector.Reset(this.tw, this.tw.Configuration);
+                    this.ImageSelector.Model.InitializeServices(this.tw, this.tw.Configuration);
 
                     try
                     {
@@ -3518,7 +3520,7 @@ namespace OpenTween
             attachmentUrl = null;
 
             // attachment_url は media_id と同時に使用できない
-            if (this.ImageSelector.Visible && this.ImageSelector.SelectedService is TwitterPhoto)
+            if (this.ImageSelector.Visible && this.ImageSelector.Model.SelectedMediaService is TwitterPhoto)
                 return statusText;
 
             var match = Twitter.AttachmentUrlRegex.Match(statusText);
@@ -3661,7 +3663,7 @@ namespace OpenTween
         }
 
         private IMediaUploadService? GetSelectedImageService()
-            => this.ImageSelector.Visible ? this.ImageSelector.SelectedService : null;
+            => this.ImageSelector.Visible ? this.ImageSelector.Model.SelectedMediaService : null;
 
         /// <summary>
         /// 全てのタブの振り分けルールを反映し直します
@@ -5732,8 +5734,8 @@ namespace OpenTween
                 this.settings.Common.HashIsHead = this.HashMgr.IsHead;
                 this.settings.Common.HashIsPermanent = this.HashMgr.IsPermanent;
                 this.settings.Common.HashIsNotAddToAtReply = this.HashMgr.IsNotAddToAtReply;
-                this.settings.Common.UseImageService = this.ImageSelector.ServiceIndex;
-                this.settings.Common.UseImageServiceName = this.ImageSelector.ServiceName;
+                this.settings.Common.UseImageService = this.ImageSelector.Model.SelectedMediaServiceIndex;
+                this.settings.Common.UseImageServiceName = this.ImageSelector.Model.SelectedMediaServiceName;
 
                 this.settings.SaveCommon();
             }
@@ -9168,7 +9170,7 @@ namespace OpenTween
 
         private void SelectMedia_DragEnter(DragEventArgs e)
         {
-            if (this.ImageSelector.HasUploadableService(((string[])e.Data.GetData(DataFormats.FileDrop, false))[0], true))
+            if (this.ImageSelector.Model.HasUploadableService(((string[])e.Data.GetData(DataFormats.FileDrop, false))[0], true))
             {
                 e.Effect = DragDropEffects.Copy;
                 return;
@@ -9183,7 +9185,7 @@ namespace OpenTween
 
             var filePathArray = (string[])e.Data.GetData(DataFormats.FileDrop, false);
             this.ImageSelector.BeginSelection();
-            this.ImageSelector.AddMediaItemFromFilePath(filePathArray);
+            this.ImageSelector.Model.AddMediaItemFromFilePath(filePathArray);
             this.StatusText.Focus();
         }
 
@@ -9235,13 +9237,13 @@ namespace OpenTween
                     // clipboardから画像を取得
                     using var image = Clipboard.GetImage();
                     this.ImageSelector.BeginSelection();
-                    this.ImageSelector.AddMediaItemFromImage(image);
+                    this.ImageSelector.Model.AddMediaItemFromImage(image);
                 }
                 else if (Clipboard.ContainsFileDropList())
                 {
                     var files = Clipboard.GetFileDropList().Cast<string>().ToArray();
                     this.ImageSelector.BeginSelection();
-                    this.ImageSelector.AddMediaItemFromFilePath(files);
+                    this.ImageSelector.Model.AddMediaItemFromFilePath(files);
                 }
             }
             catch (ExternalException ex)