複雑なドキュメントの元に戻す/やり直しモデルの実装

こんにちはHabr! この記事では、アクションをキャンセル/リターンする機能を備えた複雑な構造を持つドキュメントを編集するためのモデルを編成する方法を示したいと思います。







背景と問題



すべては、私が高度に専門化されたアウトラインソフトウェアを書いたという事実から始まりました。その主なアイデアは、さまざまなエディターのさまざまなシーンで多数の仮想ペーパーカードを操作することです。







ある程度のカスタマイズとプラグインを備えたMS Visioに似ています。 ここには技術的な問題はありませんが、多くの機能があります。







まず、いくつかのシーンがあります。 これは、いくつかのウィンドウエディタがあり、それぞれが独自のルールに従って機能することを意味します。







第二に、なぜなら 1組のカードであり、同じカードを異なる場所で使用できるため、ドキュメントの異なる部分間に特定の関係が生じます。 また、カードが削除されると、このカードが関係するすべての場所からこのカードが削除されます。







第三に、私がやりたいことをすべてやり、その結果を友人(プログラマーでさえない)に見せたとき、彼は突っ込み、Ctrl + Zをするのがいいと言った。 私はこのアイデアに興奮しましたが、これを実現するのはそれほど簡単な作業ではありませんでした。 この記事では、結果として私がどうなったかを説明します。







既存のソリューション



もちろん、自分で何かをする前に、準備ができているものを見つけたいと思っていました。 問題のかなり詳細な分析は、「 元に戻す」と「やり直し」の分析と実装にあります。 しかし、結局のところ、一般的な原則と言葉に加えて、図書館のようなものを見つけることは困難です。







最初の最も明白な解決策は、変更ごとにドキュメントのバージョンを作成することです。 もちろん、それは信頼できますが、多くのスペースと不必要な操作を占有します。 したがって、このオプションはすぐに破棄されました。







もっと面白いように思い出パターン 。 ここでは、ドキュメント自体ではなく、ドキュメントの状態を使用することで、すでに少しのリソースを節約できます。 しかしこれもまた、特定の状況に依存します。 そして以来 私はすべてをC ++で書きましたが、ここでは何も得られません。 同時に、このパターンを実装するC ++テンプレートプロジェクトundoredo-cppもあります。







コマンドパターンは基本的に必要なものですが、残念ながら、普遍的な実装ではなく原則のみを見つけることができます。 したがって、それは基礎として採用されました。 そして、もちろん、最大のパフォーマンスを達成したかったため、データストレージの最小化につながりました。







したがって、実装レベルでどのように何を取得したいかがほぼ明確になりました。 そして、特定の目標を強調することが判明しました。







  1. システムには一連のエディターが含まれており、それぞれが独自のシーンを編集できます。
  2. 開いているエディタに影響を与えるドキュメントへの変更は、彼に通知する必要があり、編集者は可能な限り効率的にそれに応答する必要があります(ドキュメントシーンの完全な再構築を除く)。
  3. すべての変更はグローバルです。つまり、 現在どのエディタを使用していても、変更のスタックは一般的です。
  4. 最後のアクションをキャンセルして、元に戻す(元に戻す/やり直し)ができるはずです。
  5. 変更バッファのサイズは、設定とハードウェアリソース以外によって制限されるべきではありません。


また、すべてがQT5 / C ++ 11で作成されていることにも注意してください。







文書モデル



アクションが実行される主なエンティティはドキュメントです。 さまざまなアトミックアクションをドキュメントに適用できます。それらをプリミティブと呼びましょう。 アトミシティは、プリミティブを適用する前後に、ドキュメントが一貫した状態にあることを前提としています。







私の文書では、次のエンティティを強調しました(私のソフトウェアはシナリオ計画の概要、したがって詳細を意図していることに注意する必要があります):カード、キャラクター、ストーリーカード(カードを参照)、キャラクターカード(カードを参照)、ストーリーラインポイント(参照カード上の)、ストーリーライン(一連のストーリーカードを含む)などです。したがって、エンティティは相互に参照できます。これは、カードを参照するストーリーカードを作成するアクションを返す場合、将来的に問題の原因になる可能性があります Y、我々はすでにロールバックされているの作成。 つまり リンクを管理するための特定のメカニズムを頼みますが、それについては後で詳しく説明します。







プリミティブを選択すると、カードの作成、カードのテキストの変更、カードの削除、ストーリーカードの作成、ストーリーラインの作成、ストーリーラインのテキストの変更、ストーリーラインへのカードの追加などのセットが取得されます。エッセンス、それからエッセンス(カード、ストーリー展開、キャラクターなど)に対処することによってプリミティブの類型化を導入することは理にかなっています。







class outline_primitive { public: enum class entity_t { card, plot, act_break, outline_card, ...}; ... entity_t entity; document_t * pDoc; using ref_t = referenced_entity<outline_primitive>; std::vector<ref_t*> dependencies; };
      
      





依存関係属性に注意する必要があります-これらはプリミティブが参照する依存関係にすぎませんが、その目的については後ほど説明します。 また、プリミティブはタイプ別に分類できます。作成、 修正; 除去。







 enum class operation_t { create, modify, remove }; operation_t operation;
      
      





さらに、許容される変更に応じて、プリミティブを変更するとツリー全体が生成されます。たとえば、カードの移動、ストーリーラインへのカードの追加などです。







プリミティブは、順方向または逆方向に適用できます。 さらに、プリミティブの削除およびアサートのために、プリミティブがどの状態で適用またはロールバックされるかを保存すると便利です。







 virtual void outline_primitive::apply() { perform_check(!applied); applied = true; pDoc->unsavedChanges = true; } virtual void outline_primitive::revert() { perform_check(applied); applied = false; pDoc->unsavedChanges = true; } bool applied;
      
      





次に、最も単純なプリミティブ(カードの追加)の実装を検討します。







最も単純なプリミティブの実装



カード作成プリミティブの実装は次のようになります。 pDocの初期化など、明らかなルーチン操作は行いません。







 class OUTLINE_DOC_API card_create_primitive : public outline_primitive { index_card * pCard; index_card::data_t cardData; //    card_create_primitive::card_create_primitive(const index_card::data_t & _data); void apply() { _Base::apply(); auto p_card = new index_card; //   p_card->data = cardData; pDoc->cards.push_back(p_card); //   pCard = p_card; //   } void revert() { _Base::revert(); auto it = std::find(pdoc->cards.begin(), pdoc->cards.end(), pCard); perform_check(it != pdoc->cards.end()); //assert pDoc->cards.erase(it); //    delete pCard; //    pCard = nullptr; //       } }
      
      





いくつかのアサーションがコードに特別に追加され、プリミティブを適用する前後にドキュメントの一貫した状態を確認します。







参照整合性



次に、原始的なストーリーラインの作成を検討します。 実際、これは同じカードですが、ストーリーシートに配置され、座標を持っています。 つまり ストーリーカードを参照し、追加の属性(座標)が含まれています。







したがって、一連のプリミティブがあるとします-カードを作成し、それに基づいてストーリーカードを作成します。 次に、2番目のプリミティブを最初に参照する必要がありますが、リンクがキャンセルおよび復元された場合(カード自体の同時削除/再作成により)リンクを更新できるようにします。







このために、依存関係のリストですでに満たされている、特殊なエンティティreference_entityが導入されています。







 template<typename T> struct referenced_entity { using primitive_t = T; using entity_ptr = void; referenced_entity(primitive_t * prim, entity_ptr * p_ent) { ... prim->dependencies.push_back(this); //      } entity_ptr * get() const { if (!parent) return entity; else { auto cur_ref = this; while (cur_ref->parent) cur_ref = &(cur_ref->parent->baseEntity); return cur_ref->entity; } } primitive_t * parent; entity_ptr * entity; };
      
      





ここで重要な点は、プリミティブの依存関係リストに自分自身を置くことです。 したがって、誰かが既にreferenced_entityのコンテンツを参照している場合、プリミティブがバッファに配置されたときに接続を復元し、この接続に基づいて、get()メソッドを使用してオブジェクトの現在のアドレスへのポインタを取得できます。







プリミティブ処理



プリミティブを処理するために、command_bufferという特別なエンティティが導入されています。 彼女のタスクは次のとおりです。









 class command_buffer { using primitive_id_sequence_t = std::vector<unsigned>; std::vector<primitive_t*> data; std::map<void*, primitive_id_sequence_t> front; };
      
      





プリミティブは、ユーザーが作成した順序でデータに保存されます。 そして前-いわゆる参照オブジェクトの前。 新しいプリミティブがバッファに入ると、baseEntityに格納されているオブジェクトのチェーンの最後の要素になります。 そして、リンクが行われます。







 void command_buffer::submit(primitive_t * new_prim) { discard_horizon(); //      // ,   for (auto & dep : new_prim->dependencies) { auto front_it = front.find(dep->entity); if (front_it != front.end()) dep->reset_parent(data[*front_it->second.rbegin()]); } unsigned new_id = add_action(new_prim); //  data   //       if (new_prim->operation == primitive_t::operation_t::create) { new_prim->apply(pDoc); primitive_id_sequence_t new_seq; new_seq.push_back(new_id); front.insert(make_pair(new_prim->baseEntity.get(), new_seq)); } else //     - ,      { auto front_it = front.find(new_prim->baseEntity.get()); if (front_it == front.end()) { primitive_id_sequence_t new_seq; new_seq.push_back(new_id); front.insert(make_pair(new_prim->baseEntity.get(), new_seq)); new_prim->apply(pDoc); } else { auto & seq = front_it->second; perform_check(!seq.empty()); seq.push_back(new_id); new_prim->apply(pDoc); } } }
      
      





他のすべてのバッファメソッドはかなり簡単で、元に戻す()およびやり直し()も含まれます。 したがって、command_bufferはドキュメントの一貫性のある状態を提供しますが、対応するエディターによって形成されたビューをどのように正しい状態に維持できるのかという疑問が残ります。







相互作用モデル



これを行うには、新しいエンティティ(イベント)を導入する必要があり、開いている各エディターは、対応するタイプのイベントに正しく応答する必要があります。 イベントは、プリミティブの適用に関連付けられます-適用前、適用後、ロールバック前、ロールバック後。 たとえば、アプリケーションの作成後、ロールバックの前に、作成プリミティブ(アプリケーションの前にオブジェクトがないため)に反応することができます-同じ作成プリミティブに ロールバック後、リンクは失われます。







 template<typename T> struct primitive_event { using primitive_t = T; enum class kind_t {pre_applied, post_applied, pre_reverted, post_reverted}; kind_t kind; primitive_t * primitive; };
      
      





これらのイベントは、プリミティブに対する4つの操作の後に送信されます。 したがって、各エディターでは、これらのイベントに応答するハンドラーを作成し、それに応じてシーンを最小限に再構築する必要があります。







 void my_editor::event_occured(event_t * event) { switch..case }
      
      





ここでは、3階建てのスイッチを作成する必要があります。本質、操作、およびイベントのケースは、ひどく見えます。 これを行うには、各要素を整数に変換できるという事実に基づいたトリックを利用し、そのようなマクロを導入します。







 #define PRIMITIVE_EVENT_ID(entity, operation, event) ((unsigned char)entity << 16) | ((unsigned char)operation << 8) | (unsigned char)event
      
      





次に、このメソッドの本体はこの形式を取り、認識の容易さを損なうことなく、新しいプリミティブが表示されるときに追加できます。







 switch (PRIMITIVE_EVENT_ID(event->primitive->entity, event->primitive->operation, event->kind)) { case PRIMITIVE_EVENT_ID(outline_primitive::entity_t::collision, outline_primitive::operation_t::create, event_t::kind_t::post_applied): case PRIMITIVE_EVENT_ID(outline_primitive::entity_t::collision, outline_primitive::operation_t::remove, event_t::kind_t::post_reverted): { auto p_collision = static_cast<collision_t*>(event->primitive->baseEntity.get()); pScene->create_image(p_collision); break; } ... }
      
      





確かに、あるエンティティの修正プリミティブのタイプの階層が大きくなった場合、その内部で新しいブランチを作成する必要があることに注意してください。







そして、それは本当に機能します



説明した方法は、私のドキュメントモデルに限定されず、さまざまなドキュメントモデルで使用できます。 誰かがこれの実際の動作に興味がある場合は、コンパイルされたアプリケーション自体をultra_outlinerページからダウンロードできます。







おわりに



提案された方法の枠組みでは、1つの重要な問題が未解決のままでした。 ドキュメントに対するユーザーアクションのほとんどは本当にアトミックですが、それらのいくつかは一度にいくつかのプリミティブを生成します。 たとえば、ユーザーがカードを移動する場合、これは1つのプリミティブです。 また、3つの方法でカードを削除する場合、これらはチェーンからカードを削除し、フィールドからカードを除外してからカード自体を削除するための3つのプリミティブです。 このようなチェーンがロールバックされる場合、1回のロールバックアクションでは1つのプリミティブのみがポンプアウトされますが、すべてを一度にロールバックすることは論理的です。 これにはメソッドの改良が必要ですが、次の記事でこの問題を検討します。








All Articles